Skip to main content

agentos_native_sidecar/
service.rs

1use crate::bridge::{build_mount_plugin_registry, MountPluginContext};
2pub(crate) use crate::execution::{
3    build_javascript_socket_path_context, canonical_signal_name, dispatch_loopback_http_request,
4    error_code, format_tcp_resource, ignore_stale_javascript_sync_rpc_response,
5    javascript_sync_rpc_arg_i32, javascript_sync_rpc_arg_str, javascript_sync_rpc_arg_u32,
6    javascript_sync_rpc_arg_u32_optional, javascript_sync_rpc_arg_u64,
7    javascript_sync_rpc_arg_u64_optional, javascript_sync_rpc_bytes_arg,
8    javascript_sync_rpc_bytes_value, javascript_sync_rpc_encoding, javascript_sync_rpc_error_code,
9    javascript_sync_rpc_option_bool, javascript_sync_rpc_option_u32, kernel_poll_response,
10    kernel_stdin_read_response, mark_execute_exit_event_queued, parse_kernel_poll_args,
11    parse_kernel_stdin_read_args, parse_signal, record_execute_exit_event_queue_wait,
12    record_execute_phase, sanitize_javascript_child_process_internal_bootstrap_env,
13    service_javascript_sync_rpc, vm_network_resource_counts, JavascriptSyncRpcServiceRequest,
14    LoopbackHttpDispatchRequest,
15};
16use crate::extension::{
17    Extension, ExtensionBufferedProcessOutput, ExtensionContext, ExtensionFuture, ExtensionHost,
18    ExtensionSnapshot,
19};
20use crate::filesystem::guest_filesystem_call as filesystem_guest_filesystem_call;
21use crate::limits::DEFAULT_ACP_STDOUT_BUFFER_BYTE_LIMIT;
22use crate::protocol::{
23    CloseStdinRequest, DisposeReason, EventFrame, EventPayload, ExecuteRequest, ExtEnvelope,
24    GuestFilesystemCallRequest, GuestFilesystemResultResponse, JavascriptChildProcessSpawnOptions,
25    JavascriptChildProcessSpawnRequest, KillProcessRequest, OpenSessionRequest, OwnershipScope,
26    ProcessKilledResponse, ProcessStartedResponse, RequestFrame, RequestId, RequestPayload,
27    ResponseFrame, ResponsePayload, SidecarRequestFrame, SidecarRequestPayload,
28    SidecarResponseFrame, SidecarResponsePayload, SidecarResponseTracker,
29    SidecarResponseTrackerError, SignalDispositionAction, StdinClosedResponse,
30    StdinWrittenResponse, VmLifecycleState, WriteStdinRequest,
31};
32use crate::state::{
33    ActiveExecutionEvent, BridgeError, ConnectionState, EventSinkTransport, JavascriptSocketFamily,
34    JavascriptSocketPathContext, ProcessEventEnvelope, SessionState, SharedBridge, SharedEventSink,
35    SharedSidecarRequestClient, SidecarRequestTransport, VmState, EXECUTION_DRIVER_NAME,
36};
37use crate::tools::register_host_callbacks;
38use crate::NativeSidecarBridge;
39use agentos_bridge::queue_tracker::{register_queue, QueueGauge, TrackedLimit};
40use agentos_bridge::{
41    CommandPermissionRequest, EnvironmentAccess, EnvironmentPermissionRequest, FilesystemAccess,
42    FilesystemPermissionRequest, LifecycleEventRecord, LifecycleState, LogLevel, LogRecord,
43    NetworkAccess, NetworkPermissionRequest, StructuredEventRecord,
44};
45use agentos_execution::{
46    record_sync_bridge_request_observed, JavascriptExecutionEngine, JavascriptExecutionError,
47    JavascriptSyncRpcRequest, PythonExecutionEngine, PythonExecutionError, WasmExecutionEngine,
48    WasmExecutionError,
49};
50use agentos_kernel::kernel::KernelError;
51use agentos_kernel::mount_plugin::{FileSystemPluginRegistry, PluginError};
52use agentos_kernel::permissions::{
53    CommandAccessRequest, EnvAccessRequest, EnvironmentOperation, NetworkAccessRequest,
54    NetworkOperation, PermissionDecision,
55};
56use agentos_native_sidecar_core::permissions::{
57    deny_all_policy, environment_permission_capability, evaluate_permissions_policy,
58    filesystem_permission_capability, network_permission_capability,
59    permission_mode_to_kernel_decision,
60};
61use agentos_native_sidecar_core::{
62    apply_process_signal_state_update, authenticated_response as shared_authenticated_response,
63    parse_process_signal_state_request, reject as shared_reject, request_dispatch_mode,
64    respond as shared_respond, route_request_payload, session_opened_response,
65    unsupported_host_callback_direction_dispatch, validate_authenticate_versions,
66    vm_lifecycle_event as shared_vm_lifecycle_event, AuthenticateVersionError, RequestDispatchMode,
67    RequestRoute,
68};
69use agentos_vm_config::PermissionsPolicy;
70// root_fs types moved to crate::vm
71use agentos_kernel::vfs::VfsError;
72use serde::Deserialize;
73use serde_json::{json, Value};
74use std::collections::{BTreeMap, BTreeSet, VecDeque};
75use std::fmt;
76use std::fs;
77use std::os::unix::fs::PermissionsExt;
78use std::path::{Component, Path, PathBuf};
79use std::sync::{Arc, Mutex, OnceLock};
80use std::task::{Context, Poll, Waker};
81use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
82use tokio::sync::mpsc::{channel, Receiver, Sender};
83use tokio::time;
84
85// Constants and type aliases moved to crate::state
86
87const INTERNAL_JAVASCRIPT_ENTRYPOINT_ENV_KEYS: &[&str] =
88    &["AGENTOS_ENTRYPOINT", "AGENTOS_BOOTSTRAP_MODULE"];
89const INTERNAL_WASM_ENTRYPOINT_ENV_KEYS: &[&str] =
90    &["AGENTOS_WASM_MODULE_PATH", "AGENTOS_WASM_MODULE_BASE64"];
91const INTERNAL_PYTHON_ENTRYPOINT_ENV_PREFIXES: &[&str] = &["AGENTOS_PYTHON_"];
92pub(crate) const MAX_PROCESS_EVENT_QUEUE: usize = 10_000;
93pub(crate) const MAX_PENDING_SIDECAR_RESPONSES: usize = 10_000;
94pub(crate) const MAX_OUTBOUND_SIDECAR_REQUESTS: usize = 10_000;
95pub(crate) const MAX_COMPLETED_SIDECAR_RESPONSES: usize = 10_000;
96static BLOCKING_DISPATCH_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
97
98fn blocking_dispatch_runtime() -> &'static tokio::runtime::Runtime {
99    BLOCKING_DISPATCH_RUNTIME.get_or_init(|| {
100        tokio::runtime::Builder::new_current_thread()
101            .enable_all()
102            .build()
103            .expect("sidecar blocking dispatch runtime")
104    })
105}
106
107pub(crate) fn process_event_queue_overflow_error() -> SidecarError {
108    SidecarError::InvalidState(format!(
109        "process event queue exceeded {MAX_PROCESS_EVENT_QUEUE} pending events"
110    ))
111}
112
113fn sidecar_response_pending_overflow_error() -> SidecarError {
114    SidecarError::InvalidState(format!(
115        "sidecar response tracker exceeded {MAX_PENDING_SIDECAR_RESPONSES} pending responses"
116    ))
117}
118
119fn outbound_sidecar_request_queue_overflow_error() -> SidecarError {
120    SidecarError::InvalidState(format!(
121        "outbound sidecar request queue exceeded {MAX_OUTBOUND_SIDECAR_REQUESTS} pending requests"
122    ))
123}
124
125fn wire_protocol_error(error: crate::wire::ProtocolCodecError) -> SidecarError {
126    SidecarError::InvalidState(format!("invalid generated wire protocol frame: {error}"))
127}
128
129fn wire_dispatch_result(
130    result: DispatchResult,
131) -> Result<crate::wire::WireDispatchResult, SidecarError> {
132    crate::wire::dispatch_result_from_compat(crate::wire::CompatDispatchResult {
133        response: result.response,
134        events: result.events,
135    })
136    .map_err(wire_protocol_error)
137}
138
139pub use agentos_native_sidecar_core::DispatchResult;
140// NativeSidecarConfig and SidecarError moved to crate::state
141pub use crate::state::{NativeSidecarConfig, SidecarError};
142
143// SharedBridge struct and Clone impl moved to crate::state
144
145#[derive(Debug, Default, Deserialize)]
146struct LegacyJavascriptChildProcessSpawnOptions {
147    #[serde(default)]
148    cwd: Option<String>,
149    #[serde(default)]
150    env: BTreeMap<String, String>,
151    #[serde(default)]
152    input: Option<Value>,
153    #[serde(default)]
154    shell: bool,
155    #[serde(default)]
156    detached: bool,
157    #[serde(default)]
158    stdio: Vec<String>,
159    #[serde(default, rename = "maxBuffer")]
160    max_buffer: Option<usize>,
161    #[serde(default)]
162    timeout: Option<u64>,
163    #[serde(default, rename = "killSignal")]
164    kill_signal: Option<String>,
165}
166
167#[derive(Debug, Deserialize)]
168#[serde(rename_all = "camelCase")]
169struct JavascriptHttpLoopbackRequest {
170    process_id: String,
171    server_id: u64,
172    host: String,
173    port: u16,
174    request: String,
175}
176
177fn is_javascript_loopback_host(host: &str) -> bool {
178    host == "127.0.0.1" || host == "::1" || host.eq_ignore_ascii_case("localhost")
179}
180
181pub(crate) fn parse_javascript_child_process_spawn_request(
182    vm: &VmState,
183    args: &[Value],
184) -> Result<(JavascriptChildProcessSpawnRequest, Option<usize>), SidecarError> {
185    if let Some(value) = args.first().cloned() {
186        if let Ok(request) = serde_json::from_value::<JavascriptChildProcessSpawnRequest>(value) {
187            return Ok((request, None));
188        }
189    }
190
191    let command = javascript_sync_rpc_arg_str(args, 0, "child_process.spawn command")?.to_owned();
192    let raw_args = javascript_sync_rpc_arg_str(args, 1, "child_process.spawn args")?;
193    let raw_options = javascript_sync_rpc_arg_str(args, 2, "child_process.spawn options")?;
194
195    let parsed_args = serde_json::from_str::<Vec<String>>(raw_args).map_err(|error| {
196        SidecarError::InvalidState(format!("invalid child_process.spawn args payload: {error}"))
197    })?;
198    let parsed_options = serde_json::from_str::<LegacyJavascriptChildProcessSpawnOptions>(
199        raw_options,
200    )
201    .map_err(|error| {
202        SidecarError::InvalidState(format!(
203            "invalid child_process.spawn options payload: {error}"
204        ))
205    })?;
206
207    Ok((
208        JavascriptChildProcessSpawnRequest {
209            command,
210            args: parsed_args,
211            options: JavascriptChildProcessSpawnOptions {
212                cwd: parsed_options.cwd,
213                env: parsed_options.env,
214                internal_bootstrap_env: sanitize_javascript_child_process_internal_bootstrap_env(
215                    &vm.guest_env,
216                ),
217                input: parsed_options.input,
218                shell: parsed_options.shell,
219                detached: parsed_options.detached,
220                stdio: parsed_options.stdio,
221                timeout: parsed_options.timeout,
222                kill_signal: parsed_options.kill_signal,
223            },
224        },
225        parsed_options.max_buffer,
226    ))
227}
228
229impl<B> SharedBridge<B> {
230    fn new(bridge: B) -> Self {
231        Self {
232            inner: Arc::new(Mutex::new(bridge)),
233            permissions: Arc::new(Mutex::new(BTreeMap::new())),
234            #[cfg(test)]
235            set_vm_permissions_outcomes: Arc::new(Mutex::new(VecDeque::new())),
236        }
237    }
238}
239
240impl<B> SharedBridge<B>
241where
242    B: NativeSidecarBridge + Send + 'static,
243    BridgeError<B>: fmt::Debug + Send + Sync + 'static,
244{
245    pub(crate) fn with_mut<T>(
246        &self,
247        operation: impl FnOnce(&mut B) -> Result<T, BridgeError<B>>,
248    ) -> Result<T, SidecarError> {
249        let mut bridge = self.inner.lock().map_err(|_| {
250            SidecarError::Bridge(String::from("native sidecar bridge lock poisoned"))
251        })?;
252        operation(&mut bridge).map_err(|error| SidecarError::Bridge(format!("{error:?}")))
253    }
254
255    fn inspect<T>(&self, operation: impl FnOnce(&mut B) -> T) -> Result<T, SidecarError> {
256        let mut bridge = self.inner.lock().map_err(|_| {
257            SidecarError::Bridge(String::from("native sidecar bridge lock poisoned"))
258        })?;
259        Ok(operation(&mut bridge))
260    }
261
262    #[cfg(test)]
263    #[allow(dead_code)]
264    pub(crate) fn queue_set_vm_permissions_result(
265        &self,
266        result: Result<(), SidecarError>,
267    ) -> Result<(), SidecarError> {
268        let mut outcomes = self.set_vm_permissions_outcomes.lock().map_err(|_| {
269            SidecarError::Bridge(String::from(
270                "native sidecar test set_vm_permissions outcome lock poisoned",
271            ))
272        })?;
273        outcomes.push_back(result.err());
274        Ok(())
275    }
276
277    pub(crate) fn emit_lifecycle(
278        &self,
279        vm_id: &str,
280        state: LifecycleState,
281    ) -> Result<(), SidecarError> {
282        self.with_mut(|bridge| {
283            bridge.emit_lifecycle(LifecycleEventRecord {
284                vm_id: vm_id.to_owned(),
285                state,
286                detail: None,
287            })
288        })
289    }
290
291    pub(crate) fn emit_log(
292        &self,
293        vm_id: &str,
294        message: impl Into<String>,
295    ) -> Result<(), SidecarError> {
296        self.with_mut(|bridge| {
297            bridge.emit_log(LogRecord {
298                vm_id: vm_id.to_owned(),
299                level: LogLevel::Info,
300                message: message.into(),
301            })
302        })
303    }
304
305    pub(crate) fn filesystem_decision(
306        &self,
307        vm_id: &str,
308        path: &str,
309        access: FilesystemAccess,
310    ) -> PermissionDecision {
311        if let Some(decision) = self.static_permission_decision(
312            vm_id,
313            filesystem_permission_capability(access),
314            "fs",
315            Some(path),
316        ) {
317            return decision;
318        }
319        match self.with_mut(|bridge| {
320            bridge.check_filesystem_access(FilesystemPermissionRequest {
321                vm_id: vm_id.to_owned(),
322                path: path.to_owned(),
323                access,
324            })
325        }) {
326            Ok(decision) => map_bridge_permission(decision),
327            Err(error) => PermissionDecision::deny(error.to_string()),
328        }
329    }
330
331    pub(crate) fn command_decision(
332        &self,
333        vm_id: &str,
334        request: &CommandAccessRequest,
335    ) -> PermissionDecision {
336        if is_internal_runtime_command_request(request) {
337            return PermissionDecision::allow();
338        }
339        if let Some(decision) = self.static_permission_decision(
340            vm_id,
341            "child_process.spawn",
342            "child_process",
343            Some(&request.command),
344        ) {
345            return decision;
346        }
347        match self.with_mut(|bridge| {
348            bridge.check_command_execution(CommandPermissionRequest {
349                vm_id: vm_id.to_owned(),
350                command: request.command.clone(),
351                args: request.args.clone(),
352                cwd: request.cwd.clone(),
353                env: request.env.clone(),
354            })
355        }) {
356            Ok(decision) => map_bridge_permission(decision),
357            Err(error) => PermissionDecision::deny(error.to_string()),
358        }
359    }
360
361    pub(crate) fn environment_decision(
362        &self,
363        vm_id: &str,
364        request: &EnvAccessRequest,
365    ) -> PermissionDecision {
366        if let Some(decision) = self.static_permission_decision(
367            vm_id,
368            environment_permission_capability(request.op),
369            "env",
370            Some(&request.key),
371        ) {
372            return decision;
373        }
374        match self.with_mut(|bridge| {
375            bridge.check_environment_access(EnvironmentPermissionRequest {
376                vm_id: vm_id.to_owned(),
377                access: match request.op {
378                    EnvironmentOperation::Read => EnvironmentAccess::Read,
379                    EnvironmentOperation::Write => EnvironmentAccess::Write,
380                },
381                key: request.key.clone(),
382                value: request.value.clone(),
383            })
384        }) {
385            Ok(decision) => map_bridge_permission(decision),
386            Err(error) => PermissionDecision::deny(error.to_string()),
387        }
388    }
389
390    pub(crate) fn network_decision(
391        &self,
392        vm_id: &str,
393        request: &NetworkAccessRequest,
394    ) -> PermissionDecision {
395        if let Some(decision) = self.static_permission_decision(
396            vm_id,
397            network_permission_capability(request.op),
398            "network",
399            Some(&request.resource),
400        ) {
401            return decision;
402        }
403        match self.with_mut(|bridge| {
404            bridge.check_network_access(NetworkPermissionRequest {
405                vm_id: vm_id.to_owned(),
406                access: match request.op {
407                    NetworkOperation::Fetch => NetworkAccess::Fetch,
408                    NetworkOperation::Http => NetworkAccess::Http,
409                    NetworkOperation::Dns => NetworkAccess::Dns,
410                    NetworkOperation::Listen => NetworkAccess::Listen,
411                },
412                resource: request.resource.clone(),
413            })
414        }) {
415            Ok(decision) => map_bridge_permission(decision),
416            Err(error) => PermissionDecision::deny(error.to_string()),
417        }
418    }
419
420    pub(crate) fn require_network_access(
421        &self,
422        vm_id: &str,
423        op: NetworkOperation,
424        resource: impl Into<String>,
425    ) -> Result<(), SidecarError> {
426        let resource = resource.into();
427        let decision = self.network_decision(
428            vm_id,
429            &NetworkAccessRequest {
430                vm_id: vm_id.to_owned(),
431                op,
432                resource: resource.clone(),
433            },
434        );
435        if decision.allow {
436            return Ok(());
437        }
438
439        let message = match decision.reason.as_deref() {
440            Some(reason) => format!("EACCES: permission denied, {resource}: {reason}"),
441            None => format!("EACCES: permission denied, {resource}"),
442        };
443        Err(SidecarError::Execution(message))
444    }
445
446    pub(crate) fn set_vm_permissions(
447        &self,
448        vm_id: &str,
449        permissions: &PermissionsPolicy,
450    ) -> Result<(), SidecarError> {
451        #[cfg(test)]
452        {
453            let mut outcomes = self.set_vm_permissions_outcomes.lock().map_err(|_| {
454                SidecarError::Bridge(String::from(
455                    "native sidecar test set_vm_permissions outcome lock poisoned",
456                ))
457            })?;
458            if let Some(Some(error)) = outcomes.pop_front() {
459                return Err(error);
460            }
461        }
462
463        let mut stored = self.permissions.lock().map_err(|_| {
464            SidecarError::Bridge(String::from(
465                "native sidecar permission policy lock poisoned",
466            ))
467        })?;
468        stored.insert(vm_id.to_owned(), permissions.clone());
469        Ok(())
470    }
471
472    pub(crate) fn restore_vm_permissions_fail_closed(
473        &self,
474        vm_id: &str,
475        original_permissions: &PermissionsPolicy,
476        context: &str,
477        operation_error: &SidecarError,
478    ) -> Result<(), SidecarError> {
479        match self.set_vm_permissions(vm_id, original_permissions) {
480            Ok(()) => Ok(()),
481            Err(restore_error) => {
482                let deny_all = deny_all_policy();
483                match self.set_vm_permissions(vm_id, &deny_all) {
484                    Ok(()) => Err(SidecarError::InvalidState(format!(
485                        "{context} failed: {operation_error}; restoring original permissions failed: {restore_error}; applied deny-all fallback"
486                    ))),
487                    Err(deny_all_error) => panic!(
488                        "{context} failed: {operation_error}; restoring original permissions failed: {restore_error}; deny-all fallback failed: {deny_all_error}"
489                    ),
490                }
491            }
492        }
493    }
494
495    pub(crate) fn clear_vm_permissions(&self, vm_id: &str) -> Result<(), SidecarError> {
496        let mut stored = self.permissions.lock().map_err(|_| {
497            SidecarError::Bridge(String::from(
498                "native sidecar permission policy lock poisoned",
499            ))
500        })?;
501        stored.remove(vm_id);
502        Ok(())
503    }
504
505    pub(crate) fn static_permission_decision(
506        &self,
507        vm_id: &str,
508        capability: &str,
509        domain: &str,
510        resource: Option<&str>,
511    ) -> Option<PermissionDecision> {
512        let stored = self.permissions.lock().ok()?;
513        let permissions = stored.get(vm_id)?;
514        let mode = evaluate_permissions_policy(permissions, domain, capability, resource);
515        Some(permission_mode_to_kernel_decision(mode, capability))
516    }
517}
518
519pub(crate) fn validate_permissions_policy(
520    permissions: &PermissionsPolicy,
521) -> Result<(), SidecarError> {
522    agentos_native_sidecar_core::permissions::validate_permissions_policy(permissions)
523        .map_err(|error| SidecarError::InvalidState(error.to_string()))
524}
525
526fn is_internal_runtime_command_request(request: &CommandAccessRequest) -> bool {
527    match request.command.as_str() {
528        "node" => request
529            .env
530            .keys()
531            .any(|key| INTERNAL_JAVASCRIPT_ENTRYPOINT_ENV_KEYS.contains(&key.as_str())),
532        "wasm" => request
533            .env
534            .keys()
535            .any(|key| INTERNAL_WASM_ENTRYPOINT_ENV_KEYS.contains(&key.as_str())),
536        "python" => request.env.keys().any(|key| {
537            INTERNAL_PYTHON_ENTRYPOINT_ENV_PREFIXES
538                .iter()
539                .any(|prefix| key.starts_with(prefix))
540        }),
541        _ => false,
542    }
543}
544
545fn ownership_matches_process_event(
546    ownership: &OwnershipScope,
547    event: &ProcessEventEnvelope,
548) -> bool {
549    match ownership {
550        OwnershipScope::ConnectionOwnership(inner) => inner.connection_id == event.connection_id,
551        OwnershipScope::SessionOwnership(inner) => {
552            inner.connection_id == event.connection_id && inner.session_id == event.session_id
553        }
554        OwnershipScope::VmOwnership(inner) => {
555            inner.connection_id == event.connection_id
556                && inner.session_id == event.session_id
557                && inner.vm_id == event.vm_id
558        }
559    }
560}
561
562fn public_process_event_matches_ownership<B>(
563    sidecar: &NativeSidecar<B>,
564    ownership: &OwnershipScope,
565    event: &ProcessEventEnvelope,
566) -> bool
567where
568    B: NativeSidecarBridge + Send + 'static,
569    BridgeError<B>: fmt::Debug + Send + Sync + 'static,
570{
571    if !ownership_matches_process_event(ownership, event) {
572        return false;
573    }
574
575    if event.process_id.contains('/') {
576        return false;
577    }
578
579    // Stale queued events must still be drained through handle_process_event_envelope()
580    // so the sidecar can emit the expected fail-closed log when teardown wins the race.
581    let _ = sidecar;
582    true
583}
584
585fn poll_future_once<F: std::future::Future>(future: std::pin::Pin<&mut F>) -> Option<F::Output> {
586    let mut context = Context::from_waker(Waker::noop());
587    match future.poll(&mut context) {
588        Poll::Ready(output) => Some(output),
589        Poll::Pending => None,
590    }
591}
592
593// ConnectionState, SessionState, VmConfiguration, VmState moved to crate::state
594
595// JavascriptSocketPathContext, JavascriptSocketFamily, VmListenPolicy moved to crate::state
596
597impl JavascriptSocketPathContext {
598    pub(crate) fn loopback_port_allowed(&self, port: u16) -> bool {
599        self.loopback_exempt_ports.contains(&port)
600            || self
601                .tcp_loopback_guest_to_host_ports
602                .keys()
603                .any(|(_, guest_port)| *guest_port == port)
604    }
605
606    pub(crate) fn translate_tcp_loopback_port(
607        &self,
608        family: JavascriptSocketFamily,
609        port: u16,
610    ) -> Option<u16> {
611        self.tcp_loopback_guest_to_host_ports
612            .get(&(family, port))
613            .copied()
614    }
615
616    pub(crate) fn http_loopback_target(
617        &self,
618        family: JavascriptSocketFamily,
619        port: u16,
620    ) -> Option<&crate::state::JavascriptHttpLoopbackTarget> {
621        self.http_loopback_targets.get(&(family, port))
622    }
623
624    pub(crate) fn translate_udp_loopback_port(
625        &self,
626        family: JavascriptSocketFamily,
627        port: u16,
628    ) -> Option<u16> {
629        self.udp_loopback_guest_to_host_ports
630            .get(&(family, port))
631            .copied()
632    }
633
634    pub(crate) fn guest_udp_port_for_host_port(
635        &self,
636        family: JavascriptSocketFamily,
637        port: u16,
638    ) -> Option<u16> {
639        self.udp_loopback_host_to_guest_ports
640            .get(&(family, port))
641            .copied()
642    }
643}
644
645// ActiveProcess, NetworkResourceCounts moved to crate::state
646
647pub struct NativeSidecar<B> {
648    pub(crate) config: NativeSidecarConfig,
649    pub(crate) bridge: SharedBridge<B>,
650    pub(crate) mount_plugins: FileSystemPluginRegistry<MountPluginContext<B>>,
651    pub(crate) cache_root: PathBuf,
652    pub(crate) javascript_engine: JavascriptExecutionEngine,
653    pub(crate) python_engine: PythonExecutionEngine,
654    pub(crate) wasm_engine: WasmExecutionEngine,
655    pub(crate) next_connection_id: usize,
656    pub(crate) next_session_id: usize,
657    pub(crate) next_vm_id: usize,
658    pub(crate) next_sidecar_request_id: RequestId,
659    pub(crate) connections: BTreeMap<String, ConnectionState>,
660    pub(crate) sessions: BTreeMap<String, SessionState>,
661    pub(crate) vms: BTreeMap<String, VmState>,
662    #[allow(dead_code)]
663    pub(crate) process_event_sender: Sender<ProcessEventEnvelope>,
664    pub(crate) process_event_receiver: Option<Receiver<ProcessEventEnvelope>>,
665    pub(crate) pending_process_events: VecDeque<ProcessEventEnvelope>,
666    pub(crate) pending_sidecar_responses: SidecarResponseTracker,
667    pub(crate) outbound_sidecar_requests: VecDeque<SidecarRequestFrame>,
668    pub(crate) completed_sidecar_responses: BTreeMap<RequestId, SidecarResponseFrame>,
669    pub(crate) completed_sidecar_response_order: VecDeque<RequestId>,
670    pub(crate) completed_sidecar_responses_gauge: Arc<QueueGauge>,
671    pub(crate) pending_process_events_gauge: Arc<QueueGauge>,
672    pub(crate) pending_sidecar_responses_gauge: Arc<QueueGauge>,
673    pub(crate) outbound_sidecar_requests_gauge: Arc<QueueGauge>,
674    pub(crate) sidecar_requests: SharedSidecarRequestClient,
675    pub(crate) event_sink: SharedEventSink,
676    pub(crate) extensions: BTreeMap<String, Arc<dyn Extension>>,
677    pub(crate) extension_sessions: BTreeMap<(String, String), ExtensionSessionResources>,
678    pub(crate) extension_process_output_buffers:
679        BTreeMap<(String, String), ExtensionBufferedProcessOutput>,
680    /// Session scopes (connection_id, session_id) disposed since the stdio
681    /// transport last drained them. Lets the transport remove dead sessions from
682    /// its active-session set instead of iterating them forever (M5).
683    pub(crate) disposed_sessions: Vec<(String, String)>,
684}
685
686#[derive(Debug)]
687pub(crate) struct ExtensionSessionResources {
688    pub(crate) ownership: OwnershipScope,
689    pub(crate) process_ids: BTreeSet<String>,
690    pub(crate) vm_ids: BTreeSet<String>,
691}
692
693impl<B> fmt::Debug for NativeSidecar<B> {
694    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
695        f.debug_struct("NativeSidecar")
696            .field("config", &self.config)
697            .field("cache_root", &self.cache_root)
698            .field("next_connection_id", &self.next_connection_id)
699            .field("next_session_id", &self.next_session_id)
700            .field("next_vm_id", &self.next_vm_id)
701            .field("connection_count", &self.connections.len())
702            .field("session_count", &self.sessions.len())
703            .field("vm_count", &self.vms.len())
704            .field("extension_session_count", &self.extension_sessions.len())
705            .field(
706                "extension_process_output_buffer_count",
707                &self.extension_process_output_buffers.len(),
708            )
709            .finish()
710    }
711}
712
713impl<B> NativeSidecar<B>
714where
715    B: NativeSidecarBridge + Send + 'static,
716    BridgeError<B>: fmt::Debug + Send + Sync + 'static,
717{
718    pub fn new(bridge: B) -> Result<Self, SidecarError> {
719        Self::with_config(bridge, NativeSidecarConfig::default())
720    }
721
722    pub fn with_config(bridge: B, config: NativeSidecarConfig) -> Result<Self, SidecarError> {
723        if matches!(config.expected_auth_token.as_deref(), Some("")) {
724            return Err(SidecarError::InvalidState(String::from(
725                "native sidecar expected_auth_token must not be empty",
726            )));
727        }
728
729        let cache_root = config.compile_cache_root.clone().unwrap_or_else(|| {
730            std::env::temp_dir().join(format!(
731                "{}-{}",
732                config.sidecar_id,
733                SystemTime::now()
734                    .duration_since(UNIX_EPOCH)
735                    .expect("system time before unix epoch")
736                    .as_nanos()
737            ))
738        });
739        fs::create_dir_all(&cache_root).map_err(|error| {
740            SidecarError::Io(format!("failed to prepare sidecar cache root: {error}"))
741        })?;
742
743        let bridge = SharedBridge::new(bridge);
744        let mount_plugins = build_mount_plugin_registry::<B>()?;
745        let (process_event_sender, process_event_receiver) = channel(MAX_PROCESS_EVENT_QUEUE);
746
747        Ok(Self {
748            config,
749            bridge,
750            mount_plugins,
751            cache_root,
752            javascript_engine: JavascriptExecutionEngine::default(),
753            python_engine: PythonExecutionEngine::default(),
754            wasm_engine: WasmExecutionEngine::default(),
755            next_connection_id: 0,
756            next_session_id: 0,
757            next_vm_id: 0,
758            next_sidecar_request_id: -1,
759            connections: BTreeMap::new(),
760            sessions: BTreeMap::new(),
761            vms: BTreeMap::new(),
762            process_event_sender,
763            process_event_receiver: Some(process_event_receiver),
764            pending_process_events: VecDeque::new(),
765            pending_sidecar_responses: SidecarResponseTracker::default(),
766            outbound_sidecar_requests: VecDeque::new(),
767            completed_sidecar_responses: BTreeMap::new(),
768            completed_sidecar_response_order: VecDeque::new(),
769            completed_sidecar_responses_gauge: register_queue(
770                TrackedLimit::CompletedSidecarResponses,
771                MAX_COMPLETED_SIDECAR_RESPONSES,
772            ),
773            pending_process_events_gauge: register_queue(
774                TrackedLimit::PendingProcessEvents,
775                MAX_PROCESS_EVENT_QUEUE,
776            ),
777            pending_sidecar_responses_gauge: register_queue(
778                TrackedLimit::PendingSidecarResponses,
779                MAX_PENDING_SIDECAR_RESPONSES,
780            ),
781            outbound_sidecar_requests_gauge: register_queue(
782                TrackedLimit::OutboundSidecarRequests,
783                MAX_OUTBOUND_SIDECAR_REQUESTS,
784            ),
785            sidecar_requests: SharedSidecarRequestClient::default(),
786            event_sink: SharedEventSink::default(),
787            extensions: BTreeMap::new(),
788            extension_sessions: BTreeMap::new(),
789            extension_process_output_buffers: BTreeMap::new(),
790            disposed_sessions: Vec::new(),
791        })
792    }
793
794    pub fn with_config_and_extensions(
795        bridge: B,
796        config: NativeSidecarConfig,
797        extensions: Vec<Box<dyn Extension>>,
798    ) -> Result<Self, SidecarError> {
799        let mut sidecar = Self::with_config(bridge, config)?;
800        for extension in extensions {
801            sidecar.register_extension(extension)?;
802        }
803        Ok(sidecar)
804    }
805
806    pub(crate) fn prune_extension_process_resource(&mut self, process_id: &str) {
807        self.extension_sessions.retain(|_, resources| {
808            resources.process_ids.remove(process_id);
809            !resources.process_ids.is_empty() || !resources.vm_ids.is_empty()
810        });
811    }
812
813    pub(crate) fn prune_extension_vm_resource(&mut self, vm_id: &str) {
814        self.extension_sessions.retain(|_, resources| {
815            if matches!(
816                &resources.ownership,
817                OwnershipScope::VmOwnership(inner) if inner.vm_id == vm_id
818            ) {
819                resources.process_ids.clear();
820            }
821            resources.vm_ids.remove(vm_id);
822            !resources.process_ids.is_empty() || !resources.vm_ids.is_empty()
823        });
824    }
825
826    /// Reclaim every per-VM tracking entry owned by the sidecar for `vm_id`.
827    ///
828    /// Called unconditionally from `dispose_vm_internal` so that a fallible
829    /// teardown step (root-filesystem snapshot/flush, kernel dispose, permission
830    /// reset) erroring out with `?` can never strand these maps for the rest of
831    /// the process lifetime (H1). This also reclaims the ACP output-buffer map,
832    /// which was previously removed only on a successful handoff and leaked on VM
833    /// or session disposal (M6).
834    pub(crate) fn reclaim_vm_tracking(&mut self, session_id: &str, vm_id: &str) {
835        self.javascript_engine.dispose_vm(vm_id);
836        self.python_engine.dispose_vm(vm_id);
837        self.wasm_engine.dispose_vm(vm_id);
838        self.prune_extension_vm_resource(vm_id);
839        self.extension_process_output_buffers
840            .retain(|(buffer_vm_id, _process_id), _| buffer_vm_id != vm_id);
841        if let Some(session) = self.sessions.get_mut(session_id) {
842            session.vm_ids.remove(vm_id);
843        }
844    }
845
846    pub(crate) fn capture_extension_process_output_event(
847        &mut self,
848        vm_id: &str,
849        process_id: &str,
850        event: &ActiveExecutionEvent,
851    ) -> bool {
852        let Some(buffer) = self
853            .extension_process_output_buffers
854            .get_mut(&(vm_id.to_string(), process_id.to_string()))
855        else {
856            return false;
857        };
858        match event {
859            ActiveExecutionEvent::Stdout(chunk) => {
860                buffer.append_stdout(chunk, DEFAULT_ACP_STDOUT_BUFFER_BYTE_LIMIT);
861                true
862            }
863            ActiveExecutionEvent::Stderr(chunk) => {
864                buffer.append_stderr(chunk, DEFAULT_ACP_STDOUT_BUFFER_BYTE_LIMIT);
865                true
866            }
867            ActiveExecutionEvent::JavascriptSyncRpcRequest(_)
868            | ActiveExecutionEvent::PythonVfsRpcRequest(_)
869            | ActiveExecutionEvent::SignalState { .. }
870            | ActiveExecutionEvent::Exited(_) => false,
871        }
872    }
873
874    fn bind_extension_process_resource(
875        &mut self,
876        ownership: OwnershipScope,
877        namespace: String,
878        ext_session_id: String,
879        process_id: String,
880    ) -> Result<(), SidecarError> {
881        if ext_session_id.is_empty() {
882            return Err(SidecarError::InvalidState(String::from(
883                "extension session id must not be empty",
884            )));
885        }
886        let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?;
887        self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
888        let process_exists = self
889            .vms
890            .get(&vm_id)
891            .is_some_and(|vm| vm.active_processes.contains_key(&process_id));
892        if !process_exists {
893            return Err(SidecarError::InvalidState(format!(
894                "VM {vm_id} has no active process {process_id}"
895            )));
896        }
897
898        let key = (namespace, ext_session_id);
899        if let Some(resources) = self.extension_sessions.get_mut(&key) {
900            if resources.ownership != ownership {
901                return Err(SidecarError::InvalidState(String::from(
902                    "extension session ownership did not match existing resources",
903                )));
904            }
905            resources.process_ids.insert(process_id);
906        } else {
907            self.extension_sessions.insert(
908                key,
909                ExtensionSessionResources {
910                    ownership,
911                    process_ids: BTreeSet::from([process_id]),
912                    vm_ids: BTreeSet::new(),
913                },
914            );
915        }
916        Ok(())
917    }
918
919    fn bind_extension_vm_resource(
920        &mut self,
921        ownership: OwnershipScope,
922        namespace: String,
923        ext_session_id: String,
924    ) -> Result<(), SidecarError> {
925        if ext_session_id.is_empty() {
926            return Err(SidecarError::InvalidState(String::from(
927                "extension session id must not be empty",
928            )));
929        }
930        let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?;
931        self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
932
933        let key = (namespace, ext_session_id);
934        if let Some(resources) = self.extension_sessions.get_mut(&key) {
935            if resources.ownership != ownership {
936                return Err(SidecarError::InvalidState(String::from(
937                    "extension session ownership did not match existing resources",
938                )));
939            }
940            resources.vm_ids.insert(vm_id);
941        } else {
942            self.extension_sessions.insert(
943                key,
944                ExtensionSessionResources {
945                    ownership,
946                    process_ids: BTreeSet::new(),
947                    vm_ids: BTreeSet::from([vm_id]),
948                },
949            );
950        }
951        Ok(())
952    }
953
954    pub fn sidecar_id(&self) -> &str {
955        &self.config.sidecar_id
956    }
957
958    pub fn with_bridge_mut<T>(
959        &self,
960        operation: impl FnOnce(&mut B) -> T,
961    ) -> Result<T, SidecarError> {
962        self.bridge.inspect(operation)
963    }
964
965    pub fn set_sidecar_request_transport(&mut self, transport: Arc<dyn SidecarRequestTransport>) {
966        self.sidecar_requests.set_transport(transport);
967    }
968
969    pub fn set_event_transport(&mut self, transport: Arc<dyn EventSinkTransport>) {
970        self.event_sink.set_transport(transport);
971    }
972
973    pub fn register_extension(
974        &mut self,
975        extension: Box<dyn Extension>,
976    ) -> Result<(), SidecarError> {
977        let namespace = extension.namespace().to_owned();
978        if namespace.is_empty() {
979            return Err(SidecarError::InvalidState(String::from(
980                "extension namespace must not be empty",
981            )));
982        }
983        if self.extensions.contains_key(&namespace) {
984            return Err(SidecarError::Conflict(format!(
985                "extension namespace {namespace} is already registered",
986            )));
987        }
988        self.extensions.insert(namespace, Arc::from(extension));
989        Ok(())
990    }
991
992    pub fn set_sidecar_request_handler<F>(&mut self, handler: F)
993    where
994        F: Fn(SidecarRequestFrame) -> Result<SidecarResponsePayload, SidecarError>
995            + Send
996            + Sync
997            + 'static,
998    {
999        struct HandlerTransport<F>(F);
1000
1001        impl<F> SidecarRequestTransport for HandlerTransport<F>
1002        where
1003            F: Fn(SidecarRequestFrame) -> Result<SidecarResponsePayload, SidecarError>
1004                + Send
1005                + Sync
1006                + 'static,
1007        {
1008            fn send_request(
1009                &self,
1010                request: SidecarRequestFrame,
1011                _timeout: Duration,
1012            ) -> Result<SidecarResponseFrame, SidecarError> {
1013                let payload = (self.0)(request.clone())?;
1014                Ok(SidecarResponseFrame::new(
1015                    request.request_id,
1016                    request.ownership,
1017                    payload,
1018                ))
1019            }
1020        }
1021
1022        self.set_sidecar_request_transport(Arc::new(HandlerTransport(handler)));
1023    }
1024
1025    pub fn set_wire_sidecar_request_handler<F>(&mut self, handler: F)
1026    where
1027        F: Fn(
1028                crate::wire::SidecarRequestFrame,
1029            ) -> Result<crate::wire::SidecarResponseFrame, SidecarError>
1030            + Send
1031            + Sync
1032            + 'static,
1033    {
1034        self.set_sidecar_request_handler(move |request| {
1035            let request = crate::wire::sidecar_request_frame_from_compat(request)
1036                .map_err(wire_protocol_error)?;
1037            let response = handler(request)?;
1038            let response = crate::wire::sidecar_response_frame_to_compat(response)
1039                .map_err(wire_protocol_error)?;
1040            Ok(response.payload)
1041        });
1042    }
1043
1044    pub(crate) fn queue_pending_process_event(
1045        &mut self,
1046        envelope: ProcessEventEnvelope,
1047    ) -> Result<(), SidecarError> {
1048        if self.pending_process_events.len() >= MAX_PROCESS_EVENT_QUEUE {
1049            return Err(process_event_queue_overflow_error());
1050        }
1051        if matches!(&envelope.event, ActiveExecutionEvent::Exited(_)) {
1052            mark_execute_exit_event_queued(&envelope.vm_id, &envelope.process_id);
1053        }
1054        self.pending_process_events.push_back(envelope);
1055        self.pending_process_events_gauge
1056            .observe_depth(self.pending_process_events.len());
1057        Ok(())
1058    }
1059
1060    pub(crate) fn queue_front_pending_process_event(
1061        &mut self,
1062        envelope: ProcessEventEnvelope,
1063    ) -> Result<(), SidecarError> {
1064        if self.pending_process_events.len() >= MAX_PROCESS_EVENT_QUEUE {
1065            return Err(process_event_queue_overflow_error());
1066        }
1067        if matches!(&envelope.event, ActiveExecutionEvent::Exited(_)) {
1068            mark_execute_exit_event_queued(&envelope.vm_id, &envelope.process_id);
1069        }
1070        self.pending_process_events.push_front(envelope);
1071        self.pending_process_events_gauge
1072            .observe_depth(self.pending_process_events.len());
1073        Ok(())
1074    }
1075
1076    pub(crate) fn pending_process_event_capacity(&self) -> usize {
1077        MAX_PROCESS_EVENT_QUEUE.saturating_sub(self.pending_process_events.len())
1078    }
1079
1080    pub fn dispatch_blocking(
1081        &mut self,
1082        request: RequestFrame,
1083    ) -> Result<DispatchResult, SidecarError> {
1084        let inside_runtime = tokio::runtime::Handle::try_current().is_ok();
1085        if request_dispatch_mode(&request) == RequestDispatchMode::Async && !inside_runtime {
1086            return blocking_dispatch_runtime().block_on(self.dispatch(request));
1087        }
1088
1089        let mut future = std::pin::pin!(self.dispatch(request));
1090        match poll_future_once(future.as_mut()) {
1091            Some(result) => result,
1092            None if inside_runtime => Err(SidecarError::InvalidState(String::from(
1093                "dispatch_blocking cannot wait for an async sidecar request inside a Tokio runtime; use dispatch().await",
1094            ))),
1095            None => blocking_dispatch_runtime().block_on(future),
1096        }
1097    }
1098
1099    pub fn dispatch_wire_blocking(
1100        &mut self,
1101        request: crate::wire::RequestFrame,
1102    ) -> Result<crate::wire::WireDispatchResult, SidecarError> {
1103        let request = crate::wire::request_frame_to_compat(request).map_err(wire_protocol_error)?;
1104        let result = self.dispatch_blocking(request)?;
1105        wire_dispatch_result(result)
1106    }
1107
1108    pub fn poll_event_blocking(
1109        &mut self,
1110        ownership: &OwnershipScope,
1111        timeout: Duration,
1112    ) -> Result<Option<EventFrame>, SidecarError> {
1113        blocking_dispatch_runtime().block_on(self.poll_event(ownership, timeout))
1114    }
1115
1116    pub fn poll_event_wire_blocking(
1117        &mut self,
1118        ownership: &crate::wire::OwnershipScope,
1119        timeout: Duration,
1120    ) -> Result<Option<crate::wire::EventFrame>, SidecarError> {
1121        let ownership = crate::wire::ownership_scope_to_compat(ownership.clone());
1122        self.poll_event_blocking(&ownership, timeout)?
1123            .map(crate::wire::event_frame_from_compat)
1124            .transpose()
1125            .map_err(wire_protocol_error)
1126    }
1127
1128    pub fn close_session_blocking(
1129        &mut self,
1130        connection_id: &str,
1131        session_id: &str,
1132    ) -> Result<Vec<EventFrame>, SidecarError> {
1133        blocking_dispatch_runtime().block_on(self.close_session(connection_id, session_id))
1134    }
1135
1136    pub fn remove_connection_blocking(
1137        &mut self,
1138        connection_id: &str,
1139    ) -> Result<Vec<EventFrame>, SidecarError> {
1140        blocking_dispatch_runtime().block_on(self.remove_connection(connection_id))
1141    }
1142
1143    pub fn dispose_vm_internal_blocking(
1144        &mut self,
1145        connection_id: &str,
1146        session_id: &str,
1147        vm_id: &str,
1148        reason: DisposeReason,
1149    ) -> Result<Vec<EventFrame>, SidecarError> {
1150        blocking_dispatch_runtime().block_on(self.dispose_vm_internal(
1151            connection_id,
1152            session_id,
1153            vm_id,
1154            reason,
1155        ))
1156    }
1157
1158    pub async fn dispatch(
1159        &mut self,
1160        request: RequestFrame,
1161    ) -> Result<DispatchResult, SidecarError> {
1162        if let Err(error) = self.ensure_request_within_frame_limit(&request) {
1163            return Ok(DispatchResult {
1164                response: self.reject(&request, error_code(&error), &error.to_string()),
1165                events: Vec::new(),
1166            });
1167        }
1168
1169        let result = match route_request_payload(&request) {
1170            RequestRoute::Authenticate(payload) => {
1171                self.authenticate_connection(&request, payload).await
1172            }
1173            RequestRoute::OpenSession(payload) => self.open_session(&request, payload).await,
1174            RequestRoute::CreateVm(payload) => self.create_vm(&request, payload).await,
1175            RequestRoute::DisposeVm(payload) => self.dispose_vm(&request, payload).await,
1176            RequestRoute::BootstrapRootFilesystem(payload) => {
1177                self.bootstrap_root_filesystem(&request, payload.entries)
1178                    .await
1179            }
1180            RequestRoute::ConfigureVm(payload) => self.configure_vm(&request, payload).await,
1181            RequestRoute::RegisterHostCallbacks(payload) => {
1182                register_host_callbacks(self, &request, payload)
1183            }
1184            RequestRoute::CreateLayer(payload) => self.create_layer(&request, payload).await,
1185            RequestRoute::SealLayer(payload) => self.seal_layer(&request, payload).await,
1186            RequestRoute::ImportSnapshot(payload) => self.import_snapshot(&request, payload).await,
1187            RequestRoute::ExportSnapshot(payload) => self.export_snapshot(&request, payload).await,
1188            RequestRoute::CreateOverlay(payload) => self.create_overlay(&request, payload).await,
1189            RequestRoute::GuestFilesystemCall(payload) => {
1190                self.guest_filesystem_call(&request, payload).await
1191            }
1192            RequestRoute::GuestKernelCall(payload) => {
1193                self.guest_kernel_call(&request, payload).await
1194            }
1195            RequestRoute::SnapshotRootFilesystem(payload) => {
1196                self.snapshot_root_filesystem(&request, payload).await
1197            }
1198            RequestRoute::Execute(payload) => self.execute(&request, payload).await,
1199            RequestRoute::WriteStdin(payload) => self.write_stdin(&request, payload).await,
1200            RequestRoute::ResizePty(payload) => self.resize_pty(&request, payload).await,
1201            RequestRoute::CloseStdin(payload) => self.close_stdin(&request, payload).await,
1202            RequestRoute::KillProcess(payload) => self.kill_process(&request, payload).await,
1203            RequestRoute::GetProcessSnapshot(payload) => {
1204                self.get_process_snapshot(&request, payload).await
1205            }
1206            RequestRoute::GetResourceSnapshot(payload) => {
1207                self.get_resource_snapshot(&request, payload).await
1208            }
1209            RequestRoute::FindListener(payload) => self.find_listener(&request, payload).await,
1210            RequestRoute::FindBoundUdp(payload) => self.find_bound_udp(&request, payload).await,
1211            RequestRoute::VmFetch(payload) => self.vm_fetch(&request, payload).await,
1212            RequestRoute::GetSignalState(payload) => self.get_signal_state(&request, payload).await,
1213            RequestRoute::GetZombieTimerCount(payload) => {
1214                self.get_zombie_timer_count(&request, payload).await
1215            }
1216            RequestRoute::LinkPackage(payload) => self.link_package(&request, payload).await,
1217            RequestRoute::ProvidedCommands(payload) => {
1218                self.provided_commands(&request, payload).await
1219            }
1220            RequestRoute::UnsupportedHostCallbackDirection => {
1221                Ok(unsupported_host_callback_direction_dispatch(&request))
1222            }
1223            RequestRoute::Ext(payload) => self.dispatch_extension_request(&request, payload).await,
1224        };
1225
1226        match result {
1227            Ok(dispatch) => Ok(dispatch),
1228            Err(error @ SidecarError::Io(_)) => Err(error),
1229            Err(error) => Ok(DispatchResult {
1230                response: self.reject(&request, error_code(&error), &error.to_string()),
1231                events: Vec::new(),
1232            }),
1233        }
1234    }
1235
1236    pub async fn dispatch_wire(
1237        &mut self,
1238        request: crate::wire::RequestFrame,
1239    ) -> Result<crate::wire::WireDispatchResult, SidecarError> {
1240        let request = crate::wire::request_frame_to_compat(request).map_err(wire_protocol_error)?;
1241        let result = self.dispatch(request).await?;
1242        wire_dispatch_result(result)
1243    }
1244
1245    pub async fn poll_event_wire(
1246        &mut self,
1247        ownership: &crate::wire::OwnershipScope,
1248        timeout: Duration,
1249    ) -> Result<Option<crate::wire::EventFrame>, SidecarError> {
1250        let ownership = crate::wire::ownership_scope_to_compat(ownership.clone());
1251        self.poll_event(&ownership, timeout)
1252            .await?
1253            .map(crate::wire::event_frame_from_compat)
1254            .transpose()
1255            .map_err(wire_protocol_error)
1256    }
1257
1258    async fn dispatch_extension_request(
1259        &mut self,
1260        request: &RequestFrame,
1261        envelope: ExtEnvelope,
1262    ) -> Result<DispatchResult, SidecarError> {
1263        let namespace = envelope.namespace;
1264        let Some(extension) = self.extensions.get(&namespace).cloned() else {
1265            return Ok(DispatchResult {
1266                response: self.reject(
1267                    request,
1268                    "unknown_extension",
1269                    &format!("no extension registered for namespace {namespace}"),
1270                ),
1271                events: Vec::new(),
1272            });
1273        };
1274        let snapshot = ExtensionSnapshot::new(
1275            namespace.clone(),
1276            request.ownership.clone(),
1277            self.sidecar_requests.clone(),
1278            self.event_sink.clone(),
1279        );
1280        let ctx = ExtensionContext::new(snapshot, self);
1281        let response = extension.handle_request(ctx, envelope.payload).await?;
1282        Ok(DispatchResult {
1283            response: self.respond(
1284                request,
1285                ResponsePayload::ExtResult(ExtEnvelope {
1286                    namespace,
1287                    payload: response.payload,
1288                }),
1289            ),
1290            events: response.events,
1291        })
1292    }
1293
1294    pub async fn poll_event(
1295        &mut self,
1296        ownership: &OwnershipScope,
1297        timeout: Duration,
1298    ) -> Result<Option<EventFrame>, SidecarError> {
1299        let deadline = Instant::now() + timeout;
1300        loop {
1301            if let Some(index) = self
1302                .pending_process_events
1303                .iter()
1304                .position(|event| public_process_event_matches_ownership(self, ownership, event))
1305            {
1306                let Some(envelope) = self.pending_process_events.remove(index) else {
1307                    continue;
1308                };
1309                if let Some(frame) = self.handle_process_event_envelope(envelope)? {
1310                    return Ok(Some(frame));
1311                }
1312                continue;
1313            }
1314
1315            if !timeout.is_zero() {
1316                let _ = self.pump_process_events(ownership).await?;
1317            }
1318
1319            let queued_envelopes = {
1320                let pending_capacity = self.pending_process_event_capacity();
1321                let receiver = self.process_event_receiver.as_mut().ok_or_else(|| {
1322                    SidecarError::InvalidState(String::from("process event receiver unavailable"))
1323                })?;
1324                let mut queued = Vec::new();
1325                loop {
1326                    if queued.len() >= pending_capacity {
1327                        if receiver.is_empty() {
1328                            break;
1329                        }
1330                        return Err(process_event_queue_overflow_error());
1331                    }
1332                    match receiver.try_recv() {
1333                        Ok(envelope) => queued.push(envelope),
1334                        Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
1335                        Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
1336                    }
1337                }
1338                queued
1339            };
1340
1341            let mut matching_envelope = None;
1342            for envelope in queued_envelopes {
1343                if matching_envelope.is_none()
1344                    && public_process_event_matches_ownership(self, ownership, &envelope)
1345                {
1346                    matching_envelope = Some(envelope);
1347                } else {
1348                    self.queue_pending_process_event(envelope)?;
1349                }
1350            }
1351
1352            if let Some(envelope) = matching_envelope {
1353                if let Some(frame) = self.handle_process_event_envelope(envelope)? {
1354                    return Ok(Some(frame));
1355                }
1356                continue;
1357            }
1358
1359            if Instant::now() >= deadline {
1360                return Ok(None);
1361            }
1362
1363            let remaining = deadline.saturating_duration_since(Instant::now());
1364            time::sleep(remaining.min(Duration::from_millis(10))).await;
1365        }
1366    }
1367
1368    pub(crate) fn handle_process_event_envelope(
1369        &mut self,
1370        envelope: ProcessEventEnvelope,
1371    ) -> Result<Option<EventFrame>, SidecarError> {
1372        let handle_start = Instant::now();
1373        let ProcessEventEnvelope {
1374            connection_id,
1375            session_id,
1376            vm_id,
1377            process_id,
1378            event,
1379        } = envelope;
1380
1381        let is_exit_event = matches!(event, ActiveExecutionEvent::Exited(_));
1382
1383        if is_exit_event {
1384            record_execute_exit_event_queue_wait(
1385                "process_exit_event_queue_wait",
1386                &vm_id,
1387                &process_id,
1388            );
1389            let mut trailing = Vec::new();
1390            let mut deferred = VecDeque::new();
1391            let phase_start = Instant::now();
1392            while let Some(pending) = self.pending_process_events.pop_front() {
1393                if pending.vm_id == vm_id
1394                    && pending.process_id == process_id
1395                    && !matches!(pending.event, ActiveExecutionEvent::Exited(_))
1396                {
1397                    trailing.push(pending.event);
1398                } else {
1399                    deferred.push_back(pending);
1400                }
1401            }
1402            self.pending_process_events = deferred;
1403            record_execute_phase("process_exit_trailing_pending_scan", phase_start.elapsed());
1404            let drain_limit = self
1405                .pending_process_event_capacity()
1406                .saturating_sub(trailing.len().saturating_add(1));
1407            let phase_start = Instant::now();
1408            trailing.extend(
1409                self.drain_process_events_blocking_with_limit(&vm_id, &process_id, drain_limit)?
1410                    .into_iter()
1411                    .filter(|event| !matches!(event, ActiveExecutionEvent::Exited(_))),
1412            );
1413            record_execute_phase(
1414                "process_exit_trailing_blocking_drain",
1415                phase_start.elapsed(),
1416            );
1417
1418            if !trailing.is_empty() {
1419                if self.pending_process_event_capacity() < trailing.len() {
1420                    return Err(process_event_queue_overflow_error());
1421                }
1422                let emit_now = if self.pending_process_event_capacity() == trailing.len() {
1423                    Some(trailing.remove(0))
1424                } else {
1425                    None
1426                };
1427                let phase_start = Instant::now();
1428                mark_execute_exit_event_queued(&vm_id, &process_id);
1429                self.queue_front_pending_process_event(ProcessEventEnvelope {
1430                    connection_id: connection_id.clone(),
1431                    session_id: session_id.clone(),
1432                    vm_id: vm_id.clone(),
1433                    process_id: process_id.clone(),
1434                    event,
1435                })?;
1436                for event in trailing.into_iter().rev() {
1437                    self.queue_front_pending_process_event(ProcessEventEnvelope {
1438                        connection_id: connection_id.clone(),
1439                        session_id: session_id.clone(),
1440                        vm_id: vm_id.clone(),
1441                        process_id: process_id.clone(),
1442                        event,
1443                    })?;
1444                }
1445                record_execute_phase("process_exit_trailing_requeue", phase_start.elapsed());
1446                if let Some(event) = emit_now {
1447                    let result = self.handle_execution_event(&vm_id, &process_id, event);
1448                    record_execute_phase(
1449                        "process_exit_event_handle_envelope_total",
1450                        handle_start.elapsed(),
1451                    );
1452                    return result;
1453                }
1454                record_execute_phase(
1455                    "process_exit_event_handle_envelope_total",
1456                    handle_start.elapsed(),
1457                );
1458                return Ok(None);
1459            }
1460        }
1461
1462        let result = self.handle_execution_event(&vm_id, &process_id, event);
1463        if is_exit_event {
1464            record_execute_phase(
1465                "process_exit_event_handle_envelope_total",
1466                handle_start.elapsed(),
1467            );
1468        }
1469        result
1470    }
1471
1472    // try_poll_event moved to crate::execution
1473
1474    pub async fn close_session(
1475        &mut self,
1476        connection_id: &str,
1477        session_id: &str,
1478    ) -> Result<Vec<EventFrame>, SidecarError> {
1479        self.dispose_session(connection_id, session_id, DisposeReason::Requested)
1480            .await
1481    }
1482
1483    pub async fn remove_connection(
1484        &mut self,
1485        connection_id: &str,
1486    ) -> Result<Vec<EventFrame>, SidecarError> {
1487        self.require_authenticated_connection(connection_id)?;
1488
1489        let session_ids = self
1490            .connections
1491            .get(connection_id)
1492            .expect("authenticated connection should exist")
1493            .sessions
1494            .iter()
1495            .cloned()
1496            .collect::<Vec<_>>();
1497
1498        let mut events = Vec::new();
1499        let mut first_error: Option<SidecarError> = None;
1500        for session_id in session_ids {
1501            // Attempt EVERY session; aggregate errors instead of `?`-ing out on
1502            // the first so one wedged session cannot abandon the rest (H1).
1503            match self
1504                .dispose_session(connection_id, &session_id, DisposeReason::ConnectionClosed)
1505                .await
1506            {
1507                Ok(session_events) => events.extend(session_events),
1508                Err(error) => {
1509                    if first_error.is_none() {
1510                        first_error = Some(error);
1511                    }
1512                }
1513            }
1514        }
1515
1516        self.connections.remove(connection_id);
1517        if let Some(error) = first_error {
1518            return Err(error);
1519        }
1520        Ok(events)
1521    }
1522
1523    async fn authenticate_connection(
1524        &mut self,
1525        request: &RequestFrame,
1526        payload: crate::protocol::AuthenticateRequest,
1527    ) -> Result<DispatchResult, SidecarError> {
1528        let _ = self.connection_id_for(&request.ownership)?;
1529        if let Err(error) = self.validate_auth_token(&payload.auth_token) {
1530            let mut fields = audit_fields([
1531                (String::from("source"), payload.client_name.clone()),
1532                (String::from("reason"), error.to_string()),
1533            ]);
1534            if let OwnershipScope::ConnectionOwnership(inner) = &request.ownership {
1535                fields.insert(String::from("connection_id"), inner.connection_id.clone());
1536            }
1537            emit_security_audit_event(
1538                &self.bridge,
1539                &self.config.sidecar_id,
1540                "security.auth.failed",
1541                fields,
1542            );
1543            return Err(error);
1544        }
1545
1546        if let Err(error) = validate_authenticate_versions(&payload) {
1547            return Err(match error {
1548                AuthenticateVersionError::ProtocolVersionMismatch(message) => {
1549                    SidecarError::ProtocolVersionMismatch(message)
1550                }
1551                AuthenticateVersionError::BridgeVersionMismatch(message) => {
1552                    SidecarError::BridgeVersionMismatch(message)
1553                }
1554            });
1555        }
1556
1557        let connection_id = self.allocate_connection_id();
1558        self.connections.insert(
1559            connection_id.clone(),
1560            ConnectionState {
1561                auth_token: payload.auth_token,
1562                sessions: BTreeSet::new(),
1563            },
1564        );
1565
1566        let response = shared_authenticated_response(
1567            request.request_id,
1568            self.config.sidecar_id.clone(),
1569            connection_id,
1570            self.config.max_frame_bytes as u32,
1571        );
1572        Ok(DispatchResult {
1573            response,
1574            events: Vec::new(),
1575        })
1576    }
1577
1578    async fn open_session(
1579        &mut self,
1580        request: &RequestFrame,
1581        payload: OpenSessionRequest,
1582    ) -> Result<DispatchResult, SidecarError> {
1583        let connection_id = self.connection_id_for(&request.ownership)?;
1584        self.require_authenticated_connection(&connection_id)?;
1585
1586        self.next_session_id += 1;
1587        let session_id = format!("session-{}", self.next_session_id);
1588        self.sessions.insert(
1589            session_id.clone(),
1590            SessionState {
1591                connection_id: connection_id.clone(),
1592                placement: payload.placement,
1593                metadata: payload.metadata.into_iter().collect(),
1594                vm_ids: BTreeSet::new(),
1595            },
1596        );
1597        self.connections
1598            .get_mut(&connection_id)
1599            .expect("authenticated connection should exist")
1600            .sessions
1601            .insert(session_id.clone());
1602
1603        Ok(DispatchResult {
1604            response: session_opened_response(request.request_id, connection_id, session_id),
1605            events: Vec::new(),
1606        })
1607    }
1608
1609    // create_vm, dispose_vm, bootstrap_root_filesystem, configure_vm moved to crate::vm
1610
1611    async fn guest_filesystem_call(
1612        &mut self,
1613        request: &RequestFrame,
1614        payload: GuestFilesystemCallRequest,
1615    ) -> Result<DispatchResult, SidecarError> {
1616        filesystem_guest_filesystem_call(self, request, payload).await
1617    }
1618
1619    // snapshot_root_filesystem moved to crate::vm
1620
1621    // execute, write_stdin, close_stdin, kill_process, find_listener, find_bound_udp,
1622    // get_signal_state, get_zombie_timer_count moved to crate::execution
1623
1624    async fn dispose_session(
1625        &mut self,
1626        connection_id: &str,
1627        session_id: &str,
1628        reason: DisposeReason,
1629    ) -> Result<Vec<EventFrame>, SidecarError> {
1630        self.require_owned_session(connection_id, session_id)?;
1631
1632        let vm_ids = self
1633            .sessions
1634            .get(session_id)
1635            .expect("owned session should exist")
1636            .vm_ids
1637            .iter()
1638            .cloned()
1639            .collect::<Vec<_>>();
1640
1641        let mut events = Vec::new();
1642        let mut first_error: Option<SidecarError> = None;
1643        for vm_id in vm_ids {
1644            // Attempt EVERY VM; aggregate errors instead of `?`-ing out on the
1645            // first so one stuck VM cannot strand the remaining VMs' teardown and
1646            // leave the session permanently un-reclaimed (H1).
1647            match self
1648                .dispose_vm_internal(connection_id, session_id, &vm_id, reason.clone())
1649                .await
1650            {
1651                Ok(vm_events) => events.extend(vm_events),
1652                Err(error) => {
1653                    if first_error.is_none() {
1654                        first_error = Some(error);
1655                    }
1656                }
1657            }
1658        }
1659
1660        // On client disconnect, give every registered extension a chance to free
1661        // the per-session state it tracks (H4): the host owns the only signal an
1662        // extension gets that a session has gone away.
1663        if matches!(reason, DisposeReason::ConnectionClosed) {
1664            if let Err(error) = self
1665                .dispose_extension_session_state(connection_id, session_id)
1666                .await
1667            {
1668                if first_error.is_none() {
1669                    first_error = Some(error);
1670                }
1671            }
1672        }
1673
1674        self.sessions.remove(session_id);
1675        if let Some(connection) = self.connections.get_mut(connection_id) {
1676            connection.sessions.remove(session_id);
1677        }
1678        // Tell the stdio transport this session is gone so it stops iterating a
1679        // dead entry every event-pump tick and the set stops growing (M5).
1680        self.disposed_sessions
1681            .push((connection_id.to_owned(), session_id.to_owned()));
1682
1683        if let Some(error) = first_error {
1684            return Err(error);
1685        }
1686        Ok(events)
1687    }
1688
1689    /// Invoke each registered extension's per-session teardown hook so it can
1690    /// release the state it keyed on this host session. Errors are aggregated so
1691    /// one misbehaving extension cannot prevent the others from cleaning up.
1692    async fn dispose_extension_session_state(
1693        &mut self,
1694        connection_id: &str,
1695        session_id: &str,
1696    ) -> Result<(), SidecarError> {
1697        let ownership = OwnershipScope::session(connection_id, session_id);
1698        let extensions = self
1699            .extensions
1700            .values()
1701            .cloned()
1702            .collect::<Vec<Arc<dyn Extension>>>();
1703        let mut first_error: Option<SidecarError> = None;
1704        for extension in extensions {
1705            let snapshot = ExtensionSnapshot::new(
1706                extension.namespace().to_owned(),
1707                ownership.clone(),
1708                self.sidecar_requests.clone(),
1709                self.event_sink.clone(),
1710            );
1711            if let Err(error) = extension.on_session_disposed(snapshot).await {
1712                if first_error.is_none() {
1713                    first_error = Some(error);
1714                }
1715            }
1716        }
1717        match first_error {
1718            Some(error) => Err(error),
1719            None => Ok(()),
1720        }
1721    }
1722
1723    /// Drain the session scopes disposed since the last call so the stdio
1724    /// transport can untrack them from its active-session set (M5).
1725    pub(crate) fn take_disposed_sessions(&mut self) -> Vec<(String, String)> {
1726        std::mem::take(&mut self.disposed_sessions)
1727    }
1728
1729    // dispose_vm_internal, terminate_vm_processes, wait_for_vm_processes_to_exit moved to crate::vm
1730
1731    // kill_process_internal, handle_execution_event, handle_python_vfs_rpc_request,
1732    // resolve_javascript_child_process_execution, spawn_javascript_child_process,
1733    // poll_javascript_child_process, write_javascript_child_process_stdin,
1734    // close_javascript_child_process_stdin, kill_javascript_child_process moved to crate::execution
1735
1736    /// Whether a `__kernel_stdin_read` / `__kernel_poll` RPC may be serviced
1737    /// via the non-blocking deferral path. Non-TTY JavaScript keeps its
1738    /// in-process local stdin bridge (serviced inline by the fallback arm).
1739    fn kernel_wait_rpc_is_deferrable(
1740        &self,
1741        vm_id: &str,
1742        process_id: &str,
1743        request: &JavascriptSyncRpcRequest,
1744    ) -> bool {
1745        let Some(vm) = self.vms.get(vm_id) else {
1746            return false;
1747        };
1748        let Some(process) = vm.active_processes.get(process_id) else {
1749            return false;
1750        };
1751        if request.method == "__kernel_stdin_read"
1752            && matches!(
1753                process.execution,
1754                crate::state::ActiveExecution::Javascript(_)
1755            )
1756            && process.tty_master_fd.is_none()
1757        {
1758            return false;
1759        }
1760        true
1761    }
1762
1763    /// Service `__kernel_stdin_read` / `__kernel_poll` without blocking the
1764    /// dispatch loop. Probes readiness with a zero timeout; when not ready and
1765    /// the requested timeout has not expired, parks the RPC on the process
1766    /// (reply-by-token) and spawns a waiter that re-enqueues it as a process
1767    /// event when kernel poll state changes or the deadline passes. The kernel
1768    /// waits stay event-driven (PollNotifier), so a host stdin write wakes the
1769    /// guest immediately instead of after a polling slice.
1770    ///
1771    /// Returns `Ok(Some(response))` to reply now, `Ok(None)` when parked.
1772    fn service_deferrable_kernel_wait_rpc(
1773        &mut self,
1774        vm_id: &str,
1775        process_id: &str,
1776        request: &JavascriptSyncRpcRequest,
1777    ) -> Result<Option<crate::execution::JavascriptSyncRpcServiceResponse>, SidecarError> {
1778        let requested_timeout_ms = match request.method.as_str() {
1779            "__kernel_stdin_read" => parse_kernel_stdin_read_args(request)?.1,
1780            _ => u64::try_from(parse_kernel_poll_args(request)?.1).unwrap_or(0),
1781        };
1782        let now = Instant::now();
1783
1784        let Some(vm) = self.vms.get_mut(vm_id) else {
1785            log_stale_process_event(&self.bridge, vm_id, process_id, "deferred kernel wait RPC");
1786            return Ok(None);
1787        };
1788        let wait_handle = vm.kernel.poll_wait_handle();
1789        // Snapshot BEFORE the readiness probe: a write landing between the
1790        // probe and the waiter's wait bumps the generation, so the wait
1791        // returns immediately instead of losing the wakeup.
1792        let generation = wait_handle.snapshot();
1793        let Some(process) = vm.active_processes.get_mut(process_id) else {
1794            log_stale_process_event(&self.bridge, vm_id, process_id, "deferred kernel wait RPC");
1795            return Ok(None);
1796        };
1797        let kernel_pid = process.kernel_pid;
1798        let deadline = match &process.deferred_kernel_wait_rpc {
1799            Some((parked, parked_deadline)) if parked.id == request.id => *parked_deadline,
1800            _ => now + Duration::from_millis(requested_timeout_ms),
1801        };
1802        let probe = match request.method.as_str() {
1803            "__kernel_stdin_read" => {
1804                let (max_bytes, _) = parse_kernel_stdin_read_args(request)?;
1805                kernel_stdin_read_response(&mut vm.kernel, kernel_pid, max_bytes, Duration::ZERO)
1806            }
1807            _ => {
1808                let (fd_requests, _) = parse_kernel_poll_args(request)?;
1809                kernel_poll_response(&vm.kernel, kernel_pid, &fd_requests, 0)
1810            }
1811        };
1812        let Some(process) = vm.active_processes.get_mut(process_id) else {
1813            return Ok(None);
1814        };
1815        let probe = match probe {
1816            Ok(value) => value,
1817            Err(error) => {
1818                process.deferred_kernel_wait_rpc = None;
1819                return Err(error);
1820            }
1821        };
1822        let ready = match request.method.as_str() {
1823            "__kernel_stdin_read" => !probe.is_null(),
1824            _ => probe.get("readyCount").and_then(Value::as_u64).unwrap_or(0) > 0,
1825        };
1826        if ready || requested_timeout_ms == 0 || now >= deadline {
1827            process.deferred_kernel_wait_rpc = None;
1828            return Ok(Some(probe.into()));
1829        }
1830
1831        let connection_id = vm.connection_id.clone();
1832        let session_id = vm.session_id.clone();
1833        let remaining = deadline.saturating_duration_since(now);
1834        let sender = self.process_event_sender.clone();
1835        let waiter_request = request.clone();
1836        let envelope_vm_id = vm_id.to_owned();
1837        let envelope_process_id = process_id.to_owned();
1838        let spawned = std::thread::Builder::new()
1839            .name(String::from("kernel-wait-rpc"))
1840            .spawn(move || {
1841                // Wake on any kernel poll-state change or the deadline; either
1842                // way requeue exactly once — the handler re-probes and either
1843                // replies or re-parks.
1844                let _ = wait_handle.wait_for_change(generation, Some(remaining));
1845                let _ = sender.blocking_send(ProcessEventEnvelope {
1846                    connection_id,
1847                    session_id,
1848                    vm_id: envelope_vm_id,
1849                    process_id: envelope_process_id,
1850                    event: ActiveExecutionEvent::JavascriptSyncRpcRequest(waiter_request),
1851                });
1852            });
1853        let Some(vm) = self.vms.get_mut(vm_id) else {
1854            return Ok(None);
1855        };
1856        let Some(process) = vm.active_processes.get_mut(process_id) else {
1857            return Ok(None);
1858        };
1859        if spawned.is_err() {
1860            // Degrade to pre-deferral behavior: reply not-ready now and let the
1861            // guest re-issue its bounded wait.
1862            process.deferred_kernel_wait_rpc = None;
1863            return Ok(Some(probe.into()));
1864        }
1865        process.deferred_kernel_wait_rpc = Some((request.clone(), deadline));
1866        Ok(None)
1867    }
1868
1869    pub(crate) fn handle_javascript_sync_rpc_request(
1870        &mut self,
1871        vm_id: &str,
1872        process_id: &str,
1873        request: JavascriptSyncRpcRequest,
1874    ) -> Result<(), SidecarError> {
1875        record_sync_bridge_request_observed(request.id, &request.method);
1876        let Some(vm) = self.vms.get(vm_id) else {
1877            log_stale_process_event(&self.bridge, vm_id, process_id, "javascript sync RPC");
1878            return Ok(());
1879        };
1880        if !vm.active_processes.contains_key(process_id) {
1881            log_stale_process_event(&self.bridge, vm_id, process_id, "javascript sync RPC");
1882            return Ok(());
1883        }
1884
1885        let response: Result<crate::execution::JavascriptSyncRpcServiceResponse, SidecarError> =
1886            match request.method.as_str() {
1887                "child_process.spawn" => {
1888                    let Some(vm) = self.vms.get(vm_id) else {
1889                        log_stale_process_event(
1890                            &self.bridge,
1891                            vm_id,
1892                            process_id,
1893                            "javascript sync RPC child_process.spawn",
1894                        );
1895                        return Ok(());
1896                    };
1897                    let (payload, _) =
1898                        parse_javascript_child_process_spawn_request(vm, &request.args)?;
1899                    self.spawn_javascript_child_process(vm_id, process_id, payload)
1900                        .map(Into::into)
1901                }
1902                "child_process.spawn_sync" => {
1903                    let Some(vm) = self.vms.get(vm_id) else {
1904                        log_stale_process_event(
1905                            &self.bridge,
1906                            vm_id,
1907                            process_id,
1908                            "javascript sync RPC child_process.spawn_sync",
1909                        );
1910                        return Ok(());
1911                    };
1912                    let (payload, max_buffer) =
1913                        parse_javascript_child_process_spawn_request(vm, &request.args)?;
1914                    self.spawn_javascript_child_process_sync(vm_id, process_id, payload, max_buffer)
1915                        .map(Into::into)
1916                }
1917                "child_process.poll" => {
1918                    let child_process_id = javascript_sync_rpc_arg_str(
1919                        &request.args,
1920                        0,
1921                        "child_process.poll child id",
1922                    )?;
1923                    let wait_ms = javascript_sync_rpc_arg_u64_optional(
1924                        &request.args,
1925                        1,
1926                        "child_process.poll wait ms",
1927                    )?
1928                    .unwrap_or_default();
1929                    self.poll_javascript_child_process(vm_id, process_id, child_process_id, wait_ms)
1930                        .map(Into::into)
1931                }
1932                "child_process.write_stdin" => {
1933                    let child_process_id = javascript_sync_rpc_arg_str(
1934                        &request.args,
1935                        0,
1936                        "child_process.write_stdin child id",
1937                    )?;
1938                    let chunk = javascript_sync_rpc_bytes_arg(
1939                        &request.args,
1940                        1,
1941                        "child_process.write_stdin chunk",
1942                    )?;
1943                    self.write_javascript_child_process_stdin(
1944                        vm_id,
1945                        process_id,
1946                        child_process_id,
1947                        &chunk,
1948                    )?;
1949                    Ok(Value::Null.into())
1950                }
1951                "child_process.close_stdin" => {
1952                    let child_process_id = javascript_sync_rpc_arg_str(
1953                        &request.args,
1954                        0,
1955                        "child_process.close_stdin child id",
1956                    )?;
1957                    self.close_javascript_child_process_stdin(vm_id, process_id, child_process_id)?;
1958                    Ok(Value::Null.into())
1959                }
1960                "child_process.kill" => {
1961                    let child_process_id = javascript_sync_rpc_arg_str(
1962                        &request.args,
1963                        0,
1964                        "child_process.kill child id",
1965                    )?;
1966                    let signal =
1967                        javascript_sync_rpc_arg_str(&request.args, 1, "child_process.kill signal")?;
1968                    self.kill_javascript_child_process(
1969                        vm_id,
1970                        process_id,
1971                        child_process_id,
1972                        signal,
1973                    )?;
1974                    Ok(Value::Null.into())
1975                }
1976                "process.kill" => {
1977                    let target_pid =
1978                        javascript_sync_rpc_arg_i32(&request.args, 0, "process.kill target pid")?;
1979                    let signal =
1980                        javascript_sync_rpc_arg_str(&request.args, 1, "process.kill signal")?;
1981                    let parsed_signal = parse_signal(signal)?;
1982                    if parsed_signal == 0 {
1983                        let Some(vm) = self.vms.get(vm_id) else {
1984                            log_stale_process_event(
1985                                &self.bridge,
1986                                vm_id,
1987                                process_id,
1988                                "javascript sync RPC process.kill",
1989                            );
1990                            return Ok(());
1991                        };
1992                        if !vm.active_processes.contains_key(process_id) {
1993                            log_stale_process_event(
1994                                &self.bridge,
1995                                vm_id,
1996                                process_id,
1997                                "javascript sync RPC process.kill",
1998                            );
1999                            return Ok(());
2000                        }
2001                        vm.kernel
2002                            .signal_process(EXECUTION_DRIVER_NAME, target_pid, parsed_signal)
2003                            .map(|()| Value::Null.into())
2004                            .map_err(kernel_error)
2005                    } else if target_pid < 0 {
2006                        let caller_kernel_pid = {
2007                            let Some(vm) = self.vms.get(vm_id) else {
2008                                log_stale_process_event(
2009                                    &self.bridge,
2010                                    vm_id,
2011                                    process_id,
2012                                    "javascript sync RPC process.kill",
2013                                );
2014                                return Ok(());
2015                            };
2016                            let Some(caller) = vm.active_processes.get(process_id) else {
2017                                log_stale_process_event(
2018                                    &self.bridge,
2019                                    vm_id,
2020                                    process_id,
2021                                    "javascript sync RPC process.kill",
2022                                );
2023                                return Ok(());
2024                            };
2025                            caller.kernel_pid
2026                        };
2027                        let pgid = target_pid.unsigned_abs();
2028                        match self.signal_vm_process_group(vm_id, caller_kernel_pid, pgid, signal) {
2029                            Ok(true) => Ok(self
2030                                .apply_self_process_kill(vm_id, process_id, parsed_signal)
2031                                .into()),
2032                            Ok(false) => Ok(Value::Null.into()),
2033                            Err(error) => Err(error),
2034                        }
2035                    } else {
2036                        enum ProcessKillTarget {
2037                            SelfProcess,
2038                            Child(String),
2039                            TopLevel(String),
2040                            KernelPid(u32),
2041                        }
2042                        let target = {
2043                            let Some(vm) = self.vms.get(vm_id) else {
2044                                log_stale_process_event(
2045                                    &self.bridge,
2046                                    vm_id,
2047                                    process_id,
2048                                    "javascript sync RPC process.kill",
2049                                );
2050                                return Ok(());
2051                            };
2052                            let Some(caller) = vm.active_processes.get(process_id) else {
2053                                log_stale_process_event(
2054                                    &self.bridge,
2055                                    vm_id,
2056                                    process_id,
2057                                    "javascript sync RPC process.kill",
2058                                );
2059                                return Ok(());
2060                            };
2061                            let caller_pid = i32::try_from(caller.kernel_pid).map_err(|_| {
2062                                SidecarError::InvalidState("caller pid exceeds i32".into())
2063                            })?;
2064                            if caller_pid == target_pid {
2065                                ProcessKillTarget::SelfProcess
2066                            } else if let Some((child_process_id, _)) =
2067                                caller.child_processes.iter().find(|(_, child)| {
2068                                    i32::try_from(child.kernel_pid) == Ok(target_pid)
2069                                })
2070                            {
2071                                ProcessKillTarget::Child(child_process_id.clone())
2072                            } else if let Some((target_process_id, _)) =
2073                                vm.active_processes.iter().find(|(_, process)| {
2074                                    i32::try_from(process.kernel_pid) == Ok(target_pid)
2075                                })
2076                            {
2077                                ProcessKillTarget::TopLevel(target_process_id.clone())
2078                            } else {
2079                                let target_kernel_pid =
2080                                    u32::try_from(target_pid).map_err(|_| {
2081                                        SidecarError::InvalidState(format!(
2082                                            "EINVAL: invalid process pid {target_pid}"
2083                                        ))
2084                                    })?;
2085                                ProcessKillTarget::KernelPid(target_kernel_pid)
2086                            }
2087                        };
2088                        match target {
2089                            ProcessKillTarget::SelfProcess => Ok(self
2090                                .apply_self_process_kill(vm_id, process_id, parsed_signal)
2091                                .into()),
2092                            ProcessKillTarget::Child(child_process_id) => {
2093                                self.kill_javascript_child_process(
2094                                    vm_id,
2095                                    process_id,
2096                                    &child_process_id,
2097                                    signal,
2098                                )?;
2099                                Ok(Value::Null.into())
2100                            }
2101                            ProcessKillTarget::TopLevel(target_process_id) => {
2102                                self.kill_process_internal(vm_id, &target_process_id, signal)?;
2103                                Ok(Value::Null.into())
2104                            }
2105                            ProcessKillTarget::KernelPid(target_kernel_pid) => {
2106                                // Grandchildren and untracked kernel processes are
2107                                // resolved VM-wide instead of failing with an
2108                                // unknown-pid error.
2109                                self.signal_vm_kernel_pid(vm_id, target_kernel_pid, signal)
2110                                    .map(|()| Value::Null.into())
2111                            }
2112                        }
2113                    }
2114                }
2115                "process.signal_state" => {
2116                    let (signal, registration) = parse_process_signal_state_request(&request.args)
2117                        .map_err(|error| SidecarError::InvalidState(error.to_string()))?;
2118                    let Some(vm) = self.vms.get_mut(vm_id) else {
2119                        log_stale_process_event(
2120                            &self.bridge,
2121                            vm_id,
2122                            process_id,
2123                            "javascript sync RPC process.signal_state",
2124                        );
2125                        return Ok(());
2126                    };
2127                    apply_process_signal_state_update(
2128                        &mut vm.signal_states,
2129                        process_id,
2130                        signal,
2131                        registration,
2132                    );
2133                    Ok(Value::Null.into())
2134                }
2135                "net.http_request" => {
2136                    let payload = request
2137                        .args
2138                        .first()
2139                        .cloned()
2140                        .ok_or_else(|| {
2141                            SidecarError::InvalidState(String::from(
2142                                "net.http_request requires a request payload",
2143                            ))
2144                        })
2145                        .and_then(|value| {
2146                            serde_json::from_value::<JavascriptHttpLoopbackRequest>(value).map_err(
2147                                |error| {
2148                                    SidecarError::InvalidState(format!(
2149                                        "invalid net.http_request payload: {error}"
2150                                    ))
2151                                },
2152                            )
2153                        })?;
2154                    if !is_javascript_loopback_host(&payload.host) {
2155                        return Err(SidecarError::Execution(format!(
2156                            "EACCES: HTTP loopback request requires a loopback host, got {}",
2157                            payload.host
2158                        )));
2159                    }
2160                    self.bridge.require_network_access(
2161                        vm_id,
2162                        NetworkOperation::Http,
2163                        format_tcp_resource(&payload.host, payload.port),
2164                    )?;
2165                    let Some(vm) = self.vms.get_mut(vm_id) else {
2166                        log_stale_process_event(
2167                            &self.bridge,
2168                            vm_id,
2169                            process_id,
2170                            "javascript sync RPC net.http_request",
2171                        );
2172                        return Ok(());
2173                    };
2174                    let resource_limits = vm.kernel.resource_limits().clone();
2175                    let socket_paths = build_javascript_socket_path_context(vm)?;
2176                    let target_is_current =
2177                        [JavascriptSocketFamily::Ipv4, JavascriptSocketFamily::Ipv6]
2178                            .iter()
2179                            .any(|family| {
2180                                socket_paths
2181                                    .http_loopback_target(*family, payload.port)
2182                                    .is_some_and(|target| {
2183                                        target.process_id == payload.process_id
2184                                            && target.server_id == payload.server_id
2185                                    })
2186                            });
2187                    if !target_is_current {
2188                        return Err(SidecarError::InvalidState(format!(
2189                            "unknown HTTP loopback target {}:{} for server {} in process {}",
2190                            payload.host, payload.port, payload.server_id, payload.process_id
2191                        )));
2192                    }
2193                    let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness);
2194                    let Some(target_process) = vm.active_processes.get_mut(&payload.process_id)
2195                    else {
2196                        return Err(SidecarError::InvalidState(format!(
2197                            "unknown HTTP loopback process {}",
2198                            payload.process_id
2199                        )));
2200                    };
2201                    dispatch_loopback_http_request(LoopbackHttpDispatchRequest {
2202                        bridge: &self.bridge,
2203                        vm_id,
2204                        dns: &vm.dns,
2205                        socket_paths: &socket_paths,
2206                        kernel: &mut vm.kernel,
2207                        kernel_readiness,
2208                        process: target_process,
2209                        resource_limits: &resource_limits,
2210                        server_id: payload.server_id,
2211                        request_json: &payload.request,
2212                    })
2213                    .map(Value::String)
2214                    .map(Into::into)
2215                }
2216                "__kernel_stdio_write"
2217                    if self
2218                        .vms
2219                        .get(vm_id)
2220                        .and_then(|vm| vm.active_processes.get(process_id))
2221                        .is_some_and(|process| process.tty_master_owner.is_some()) =>
2222                {
2223                    let (writer_kernel_pid, owner) = {
2224                        let process = self
2225                            .vms
2226                            .get(vm_id)
2227                            .and_then(|vm| vm.active_processes.get(process_id))
2228                            .expect("guarded by match arm");
2229                        (
2230                            process.kernel_pid,
2231                            process.tty_master_owner.expect("guarded by match arm"),
2232                        )
2233                    };
2234                    self.service_shared_tty_stdio_write(vm_id, writer_kernel_pid, owner, &request)
2235                        .map(Into::into)
2236                }
2237                "__kernel_stdin_read" | "__kernel_poll"
2238                    if self.kernel_wait_rpc_is_deferrable(vm_id, process_id, &request) =>
2239                {
2240                    match self.service_deferrable_kernel_wait_rpc(vm_id, process_id, &request) {
2241                        Ok(Some(response)) => Ok(response),
2242                        // Parked: an off-loop waiter re-enqueues this request as a
2243                        // process event when kernel poll state changes.
2244                        Ok(None) => return Ok(()),
2245                        Err(error) => Err(error),
2246                    }
2247                }
2248                _ => {
2249                    let Some(vm) = self.vms.get_mut(vm_id) else {
2250                        log_stale_process_event(
2251                            &self.bridge,
2252                            vm_id,
2253                            process_id,
2254                            "javascript sync RPC bridge dispatch",
2255                        );
2256                        return Ok(());
2257                    };
2258                    let resource_limits = vm.kernel.resource_limits().clone();
2259                    let network_counts = vm_network_resource_counts(vm);
2260                    let socket_paths = build_javascript_socket_path_context(vm)?;
2261                    let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness);
2262                    let Some(process) = vm.active_processes.get_mut(process_id) else {
2263                        log_stale_process_event(
2264                            &self.bridge,
2265                            vm_id,
2266                            process_id,
2267                            "javascript sync RPC bridge dispatch",
2268                        );
2269                        return Ok(());
2270                    };
2271                    service_javascript_sync_rpc(JavascriptSyncRpcServiceRequest {
2272                        bridge: &self.bridge,
2273                        vm_id,
2274                        dns: &vm.dns,
2275                        socket_paths: &socket_paths,
2276                        kernel: &mut vm.kernel,
2277                        kernel_readiness,
2278                        process,
2279                        sync_request: &request,
2280                        resource_limits: &resource_limits,
2281                        network_counts,
2282                    })
2283                }
2284            };
2285
2286        let Some(vm) = self.vms.get_mut(vm_id) else {
2287            log_stale_process_event(
2288                &self.bridge,
2289                vm_id,
2290                process_id,
2291                "javascript sync RPC response delivery",
2292            );
2293            return Ok(());
2294        };
2295        let shadow_root = vm.cwd.clone();
2296        let Some(process) = vm.active_processes.get_mut(process_id) else {
2297            log_stale_process_event(
2298                &self.bridge,
2299                vm_id,
2300                process_id,
2301                "javascript sync RPC response delivery",
2302            );
2303            return Ok(());
2304        };
2305
2306        if response.is_ok()
2307            && matches!(
2308                request.method.as_str(),
2309                "fs.chmodSync" | "fs.promises.chmod"
2310            )
2311        {
2312            let guest_path =
2313                javascript_sync_rpc_arg_str(&request.args, 0, "filesystem chmod path")?;
2314            let mode =
2315                javascript_sync_rpc_arg_u32(&request.args, 1, "filesystem chmod mode")? & 0o7777;
2316            let host_path =
2317                shadow_host_path_for_process(&shadow_root, &process.guest_cwd, guest_path);
2318            if host_path.exists() {
2319                fs::set_permissions(&host_path, fs::Permissions::from_mode(mode)).map_err(
2320                    |error| {
2321                        SidecarError::Io(format!(
2322                            "failed to mirror chmod to shadow path {}: {error}",
2323                            host_path.display()
2324                        ))
2325                    },
2326                )?;
2327            }
2328        }
2329
2330        match response {
2331            Ok(result) => process
2332                .execution
2333                .respond_javascript_sync_rpc_response(request.id, result)
2334                .or_else(ignore_stale_javascript_sync_rpc_response),
2335            Err(error) => process
2336                .execution
2337                .respond_javascript_sync_rpc_error(
2338                    request.id,
2339                    javascript_sync_rpc_error_code(&error),
2340                    error.to_string(),
2341                )
2342                .or_else(ignore_stale_javascript_sync_rpc_response),
2343        }
2344    }
2345
2346    /// Applies a `process.kill` aimed at the calling process itself and
2347    /// returns the self-delivery action payload for the bridge.
2348    fn apply_self_process_kill(
2349        &mut self,
2350        vm_id: &str,
2351        process_id: &str,
2352        parsed_signal: i32,
2353    ) -> Value {
2354        let action = self
2355            .vms
2356            .get(vm_id)
2357            .and_then(|vm| vm.signal_states.get(process_id))
2358            .and_then(|handlers| handlers.get(&(parsed_signal as u32)))
2359            .map(|registration| registration.action.clone())
2360            .unwrap_or(SignalDispositionAction::Default);
2361        if action == SignalDispositionAction::Default
2362            && parsed_signal != 0
2363            && !matches!(
2364                canonical_signal_name(parsed_signal),
2365                Some("SIGWINCH" | "SIGCHLD" | "SIGCONT" | "SIGURG")
2366            )
2367        {
2368            if let Some(vm) = self.vms.get_mut(vm_id) {
2369                if let Some(process) = vm.active_processes.get_mut(process_id) {
2370                    process.pending_self_signal_exit = Some(parsed_signal);
2371                }
2372            }
2373        }
2374        json!({
2375            "self": true,
2376            "action": match action {
2377                SignalDispositionAction::Default => "default",
2378                SignalDispositionAction::Ignore => "ignore",
2379                SignalDispositionAction::User => "user",
2380            },
2381        })
2382    }
2383
2384    pub(crate) fn vm_ids_for_scope(
2385        &self,
2386        ownership: &OwnershipScope,
2387    ) -> Result<Vec<String>, SidecarError> {
2388        match ownership {
2389            OwnershipScope::SessionOwnership(inner) => {
2390                self.require_owned_session(&inner.connection_id, &inner.session_id)?;
2391                Ok(self
2392                    .sessions
2393                    .get(&inner.session_id)
2394                    .expect("owned session should exist")
2395                    .vm_ids
2396                    .iter()
2397                    .cloned()
2398                    .collect())
2399            }
2400            OwnershipScope::VmOwnership(inner) => {
2401                self.require_owned_vm(&inner.connection_id, &inner.session_id, &inner.vm_id)?;
2402                Ok(vec![inner.vm_id.clone()])
2403            }
2404            OwnershipScope::ConnectionOwnership(..) => Err(SidecarError::InvalidState(
2405                String::from("event polling requires session or VM ownership scope"),
2406            )),
2407        }
2408    }
2409
2410    pub(crate) fn vm_ownership(&self, vm_id: &str) -> Result<OwnershipScope, SidecarError> {
2411        let vm = self
2412            .vms
2413            .get(vm_id)
2414            .ok_or_else(|| SidecarError::InvalidState(format!("unknown sidecar VM {vm_id}")))?;
2415        Ok(OwnershipScope::vm(&vm.connection_id, &vm.session_id, vm_id))
2416    }
2417
2418    pub(crate) fn vm_has_active_processes(&self, vm_id: &str) -> bool {
2419        self.vms
2420            .get(vm_id)
2421            .is_some_and(|vm| !vm.active_processes.is_empty())
2422    }
2423
2424    fn require_authenticated_connection(&self, connection_id: &str) -> Result<(), SidecarError> {
2425        if self.connections.contains_key(connection_id) {
2426            Ok(())
2427        } else {
2428            Err(SidecarError::InvalidState(format!(
2429                "connection {connection_id} has not authenticated"
2430            )))
2431        }
2432    }
2433
2434    pub(crate) fn require_owned_session(
2435        &self,
2436        connection_id: &str,
2437        session_id: &str,
2438    ) -> Result<(), SidecarError> {
2439        self.require_authenticated_connection(connection_id)?;
2440        let session = self.sessions.get(session_id).ok_or_else(|| {
2441            SidecarError::InvalidState(format!("unknown sidecar session {session_id}"))
2442        })?;
2443        if session.connection_id == connection_id {
2444            Ok(())
2445        } else {
2446            Err(SidecarError::InvalidState(format!(
2447                "session {session_id} is not owned by connection {connection_id}"
2448            )))
2449        }
2450    }
2451
2452    pub(crate) fn require_owned_vm(
2453        &self,
2454        connection_id: &str,
2455        session_id: &str,
2456        vm_id: &str,
2457    ) -> Result<(), SidecarError> {
2458        self.require_owned_session(connection_id, session_id)?;
2459        let vm = self
2460            .vms
2461            .get(vm_id)
2462            .ok_or_else(|| SidecarError::InvalidState(format!("unknown sidecar VM {vm_id}")))?;
2463        if vm.connection_id != connection_id || vm.session_id != session_id {
2464            return Err(SidecarError::InvalidState(format!(
2465                "VM {vm_id} is not owned by {connection_id}/{session_id}"
2466            )));
2467        }
2468        Ok(())
2469    }
2470
2471    fn connection_id_for(&self, ownership: &OwnershipScope) -> Result<String, SidecarError> {
2472        match ownership {
2473            OwnershipScope::ConnectionOwnership(inner) => Ok(inner.connection_id.clone()),
2474            OwnershipScope::SessionOwnership(..) | OwnershipScope::VmOwnership(..) => {
2475                Err(SidecarError::InvalidState(String::from(
2476                    "request requires connection ownership scope",
2477                )))
2478            }
2479        }
2480    }
2481
2482    fn validate_auth_token(&self, auth_token: &str) -> Result<(), SidecarError> {
2483        let Some(expected_auth_token) = self.config.expected_auth_token.as_deref() else {
2484            return Ok(());
2485        };
2486
2487        if auth_token == expected_auth_token {
2488            Ok(())
2489        } else {
2490            Err(SidecarError::Unauthorized(String::from(
2491                "authenticate request provided an invalid auth token",
2492            )))
2493        }
2494    }
2495
2496    fn allocate_connection_id(&mut self) -> String {
2497        self.next_connection_id += 1;
2498        format!("conn-{}", self.next_connection_id)
2499    }
2500
2501    fn take_matching_process_event_envelope(
2502        &mut self,
2503        vm_id: &str,
2504        process_id: &str,
2505    ) -> Result<Option<ProcessEventEnvelope>, SidecarError> {
2506        if let Some(index) = self
2507            .pending_process_events
2508            .iter()
2509            .position(|event| event.vm_id == vm_id && event.process_id == process_id)
2510        {
2511            return Ok(self.pending_process_events.remove(index));
2512        }
2513
2514        let mut matching_envelope = None;
2515        let mut deferred = Vec::new();
2516        {
2517            let pending_capacity = self.pending_process_event_capacity();
2518            let receiver = self.process_event_receiver.as_mut().ok_or_else(|| {
2519                SidecarError::InvalidState(String::from("process event receiver unavailable"))
2520            })?;
2521            loop {
2522                if deferred.len() >= pending_capacity {
2523                    if receiver.is_empty() {
2524                        break;
2525                    }
2526                    return Err(process_event_queue_overflow_error());
2527                }
2528                let envelope = match receiver.try_recv() {
2529                    Ok(envelope) => envelope,
2530                    Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
2531                    Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
2532                };
2533                if matching_envelope.is_none()
2534                    && envelope.vm_id == vm_id
2535                    && envelope.process_id == process_id
2536                {
2537                    matching_envelope = Some(envelope);
2538                    break;
2539                }
2540                deferred.push(envelope);
2541            }
2542        }
2543        for envelope in deferred {
2544            self.queue_pending_process_event(envelope)?;
2545        }
2546
2547        Ok(matching_envelope)
2548    }
2549
2550    fn allocate_sidecar_request_id(&mut self) -> RequestId {
2551        let request_id = self.next_sidecar_request_id;
2552        self.next_sidecar_request_id -= 1;
2553        request_id
2554    }
2555
2556    pub(crate) fn session_scope_for(
2557        &self,
2558        ownership: &OwnershipScope,
2559    ) -> Result<(String, String), SidecarError> {
2560        match ownership {
2561            OwnershipScope::SessionOwnership(inner) => {
2562                Ok((inner.connection_id.clone(), inner.session_id.clone()))
2563            }
2564            OwnershipScope::ConnectionOwnership(..) | OwnershipScope::VmOwnership(..) => {
2565                Err(SidecarError::InvalidState(String::from(
2566                    "request requires session ownership scope",
2567                )))
2568            }
2569        }
2570    }
2571
2572    pub(crate) fn vm_scope_for(
2573        &self,
2574        ownership: &OwnershipScope,
2575    ) -> Result<(String, String, String), SidecarError> {
2576        match ownership {
2577            OwnershipScope::VmOwnership(inner) => Ok((
2578                inner.connection_id.clone(),
2579                inner.session_id.clone(),
2580                inner.vm_id.clone(),
2581            )),
2582            OwnershipScope::ConnectionOwnership(..) | OwnershipScope::SessionOwnership(..) => Err(
2583                SidecarError::InvalidState(String::from("request requires VM ownership scope")),
2584            ),
2585        }
2586    }
2587
2588    pub(crate) fn respond(
2589        &self,
2590        request: &RequestFrame,
2591        payload: ResponsePayload,
2592    ) -> ResponseFrame {
2593        shared_respond(request, payload)
2594    }
2595
2596    fn reject(&self, request: &RequestFrame, code: &str, message: &str) -> ResponseFrame {
2597        shared_reject(request, code, message)
2598    }
2599
2600    pub fn queue_sidecar_request(
2601        &mut self,
2602        ownership: OwnershipScope,
2603        payload: SidecarRequestPayload,
2604    ) -> Result<RequestId, SidecarError> {
2605        if self.outbound_sidecar_requests.len() >= MAX_OUTBOUND_SIDECAR_REQUESTS {
2606            return Err(outbound_sidecar_request_queue_overflow_error());
2607        }
2608        if self.pending_sidecar_responses.pending_count() >= MAX_PENDING_SIDECAR_RESPONSES {
2609            return Err(sidecar_response_pending_overflow_error());
2610        }
2611        let request_id = self.allocate_sidecar_request_id();
2612        let request = SidecarRequestFrame::new(request_id, ownership, payload);
2613        self.pending_sidecar_responses
2614            .register_request(&request)
2615            .map_err(sidecar_response_tracker_error)?;
2616        self.outbound_sidecar_requests.push_back(request);
2617        self.outbound_sidecar_requests_gauge
2618            .observe_depth(self.outbound_sidecar_requests.len());
2619        self.pending_sidecar_responses_gauge
2620            .observe_depth(self.pending_sidecar_responses.pending_count());
2621        Ok(request_id)
2622    }
2623
2624    pub fn queue_wire_sidecar_request(
2625        &mut self,
2626        ownership: crate::wire::OwnershipScope,
2627        payload: crate::wire::SidecarRequestPayload,
2628    ) -> Result<crate::wire::RequestId, SidecarError> {
2629        let ownership = crate::wire::ownership_scope_to_compat(ownership);
2630        let payload = crate::wire::sidecar_request_payload_to_compat(&ownership, payload)
2631            .map_err(wire_protocol_error)?;
2632        self.queue_sidecar_request(ownership, payload)
2633    }
2634
2635    pub fn pop_sidecar_request(&mut self) -> Option<SidecarRequestFrame> {
2636        let request = self.outbound_sidecar_requests.pop_front();
2637        self.outbound_sidecar_requests_gauge
2638            .observe_depth(self.outbound_sidecar_requests.len());
2639        request
2640    }
2641
2642    pub fn pop_wire_sidecar_request(
2643        &mut self,
2644    ) -> Result<Option<crate::wire::SidecarRequestFrame>, SidecarError> {
2645        self.pop_sidecar_request()
2646            .map(crate::wire::sidecar_request_frame_from_compat)
2647            .transpose()
2648            .map_err(wire_protocol_error)
2649    }
2650
2651    pub fn accept_sidecar_response(
2652        &mut self,
2653        response: SidecarResponseFrame,
2654    ) -> Result<(), SidecarError> {
2655        match self.pending_sidecar_responses.accept_response(&response) {
2656            Ok(()) => {}
2657            // A response for a request that is no longer pending (its owning VM
2658            // was disposed, abandoning the in-flight callback) or already
2659            // completed is a benign late/stale reply on the shared sidecar — a
2660            // per-VM `sidecar_request` can be answered by the host after that VM
2661            // has been torn down (multiple VMs share one sidecar process). Drop
2662            // it instead of failing the whole sidecar over a harmless straggler.
2663            Err(
2664                error @ (SidecarResponseTrackerError::UnmatchedResponse { .. }
2665                | SidecarResponseTrackerError::DuplicateResponse { .. }),
2666            ) => {
2667                tracing::warn!(
2668                    request_id = response.request_id,
2669                    "dropping stale sidecar response with no matching pending request: {error}"
2670                );
2671                return Ok(());
2672            }
2673            Err(error) => return Err(sidecar_response_tracker_error(error)),
2674        }
2675        self.pending_sidecar_responses_gauge
2676            .observe_depth(self.pending_sidecar_responses.pending_count());
2677        self.completed_sidecar_response_order
2678            .push_back(response.request_id);
2679        self.completed_sidecar_responses
2680            .insert(response.request_id, response);
2681        self.completed_sidecar_responses_gauge
2682            .observe_depth(self.completed_sidecar_responses.len());
2683        while self.completed_sidecar_responses.len() > MAX_COMPLETED_SIDECAR_RESPONSES {
2684            match self.completed_sidecar_response_order.pop_front() {
2685                // Only a response that was never retrieved is a real loss; an id
2686                // already taken via take_sidecar_response leaves a stale order
2687                // entry that removes to None and is not a dropped response.
2688                Some(evicted) => {
2689                    if self.completed_sidecar_responses.remove(&evicted).is_some() {
2690                        tracing::warn!(
2691                            queue = "completed_sidecar_responses",
2692                            evicted_request_id = evicted,
2693                            capacity = MAX_COMPLETED_SIDECAR_RESPONSES,
2694                            "dropping an unretrieved completed sidecar response to stay within cap; the host can no longer fetch it (response lost)"
2695                        );
2696                        self.completed_sidecar_responses_gauge
2697                            .observe_depth(self.completed_sidecar_responses.len());
2698                    }
2699                }
2700                None => break,
2701            }
2702        }
2703        Ok(())
2704    }
2705
2706    pub fn accept_wire_sidecar_response(
2707        &mut self,
2708        response: crate::wire::SidecarResponseFrame,
2709    ) -> Result<(), SidecarError> {
2710        let response =
2711            crate::wire::sidecar_response_frame_to_compat(response).map_err(wire_protocol_error)?;
2712        self.accept_sidecar_response(response)
2713    }
2714
2715    pub fn take_sidecar_response(&mut self, request_id: RequestId) -> Option<SidecarResponseFrame> {
2716        let response = self.completed_sidecar_responses.remove(&request_id);
2717        if response.is_some() {
2718            self.completed_sidecar_response_order
2719                .retain(|completed_id| completed_id != &request_id);
2720            self.completed_sidecar_responses_gauge
2721                .observe_depth(self.completed_sidecar_responses.len());
2722        }
2723        response
2724    }
2725
2726    pub fn take_wire_sidecar_response(
2727        &mut self,
2728        request_id: crate::wire::RequestId,
2729    ) -> Result<Option<crate::wire::SidecarResponseFrame>, SidecarError> {
2730        self.take_sidecar_response(request_id)
2731            .map(|response| {
2732                crate::wire::sidecar_response_frame_from_compat(response)
2733                    .map_err(wire_protocol_error)
2734            })
2735            .transpose()
2736    }
2737
2738    pub(crate) fn vm_lifecycle_event(
2739        &self,
2740        connection_id: &str,
2741        session_id: &str,
2742        vm_id: &str,
2743        state: VmLifecycleState,
2744    ) -> EventFrame {
2745        shared_vm_lifecycle_event(connection_id, session_id, vm_id, state)
2746    }
2747
2748    fn ensure_request_within_frame_limit(
2749        &self,
2750        request: &RequestFrame,
2751    ) -> Result<(), SidecarError> {
2752        let frame = crate::protocol::to_generated_protocol_frame(
2753            &crate::protocol::ProtocolFrame::Request(request.clone()),
2754        )
2755        .map_err(|error| {
2756            SidecarError::InvalidState(format!("failed to convert request frame: {error}"))
2757        })?;
2758        let crate::wire::ProtocolFrame::RequestFrame(_) = &frame else {
2759            return Err(SidecarError::InvalidState(String::from(
2760                "request converted to non-request wire frame",
2761            )));
2762        };
2763
2764        crate::wire::WireFrameCodec::new(self.config.max_frame_bytes)
2765            .encode(&frame)
2766            .map(|_| ())
2767            .map_err(|error| SidecarError::FrameTooLarge(error.to_string()))
2768    }
2769}
2770
2771impl<B> ExtensionHost for NativeSidecar<B>
2772where
2773    B: NativeSidecarBridge + Send + 'static,
2774    BridgeError<B>: fmt::Debug + Send + Sync + 'static,
2775{
2776    fn spawn_process<'a>(
2777        &'a mut self,
2778        ownership: OwnershipScope,
2779        payload: ExecuteRequest,
2780    ) -> ExtensionFuture<'a, ProcessStartedResponse> {
2781        Box::pin(async move {
2782            let request = RequestFrame::new(0, ownership, RequestPayload::Execute(payload.clone()));
2783            let dispatch = NativeSidecar::execute(self, &request, payload).await?;
2784            match dispatch.response.payload {
2785                ResponsePayload::ProcessStarted(response) => Ok(response),
2786                other => Err(unexpected_extension_host_response("execute", other)),
2787            }
2788        })
2789    }
2790
2791    fn write_stdin<'a>(
2792        &'a mut self,
2793        ownership: OwnershipScope,
2794        payload: WriteStdinRequest,
2795    ) -> ExtensionFuture<'a, StdinWrittenResponse> {
2796        Box::pin(async move {
2797            let request =
2798                RequestFrame::new(0, ownership, RequestPayload::WriteStdin(payload.clone()));
2799            let dispatch = NativeSidecar::write_stdin(self, &request, payload).await?;
2800            match dispatch.response.payload {
2801                ResponsePayload::StdinWritten(response) => Ok(response),
2802                other => Err(unexpected_extension_host_response("write_stdin", other)),
2803            }
2804        })
2805    }
2806
2807    fn close_stdin<'a>(
2808        &'a mut self,
2809        ownership: OwnershipScope,
2810        payload: CloseStdinRequest,
2811    ) -> ExtensionFuture<'a, StdinClosedResponse> {
2812        Box::pin(async move {
2813            let request =
2814                RequestFrame::new(0, ownership, RequestPayload::CloseStdin(payload.clone()));
2815            let dispatch = NativeSidecar::close_stdin(self, &request, payload).await?;
2816            match dispatch.response.payload {
2817                ResponsePayload::StdinClosed(response) => Ok(response),
2818                other => Err(unexpected_extension_host_response("close_stdin", other)),
2819            }
2820        })
2821    }
2822
2823    fn kill_process<'a>(
2824        &'a mut self,
2825        ownership: OwnershipScope,
2826        payload: KillProcessRequest,
2827    ) -> ExtensionFuture<'a, ProcessKilledResponse> {
2828        Box::pin(async move {
2829            let request =
2830                RequestFrame::new(0, ownership, RequestPayload::KillProcess(payload.clone()));
2831            let dispatch = NativeSidecar::kill_process(self, &request, payload).await?;
2832            match dispatch.response.payload {
2833                ResponsePayload::ProcessKilled(response) => Ok(response),
2834                other => Err(unexpected_extension_host_response("kill_process", other)),
2835            }
2836        })
2837    }
2838
2839    fn poll_event<'a>(
2840        &'a mut self,
2841        ownership: OwnershipScope,
2842        timeout: Duration,
2843    ) -> ExtensionFuture<'a, Option<EventFrame>> {
2844        Box::pin(async move { NativeSidecar::poll_event(self, &ownership, timeout).await })
2845    }
2846
2847    fn projected_agents<'a>(
2848        &'a mut self,
2849        ownership: OwnershipScope,
2850    ) -> ExtensionFuture<'a, Vec<crate::extension::ProjectedAgentLaunchEntry>> {
2851        Box::pin(async move {
2852            let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?;
2853            self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
2854            let vm = self.vms.get(&vm_id).ok_or_else(|| {
2855                SidecarError::InvalidState(format!("unknown VM {vm_id}"))
2856            })?;
2857            Ok(vm
2858                .projected_agent_launch
2859                .iter()
2860                .map(|(id, launch): (&String, &crate::state::ProjectedAgentLaunch)| crate::extension::ProjectedAgentLaunchEntry {
2861                    id: id.clone(),
2862                    acp_entrypoint: launch.acp_entrypoint.clone(),
2863                    env: launch.env.clone(),
2864                    launch_args: launch.launch_args.clone(),
2865                })
2866                .collect())
2867        })
2868    }
2869
2870    fn guest_filesystem_call<'a>(
2871        &'a mut self,
2872        ownership: OwnershipScope,
2873        payload: GuestFilesystemCallRequest,
2874    ) -> ExtensionFuture<'a, GuestFilesystemResultResponse> {
2875        Box::pin(async move {
2876            let request = RequestFrame::new(
2877                0,
2878                ownership,
2879                RequestPayload::GuestFilesystemCall(payload.clone()),
2880            );
2881            let dispatch = NativeSidecar::guest_filesystem_call(self, &request, payload).await?;
2882            match dispatch.response.payload {
2883                ResponsePayload::GuestFilesystemResult(response) => Ok(response),
2884                other => Err(unexpected_extension_host_response(
2885                    "guest_filesystem_call",
2886                    other,
2887                )),
2888            }
2889        })
2890    }
2891
2892    fn bind_process_to_session<'a>(
2893        &'a mut self,
2894        ownership: OwnershipScope,
2895        namespace: String,
2896        ext_session_id: String,
2897        process_id: String,
2898    ) -> ExtensionFuture<'a, ()> {
2899        Box::pin(async move {
2900            self.bind_extension_process_resource(ownership, namespace, ext_session_id, process_id)
2901        })
2902    }
2903
2904    fn bind_vm_to_session<'a>(
2905        &'a mut self,
2906        ownership: OwnershipScope,
2907        namespace: String,
2908        ext_session_id: String,
2909    ) -> ExtensionFuture<'a, ()> {
2910        Box::pin(
2911            async move { self.bind_extension_vm_resource(ownership, namespace, ext_session_id) },
2912        )
2913    }
2914
2915    fn dispose_session_resources<'a>(
2916        &'a mut self,
2917        ownership: OwnershipScope,
2918        namespace: String,
2919        ext_session_id: String,
2920    ) -> ExtensionFuture<'a, Vec<EventFrame>> {
2921        Box::pin(async move {
2922            let key = (namespace, ext_session_id);
2923            let Some(resources) = self.extension_sessions.get(&key) else {
2924                return Ok(Vec::new());
2925            };
2926            if resources.ownership != ownership {
2927                return Err(SidecarError::InvalidState(String::from(
2928                    "extension session ownership did not match dispose request",
2929                )));
2930            }
2931            let resources = self
2932                .extension_sessions
2933                .remove(&key)
2934                .expect("extension resources existed before removal");
2935            let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?;
2936            for process_id in resources.process_ids {
2937                if self
2938                    .vms
2939                    .get(&vm_id)
2940                    .is_some_and(|vm| vm.active_processes.contains_key(&process_id))
2941                {
2942                    self.kill_process_internal(&vm_id, &process_id, "SIGTERM")?;
2943                }
2944            }
2945            let mut events = Vec::new();
2946            for resource_vm_id in resources.vm_ids {
2947                if self.vms.contains_key(&resource_vm_id) {
2948                    events.extend(
2949                        self.dispose_vm_internal(
2950                            &connection_id,
2951                            &session_id,
2952                            &resource_vm_id,
2953                            DisposeReason::Requested,
2954                        )
2955                        .await?,
2956                    );
2957                }
2958            }
2959            Ok(events)
2960        })
2961    }
2962
2963    fn start_buffering_process_output<'a>(
2964        &'a mut self,
2965        ownership: OwnershipScope,
2966        process_id: String,
2967    ) -> ExtensionFuture<'a, ()> {
2968        Box::pin(async move {
2969            let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?;
2970            self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
2971            let key = (vm_id, process_id);
2972            if self.extension_process_output_buffers.contains_key(&key) {
2973                return Err(SidecarError::Conflict(String::from(
2974                    "extension process output buffering already started",
2975                )));
2976            }
2977            self.extension_process_output_buffers
2978                .insert(key, ExtensionBufferedProcessOutput::default());
2979            Ok(())
2980        })
2981    }
2982
2983    fn handoff_buffered_process_output<'a>(
2984        &'a mut self,
2985        ownership: OwnershipScope,
2986        namespace: String,
2987        ext_session_id: String,
2988        process_id: String,
2989        timeout: Duration,
2990    ) -> ExtensionFuture<'a, ExtensionBufferedProcessOutput> {
2991        Box::pin(async move {
2992            let (connection_id, session_id, vm_id) = self.vm_scope_for(&ownership)?;
2993            self.require_owned_vm(&connection_id, &session_id, &vm_id)?;
2994            let key = (vm_id.clone(), process_id.clone());
2995            let deadline = Instant::now() + timeout;
2996            loop {
2997                self.pump_process_events(&ownership).await?;
2998                while let Some(envelope) =
2999                    self.take_matching_process_event_envelope(&vm_id, &process_id)?
3000                {
3001                    if self.capture_extension_process_output_event(
3002                        &vm_id,
3003                        &process_id,
3004                        &envelope.event,
3005                    ) {
3006                        continue;
3007                    }
3008                    self.queue_pending_process_event(envelope)?;
3009                    break;
3010                }
3011                let buffered = self
3012                    .extension_process_output_buffers
3013                    .get(&key)
3014                    .is_some_and(|buffer| !buffer.stdout.is_empty() || !buffer.stderr.is_empty());
3015                if buffered || timeout.is_zero() || Instant::now() >= deadline {
3016                    break;
3017                }
3018                let remaining = deadline.saturating_duration_since(Instant::now());
3019                time::sleep(remaining.min(Duration::from_millis(10))).await;
3020            }
3021            self.bind_extension_process_resource(
3022                ownership,
3023                namespace,
3024                ext_session_id,
3025                process_id.clone(),
3026            )?;
3027            self.extension_process_output_buffers
3028                .remove(&key)
3029                .ok_or_else(|| {
3030                    SidecarError::InvalidState(String::from(
3031                        "extension process output buffering was not started",
3032                    ))
3033                })
3034        })
3035    }
3036}
3037
3038fn unexpected_extension_host_response(operation: &str, payload: ResponsePayload) -> SidecarError {
3039    match payload {
3040        ResponsePayload::Rejected(response) => SidecarError::InvalidState(format!(
3041            "extension {operation} rejected with {}: {}",
3042            response.code, response.message
3043        )),
3044        other => SidecarError::InvalidState(format!(
3045            "extension {operation} returned unexpected response: {other:?}"
3046        )),
3047    }
3048}
3049
3050fn shadow_host_path_for_process(
3051    shadow_root: &Path,
3052    process_guest_cwd: &str,
3053    guest_path: &str,
3054) -> PathBuf {
3055    let normalized_guest_path = if guest_path.starts_with('/') {
3056        normalize_path(guest_path)
3057    } else {
3058        normalize_path(&format!(
3059            "{}/{}",
3060            process_guest_cwd.trim_end_matches('/'),
3061            guest_path
3062        ))
3063    };
3064    if normalized_guest_path == "/" {
3065        shadow_root.to_path_buf()
3066    } else {
3067        shadow_root.join(normalized_guest_path.trim_start_matches('/'))
3068    }
3069}
3070
3071fn sidecar_response_tracker_error(error: SidecarResponseTrackerError) -> SidecarError {
3072    SidecarError::InvalidState(format!(
3073        "invalid sidecar response correlation state: {error}"
3074    ))
3075}
3076
3077fn map_bridge_permission(decision: agentos_bridge::PermissionDecision) -> PermissionDecision {
3078    match decision.verdict {
3079        agentos_bridge::PermissionVerdict::Allow => PermissionDecision::allow(),
3080        agentos_bridge::PermissionVerdict::Deny => PermissionDecision::deny(
3081            decision
3082                .reason
3083                .unwrap_or_else(|| String::from("denied by host")),
3084        ),
3085        agentos_bridge::PermissionVerdict::Prompt => PermissionDecision::deny(
3086            decision
3087                .reason
3088                .unwrap_or_else(|| String::from("permission prompt required")),
3089        ),
3090    }
3091}
3092
3093fn audit_timestamp() -> String {
3094    SystemTime::now()
3095        .duration_since(UNIX_EPOCH)
3096        .expect("system time before unix epoch")
3097        .as_millis()
3098        .to_string()
3099}
3100
3101pub(crate) fn audit_fields<I, K, V>(fields: I) -> BTreeMap<String, String>
3102where
3103    I: IntoIterator<Item = (K, V)>,
3104    K: Into<String>,
3105    V: Into<String>,
3106{
3107    let mut mapped = BTreeMap::from([(String::from("timestamp"), audit_timestamp())]);
3108    for (key, value) in fields {
3109        mapped.insert(key.into(), value.into());
3110    }
3111    mapped
3112}
3113
3114pub(crate) fn emit_structured_event<B>(
3115    bridge: &SharedBridge<B>,
3116    vm_id: &str,
3117    name: &str,
3118    fields: BTreeMap<String, String>,
3119) -> Result<(), SidecarError>
3120where
3121    B: NativeSidecarBridge + Send + 'static,
3122    BridgeError<B>: fmt::Debug + Send + Sync + 'static,
3123{
3124    bridge.with_mut(|bridge| {
3125        bridge.emit_structured_event(StructuredEventRecord {
3126            vm_id: vm_id.to_owned(),
3127            name: name.to_owned(),
3128            fields,
3129        })
3130    })
3131}
3132
3133pub(crate) fn emit_security_audit_event<B>(
3134    bridge: &SharedBridge<B>,
3135    vm_id: &str,
3136    name: &str,
3137    fields: BTreeMap<String, String>,
3138) where
3139    B: NativeSidecarBridge + Send + 'static,
3140    BridgeError<B>: fmt::Debug + Send + Sync + 'static,
3141{
3142    let _ = emit_structured_event(bridge, vm_id, name, fields);
3143}
3144
3145/// Build a wire `EventFrame` carrying a `StructuredEvent` (name + string-map
3146/// detail) scoped to a connection. Used to forward limit-registry warnings to the
3147/// host as `{type:"structured", name:"limit_warning", detail}` events without a
3148/// protocol schema change. Emitted directly to the host (not via the polled,
3149/// per-session bridge queue, which is a no-op in the stdio sidecar), so a
3150/// process-global signal is delivered against the active connection.
3151pub(crate) fn structured_event_frame(
3152    connection_id: &str,
3153    name: &str,
3154    detail: std::collections::HashMap<String, String>,
3155) -> Result<crate::wire::EventFrame, SidecarError> {
3156    let event = EventFrame::new(
3157        OwnershipScope::connection(connection_id),
3158        EventPayload::Structured(crate::protocol::StructuredEvent {
3159            name: name.to_owned(),
3160            detail,
3161        }),
3162    );
3163    crate::wire::event_frame_from_compat(event).map_err(|error| {
3164        SidecarError::InvalidState(format!("invalid structured event frame: {error}"))
3165    })
3166}
3167
3168pub(crate) fn log_stale_process_event<B>(
3169    bridge: &SharedBridge<B>,
3170    vm_id: &str,
3171    process_id: &str,
3172    context: &str,
3173) where
3174    B: NativeSidecarBridge + Send + 'static,
3175    BridgeError<B>: fmt::Debug + Send + Sync + 'static,
3176{
3177    let _ = bridge.emit_log(
3178        vm_id,
3179        format!(
3180            "Ignoring stale process event during {context}: VM {vm_id} process {process_id} was already reaped"
3181        ),
3182    );
3183}
3184
3185// filesystem_operation_label moved to crate::vm
3186
3187pub(crate) fn root_filesystem_error(error: impl std::fmt::Display) -> SidecarError {
3188    SidecarError::InvalidState(format!("root filesystem: {error}"))
3189}
3190
3191pub(crate) fn normalize_path(path: &str) -> String {
3192    let mut segments = Vec::new();
3193    for component in Path::new(path).components() {
3194        match component {
3195            Component::RootDir => segments.clear(),
3196            Component::ParentDir => {
3197                segments.pop();
3198            }
3199            Component::CurDir => {}
3200            Component::Normal(value) => segments.push(value.to_string_lossy().into_owned()),
3201            Component::Prefix(prefix) => {
3202                segments.push(prefix.as_os_str().to_string_lossy().into_owned());
3203            }
3204        }
3205    }
3206
3207    let normalized = format!("/{}", segments.join("/"));
3208    if normalized.is_empty() {
3209        String::from("/")
3210    } else {
3211        normalized
3212    }
3213}
3214
3215pub(crate) fn normalize_host_path(path: &Path) -> PathBuf {
3216    let mut normalized = PathBuf::new();
3217
3218    for component in path.components() {
3219        match component {
3220            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
3221            Component::RootDir => normalized.push(Path::new("/")),
3222            Component::CurDir => {}
3223            Component::ParentDir => {
3224                if normalized != Path::new("/") {
3225                    normalized.pop();
3226                }
3227            }
3228            Component::Normal(part) => normalized.push(part),
3229        }
3230    }
3231
3232    if normalized.as_os_str().is_empty() {
3233        if path.is_absolute() {
3234            PathBuf::from("/")
3235        } else {
3236            PathBuf::from(".")
3237        }
3238    } else {
3239        normalized
3240    }
3241}
3242
3243pub(crate) fn path_is_within_root(path: &Path, root: &Path) -> bool {
3244    path == root || path.starts_with(root)
3245}
3246
3247pub(crate) fn dirname(path: &str) -> String {
3248    let normalized = normalize_path(path);
3249    let parent = Path::new(&normalized)
3250        .parent()
3251        .unwrap_or_else(|| Path::new("/"));
3252    let value = parent.to_string_lossy();
3253    if value.is_empty() {
3254        String::from("/")
3255    } else {
3256        value.into_owned()
3257    }
3258}
3259
3260pub(crate) fn kernel_error(error: KernelError) -> SidecarError {
3261    SidecarError::Kernel(error.to_string())
3262}
3263
3264pub(crate) fn plugin_error(error: PluginError) -> SidecarError {
3265    SidecarError::Plugin(error.to_string())
3266}
3267
3268pub(crate) fn javascript_error(error: JavascriptExecutionError) -> SidecarError {
3269    SidecarError::Execution(error.to_string())
3270}
3271
3272pub(crate) fn wasm_error(error: WasmExecutionError) -> SidecarError {
3273    SidecarError::Execution(error.to_string())
3274}
3275
3276pub(crate) fn python_error(error: PythonExecutionError) -> SidecarError {
3277    SidecarError::Execution(error.to_string())
3278}
3279
3280pub(crate) fn vfs_error(error: VfsError) -> SidecarError {
3281    SidecarError::Kernel(error.to_string())
3282}
3283
3284/// Actionable guidance shown when guest package resolution fails because the packages live in a
3285/// non-flat `node_modules` whose package store is not visible in the VM. Mounting host `node_modules`
3286/// is a bind mount, so symlinked/store layouts
3287/// do not resolve inside the VM: Node canonicalizes a module to its store
3288/// realpath (e.g. `node_modules/.pnpm/...`, `.bun/...`, `.store/...`) which lives
3289/// above the mounted directory and the guest `fs` cannot read. Plug'n'Play
3290/// (yarn-berry default) has no `node_modules` at all. A flat (hoisted) layout is
3291/// required. The empirically-supported package managers are captured in
3292/// `crates/sidecar/tests/module_layout_e2e.rs`.
3293#[allow(dead_code)]
3294const HOISTED_NODE_MODULES_GUIDANCE: &str = "secure-exec can't load mounted node_modules: the directory uses a non-flat layout (pnpm / bun / yarn workspaces store, or yarn Plug'n'Play) whose package store isn't visible inside the VM. A flat (hoisted) node_modules is required.\n  - pnpm        -> add `node-linker=hoisted` to .npmrc, then reinstall\n  - yarn berry  -> set `nodeLinker: node-modules` in .yarnrc.yml (not pnp/pnpm)\n  - bun         -> install dependencies outside a workspace (workspaces use a .bun store)\n  - npm / yarn classic -> already flat, no change needed";
3295
3296/// Detect, from an adapter's captured stderr, a non-flat-`node_modules` failure
3297/// signature. Returns the actionable guidance to fold into the surfaced error,
3298/// or `None` when the failure is unrelated.
3299///
3300/// Two signatures, both kept specific so they never fire on unrelated crashes:
3301/// - a missing-file / cannot-resolve error referencing a package STORE path that
3302///   lives above the mounted project (`.pnpm`, `.bun`, `.store`, PnP `__virtual__`),
3303/// - a yarn Plug'n'Play fingerprint (`.pnp.cjs`, the zip cache, or PnP's
3304///   "isn't declared in your dependencies" resolver error).
3305#[allow(dead_code)]
3306fn symlinked_node_modules_hint(stderr: &str) -> Option<&'static str> {
3307    // Package stores that only appear in a path when a non-flat layout is used.
3308    // pnpm (isolated), bun (workspace), yarn-berry (nodeLinker: pnpm), and PnP
3309    // virtual instances all keep real package files under these store dirs, which
3310    // sit above the mounted project node_modules and so are not guest-visible.
3311    const STORE_MARKERS: &[&str] = &[
3312        "node_modules/.pnpm/",
3313        "node_modules/.bun/",
3314        "node_modules/.store/",
3315        "/__virtual__/",
3316    ];
3317    // Yarn Plug'n'Play has no node_modules at all; resolution fails against the
3318    // .pnp runtime / zip cache. "isn't declared in your dependencies" is PnP's
3319    // distinctive resolver error and is specific enough to fire on its own.
3320    const PNP_STRICT_MARKERS: &[&str] = &["isn't declared in your dependencies"];
3321    const PNP_PATH_MARKERS: &[&str] = &[".pnp.cjs", ".pnp.loader.mjs", "/.yarn/cache/"];
3322
3323    if PNP_STRICT_MARKERS.iter().any(|m| stderr.contains(m)) {
3324        return Some(HOISTED_NODE_MODULES_GUIDANCE);
3325    }
3326
3327    let missing = stderr.contains("ENOENT")
3328        || stderr.contains("no such file or directory")
3329        || stderr.contains("Cannot find module")
3330        || stderr.contains("MODULE_NOT_FOUND");
3331    if !missing {
3332        return None;
3333    }
3334    if STORE_MARKERS.iter().any(|m| stderr.contains(m))
3335        || PNP_PATH_MARKERS.iter().any(|m| stderr.contains(m))
3336    {
3337        return Some(HOISTED_NODE_MODULES_GUIDANCE);
3338    }
3339    None
3340}
3341
3342#[cfg(test)]
3343mod symlinked_node_modules_hint_tests {
3344    use super::symlinked_node_modules_hint;
3345
3346    // Positive cases: each non-flat package manager's store/PnP signature.
3347    #[test]
3348    fn matches_pnpm_store_enoent() {
3349        // Real pi-coding-agent failure: getPackageDir() falls back to a
3350        // dist/package.json inside the unreachable .pnpm store.
3351        let stderr = "Error: ENOENT: no such file or directory, open '/root/node_modules/.pnpm/@mariozechner+pi-coding-agent@0.60.0_x/node_modules/@mariozechner/pi-coding-agent/dist/package.json'";
3352        let hint = symlinked_node_modules_hint(stderr).expect("expected hoisted guidance");
3353        assert!(hint.contains("secure-exec can't load mounted node_modules"));
3354        assert!(!hint.contains("agentos"));
3355    }
3356
3357    #[test]
3358    fn matches_bun_store_enoent() {
3359        let stderr = "Error: ENOENT: no such file or directory, open '/root/node_modules/.bun/is-odd@3.0.1/node_modules/is-odd/package.json'";
3360        assert!(symlinked_node_modules_hint(stderr).is_some());
3361    }
3362
3363    #[test]
3364    fn matches_yarn_pnpm_store_enoent() {
3365        let stderr = "Error: ENOENT: no such file or directory, open '/root/node_modules/.store/is-odd-npm-3.0.1-93c3c3f41b/package/package.json'";
3366        assert!(symlinked_node_modules_hint(stderr).is_some());
3367    }
3368
3369    #[test]
3370    fn matches_pnp_declared_error() {
3371        // Yarn PnP's distinctive resolver error (no node_modules at all).
3372        let stderr = "Error: Your application tried to access is-number, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound.";
3373        assert!(symlinked_node_modules_hint(stderr).is_some());
3374    }
3375
3376    #[test]
3377    fn matches_pnp_cjs_module_not_found() {
3378        let stderr = "Error: Cannot find module 'is-odd'\n    at /root/.pnp.cjs:12345:18\n    code: 'MODULE_NOT_FOUND'";
3379        assert!(symlinked_node_modules_hint(stderr).is_some());
3380    }
3381
3382    #[test]
3383    fn matches_virtual_instance() {
3384        let stderr = "Error: ENOENT: no such file or directory, open '/root/.yarn/__virtual__/is-odd-abc/1/node_modules/is-odd/package.json'";
3385        assert!(symlinked_node_modules_hint(stderr).is_some());
3386    }
3387
3388    // Negative cases: must not fire.
3389    #[test]
3390    fn ignores_enoent_outside_a_store() {
3391        let stderr = "Error: ENOENT: no such file or directory, open '/tmp/scratch/config.json'";
3392        assert!(symlinked_node_modules_hint(stderr).is_none());
3393    }
3394
3395    #[test]
3396    fn ignores_store_path_without_missing_file() {
3397        let stderr =
3398            "loaded /root/node_modules/.pnpm/some-pkg@1.0.0/node_modules/some-pkg/index.js";
3399        assert!(symlinked_node_modules_hint(stderr).is_none());
3400    }
3401
3402    #[test]
3403    fn ignores_flat_node_modules_enoent() {
3404        // npm / yarn-nm / pnpm-hoisted: flat, no store dir in the path.
3405        let stderr = "Error: ENOENT: no such file or directory, open '/root/node_modules/is-odd/missing-asset.json'";
3406        assert!(symlinked_node_modules_hint(stderr).is_none());
3407    }
3408
3409    #[test]
3410    fn ignores_unrelated_failure() {
3411        let stderr = "Error: connect ECONNREFUSED 127.0.0.1:443";
3412        assert!(symlinked_node_modules_hint(stderr).is_none());
3413    }
3414}
3415
3416#[cfg(test)]
3417mod structured_event_frame_tests {
3418    use super::*;
3419
3420    #[test]
3421    fn structured_event_frame_round_trips_limit_warning() {
3422        let mut detail = std::collections::HashMap::new();
3423        // Pin a real emitted limit name rather than a fictional string.
3424        let limit_name = TrackedLimit::JavascriptEventChannel.as_str();
3425        detail.insert(String::from("limit"), String::from(limit_name));
3426        detail.insert(String::from("fillPercent"), String::from("82"));
3427
3428        let wire = structured_event_frame("conn-1", "limit_warning", detail)
3429            .expect("build structured event frame");
3430        let compat = crate::wire::event_frame_to_compat(wire).expect("convert to compat");
3431
3432        match compat.payload {
3433            EventPayload::Structured(event) => {
3434                assert_eq!(event.name, "limit_warning");
3435                assert_eq!(
3436                    event.detail.get("limit").map(String::as_str),
3437                    Some(limit_name)
3438                );
3439                assert_eq!(
3440                    event.detail.get("fillPercent").map(String::as_str),
3441                    Some("82")
3442                );
3443            }
3444            other => panic!("expected structured payload, got {other:?}"),
3445        }
3446        match compat.ownership {
3447            OwnershipScope::ConnectionOwnership(inner) => {
3448                assert_eq!(inner.connection_id, "conn-1");
3449            }
3450            other => panic!("expected connection ownership, got {other:?}"),
3451        }
3452    }
3453}
3454
3455#[cfg(test)]
3456mod dispose_lifecycle_tests {
3457    use super::*;
3458    use crate::extension::ExtensionResponse;
3459    use crate::stdio::LocalBridge;
3460    use std::sync::atomic::{AtomicUsize, Ordering};
3461
3462    fn block_on<F: std::future::Future>(future: F) -> F::Output {
3463        tokio::runtime::Builder::new_current_thread()
3464            .enable_all()
3465            .build()
3466            .expect("dispose lifecycle test runtime")
3467            .block_on(future)
3468    }
3469
3470    fn test_sidecar() -> NativeSidecar<LocalBridge> {
3471        NativeSidecar::new(LocalBridge::default()).expect("build test sidecar")
3472    }
3473
3474    // Register a connection + session directly so the dispose paths can be
3475    // exercised without spinning up a V8-backed VM.
3476    fn insert_session(
3477        sidecar: &mut NativeSidecar<LocalBridge>,
3478        connection_id: &str,
3479        session_id: &str,
3480        vm_ids: BTreeSet<String>,
3481    ) {
3482        sidecar.connections.insert(
3483            connection_id.to_string(),
3484            ConnectionState {
3485                auth_token: String::new(),
3486                sessions: BTreeSet::from([session_id.to_string()]),
3487            },
3488        );
3489        sidecar.sessions.insert(
3490            session_id.to_string(),
3491            SessionState {
3492                connection_id: connection_id.to_string(),
3493                placement: crate::protocol::SidecarPlacement::SidecarPlacementShared(
3494                    crate::protocol::SidecarPlacementShared { pool: None },
3495                ),
3496                metadata: BTreeMap::new(),
3497                vm_ids,
3498            },
3499        );
3500    }
3501
3502    struct RecordingExtension {
3503        namespace: String,
3504        session_disposed: Arc<AtomicUsize>,
3505    }
3506
3507    impl Extension for RecordingExtension {
3508        fn namespace(&self) -> &str {
3509            &self.namespace
3510        }
3511
3512        fn handle_request<'a>(
3513            &'a self,
3514            _ctx: ExtensionContext<'a>,
3515            _payload: Vec<u8>,
3516        ) -> ExtensionFuture<'a, ExtensionResponse> {
3517            Box::pin(async { Ok(ExtensionResponse::new(Vec::new())) })
3518        }
3519
3520        fn on_session_disposed<'a>(&'a self, _ctx: ExtensionSnapshot) -> ExtensionFuture<'a, ()> {
3521            let counter = self.session_disposed.clone();
3522            Box::pin(async move {
3523                counter.fetch_add(1, Ordering::SeqCst);
3524                Ok(())
3525            })
3526        }
3527    }
3528
3529    fn register_recording_extension(sidecar: &mut NativeSidecar<LocalBridge>) -> Arc<AtomicUsize> {
3530        let counter = Arc::new(AtomicUsize::new(0));
3531        sidecar
3532            .register_extension(Box::new(RecordingExtension {
3533                namespace: String::from("dev.test.dispose"),
3534                session_disposed: counter.clone(),
3535            }))
3536            .expect("register recording extension");
3537        counter
3538    }
3539
3540    // H4: the extension per-session teardown hook fires on ConnectionClosed so an
3541    // ACP-style extension can release per-session state on client disconnect.
3542    #[test]
3543    fn connection_closed_dispose_invokes_extension_session_teardown() {
3544        let mut sidecar = test_sidecar();
3545        let counter = register_recording_extension(&mut sidecar);
3546        insert_session(&mut sidecar, "conn-1", "session-1", BTreeSet::new());
3547
3548        block_on(sidecar.dispose_session("conn-1", "session-1", DisposeReason::ConnectionClosed))
3549            .expect("dispose session on connection close");
3550
3551        assert_eq!(
3552            counter.load(Ordering::SeqCst),
3553            1,
3554            "extension session-teardown hook must fire on ConnectionClosed"
3555        );
3556        assert!(
3557            !sidecar.sessions.contains_key("session-1"),
3558            "the disposed session must be reclaimed"
3559        );
3560    }
3561
3562    // H4 (negative): a client-requested dispose is not a disconnect, so the
3563    // teardown hook must not fire.
3564    #[test]
3565    fn requested_dispose_does_not_invoke_extension_session_teardown() {
3566        let mut sidecar = test_sidecar();
3567        let counter = register_recording_extension(&mut sidecar);
3568        insert_session(&mut sidecar, "conn-1", "session-1", BTreeSet::new());
3569
3570        block_on(sidecar.dispose_session("conn-1", "session-1", DisposeReason::Requested))
3571            .expect("dispose session on request");
3572
3573        assert_eq!(
3574            counter.load(Ordering::SeqCst),
3575            0,
3576            "the teardown hook is reserved for client disconnect"
3577        );
3578    }
3579
3580    // M5: disposing a session records its scope for the stdio transport to drain.
3581    #[test]
3582    fn dispose_session_records_disposed_scope() {
3583        let mut sidecar = test_sidecar();
3584        insert_session(&mut sidecar, "conn-1", "session-1", BTreeSet::new());
3585
3586        block_on(sidecar.dispose_session("conn-1", "session-1", DisposeReason::Requested))
3587            .expect("dispose session");
3588
3589        assert_eq!(
3590            sidecar.take_disposed_sessions(),
3591            vec![(String::from("conn-1"), String::from("session-1"))],
3592            "dispose must publish the session scope so stdio can untrack it"
3593        );
3594    }
3595
3596    // H1 + M6: every per-VM tracking map is reclaimed for a disposed VM. The
3597    // output-buffer map (M6) was previously only removed on a successful handoff,
3598    // and the engine/extension maps (H1) were only reclaimed after the fallible
3599    // teardown steps' `?`, so any failure stranded them.
3600    #[test]
3601    fn reclaim_vm_tracking_clears_every_per_vm_map() {
3602        let mut sidecar = test_sidecar();
3603        insert_session(
3604            &mut sidecar,
3605            "conn-1",
3606            "session-1",
3607            BTreeSet::from([String::from("vm-1")]),
3608        );
3609        sidecar.extension_process_output_buffers.insert(
3610            (String::from("vm-1"), String::from("proc-1")),
3611            ExtensionBufferedProcessOutput::default(),
3612        );
3613        sidecar.extension_sessions.insert(
3614            (String::from("ns"), String::from("ext-sess-1")),
3615            ExtensionSessionResources {
3616                ownership: OwnershipScope::vm("conn-1", "session-1", "vm-1"),
3617                process_ids: BTreeSet::new(),
3618                vm_ids: BTreeSet::from([String::from("vm-1")]),
3619            },
3620        );
3621
3622        sidecar.reclaim_vm_tracking("session-1", "vm-1");
3623
3624        assert!(
3625            sidecar.extension_process_output_buffers.is_empty(),
3626            "M6: the output-buffer map must be reclaimed on VM disposal"
3627        );
3628        assert!(
3629            sidecar.extension_sessions.is_empty(),
3630            "H1: an extension session bound only to the VM must be reclaimed"
3631        );
3632        assert!(
3633            !sidecar
3634                .sessions
3635                .get("session-1")
3636                .expect("session present")
3637                .vm_ids
3638                .contains("vm-1"),
3639            "the VM id must be removed from its session"
3640        );
3641    }
3642
3643    // H1: a failing VM dispose inside the loop must not abandon the session. With
3644    // unregistered VM ids, `dispose_vm_internal` fails on `require_owned_vm`;
3645    // pre-fix the loop `?`-ed out and left the session in `self.sessions`.
3646    #[test]
3647    fn dispose_session_reclaims_session_even_when_a_vm_dispose_fails() {
3648        let mut sidecar = test_sidecar();
3649        insert_session(
3650            &mut sidecar,
3651            "conn-1",
3652            "session-1",
3653            BTreeSet::from([String::from("vm-a"), String::from("vm-b")]),
3654        );
3655
3656        let result =
3657            block_on(sidecar.dispose_session("conn-1", "session-1", DisposeReason::Requested));
3658
3659        assert!(
3660            result.is_err(),
3661            "a failing VM dispose must still surface an error"
3662        );
3663        assert!(
3664            !sidecar.sessions.contains_key("session-1"),
3665            "the session must be reclaimed even though VM dispose failed"
3666        );
3667    }
3668}