Skip to main content

agentos_native_sidecar/
stdio.rs

1use crate::wire::{
2    self, AuthenticatedResponse, ExtEnvelope, OwnershipScope, ProtocolCodecError, ProtocolFrame,
3    RequestFrame, RequestId, RequestPayload, ResponseFrame, ResponsePayload, SessionOpenedResponse,
4    SidecarResponseFrame, WireDispatchResult, WireFrameCodec,
5};
6use crate::{
7    EventSinkTransport, Extension, ExtensionInterruptRequest, NativeSidecar, NativeSidecarConfig,
8    SidecarError, SidecarRequestTransport,
9};
10use agentos_bridge::queue_tracker::{tracked_sync_channel, TrackedLimit, TrackedSyncSender};
11use agentos_bridge::{
12    BridgeTypes, ChmodRequest, ClockBridge, ClockRequest, CommandPermissionRequest,
13    CreateDirRequest, CreateJavascriptContextRequest, CreateWasmContextRequest, DiagnosticRecord,
14    DirectoryEntry, EnvironmentPermissionRequest, EventBridge, ExecutionBridge, ExecutionEvent,
15    ExecutionHandleRequest, FileMetadata, FilesystemBridge, FilesystemPermissionRequest,
16    FilesystemSnapshot, FlushFilesystemStateRequest, GuestContextHandle, KillExecutionRequest,
17    LifecycleEventRecord, LoadFilesystemStateRequest, LogRecord, NetworkPermissionRequest,
18    PathRequest, PermissionBridge, PermissionDecision, PersistenceBridge,
19    PollExecutionEventRequest, RandomBridge, RandomBytesRequest, ReadDirRequest, ReadFileRequest,
20    RenameRequest, ScheduleTimerRequest, ScheduledTimer, StartExecutionRequest, StartedExecution,
21    StructuredEventRecord, SymlinkRequest, TruncateRequest, WriteExecutionStdinRequest,
22    WriteFileRequest,
23};
24use agentos_native_sidecar_core::{
25    generated_wire_blocking_extension_interrupt, BlockingExtensionInterrupt,
26};
27use std::collections::{BTreeMap, BTreeSet};
28use std::error::Error;
29use std::fmt;
30use std::fs::{self, OpenOptions};
31use std::io::{self, Read, Write};
32use std::os::unix::fs::{symlink as create_symlink, MetadataExt, PermissionsExt};
33use std::path::{Path, PathBuf};
34use std::sync::{mpsc, Arc, Mutex};
35use std::thread;
36use std::time::{Duration, Instant, SystemTime};
37use tokio::sync::{
38    mpsc::{channel, unbounded_channel, Receiver},
39    Notify,
40};
41use tokio::time;
42
43// Guest sync fs/module RPCs are serviced by `pump_process_events` on this timer,
44// so a blocked guest call waits up to one interval before the host even sees it.
45// At 5ms this dominated per-call latency (~5ms/stat); 250us cuts it ~11x (stat
46// 7.5s -> ~0.65s over 1500 ops) and the sub-ms tokio timer is honored. Idle
47// pumps are cheap no-ops (try_recv + zero-timeout poll), so the higher cadence
48// costs negligible CPU when no guest is issuing RPCs.
49const EVENT_PUMP_INTERVAL: Duration = Duration::from_micros(250);
50const MAX_STDIN_FRAME_QUEUE: usize = 128;
51const MAX_EVENT_READY_QUEUE: usize = 1;
52// Defense-in-depth headroom for the host-bound frame queue: a burst of output
53// frames from a busy turn should be buffered, so the writer only backpressures
54// when the host genuinely stops reading stdout rather than on every spike.
55const MAX_STDOUT_FRAME_QUEUE: usize = 4096;
56
57#[cfg(test)]
58fn request_frame(
59    request_id: RequestId,
60    ownership: OwnershipScope,
61    payload: RequestPayload,
62) -> RequestFrame {
63    RequestFrame {
64        schema: wire::protocol_schema(),
65        request_id,
66        ownership,
67        payload,
68    }
69}
70
71fn response_frame(
72    request_id: RequestId,
73    ownership: OwnershipScope,
74    payload: ResponsePayload,
75) -> ResponseFrame {
76    ResponseFrame {
77        schema: wire::protocol_schema(),
78        request_id,
79        ownership,
80        payload,
81    }
82}
83
84#[cfg(test)]
85fn connection_ownership(connection_id: &str) -> OwnershipScope {
86    OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
87        connection_id: connection_id.to_owned(),
88    })
89}
90
91fn session_ownership(connection_id: &str, session_id: &str) -> OwnershipScope {
92    OwnershipScope::SessionOwnership(wire::SessionOwnership {
93        connection_id: connection_id.to_owned(),
94        session_id: session_id.to_owned(),
95    })
96}
97
98#[cfg(test)]
99fn vm_ownership(connection_id: &str, session_id: &str, vm_id: &str) -> OwnershipScope {
100    OwnershipScope::VmOwnership(wire::VmOwnership {
101        connection_id: connection_id.to_owned(),
102        session_id: session_id.to_owned(),
103        vm_id: vm_id.to_owned(),
104    })
105}
106
107fn wire_protocol_error(error: ProtocolCodecError) -> SidecarError {
108    SidecarError::InvalidState(format!("invalid generated wire protocol frame: {error}"))
109}
110
111pub fn run() -> Result<(), Box<dyn Error>> {
112    run_with_extensions(Vec::new())
113}
114
115pub fn run_with_extensions(extensions: Vec<Box<dyn Extension>>) -> Result<(), Box<dyn Error>> {
116    // Initialize the embedded V8 runtime + platform now, on the long-lived main
117    // thread, so it is never first-initialized on a transient worker thread (e.g. a
118    // VM-create snapshot pre-warm thread that then exits — which corrupts V8's
119    // platform and wedges later isolate creation). Best-effort.
120    if let Err(error) = agentos_execution::v8_host::ensure_runtime_initialized() {
121        eprintln!("embedded V8 runtime init failed at startup: {error}");
122    }
123    tokio::runtime::Builder::new_current_thread()
124        .enable_all()
125        .build()?
126        .block_on(run_async(extensions))
127}
128
129async fn run_async(extensions: Vec<Box<dyn Extension>>) -> Result<(), Box<dyn Error>> {
130    let config = NativeSidecarConfig {
131        compile_cache_root: Some(default_compile_cache_root()),
132        ..NativeSidecarConfig::default()
133    };
134    let codec = WireFrameCodec::new(config.max_frame_bytes);
135    let mut sidecar =
136        NativeSidecar::with_config_and_extensions(LocalBridge::default(), config, extensions)?;
137    let mut active_sessions = BTreeSet::<SessionScope>::new();
138    let mut active_connections = BTreeSet::<String>::new();
139    let (stdin_tx, mut stdin_rx) =
140        channel::<Result<Option<ProtocolFrame>, String>>(MAX_STDIN_FRAME_QUEUE);
141    let stdin_gauge = agentos_bridge::queue_tracker::register_queue(
142        TrackedLimit::SidecarStdinFrames,
143        MAX_STDIN_FRAME_QUEUE,
144    );
145    let (event_ready_tx, mut event_ready_rx) = channel::<()>(MAX_EVENT_READY_QUEUE);
146    let (write_tx, write_rx) = tracked_sync_channel::<ProtocolFrame>(
147        TrackedLimit::SidecarStdoutFrames,
148        MAX_STDOUT_FRAME_QUEUE,
149    );
150    let (write_error_tx, mut write_error_rx) = unbounded_channel::<String>();
151
152    // Forward limit-registry near-capacity warnings to the host: the global sink
153    // fires (edge-triggered, from arbitrary threads) into this channel, and the
154    // event loop below drains it and emits a `StructuredEvent` (name
155    // "limit_warning"). The unbounded sender is Send+Sync and lives for the whole
156    // process inside the global handler, so the receiver never sees a hangup.
157    let (limit_warning_tx, mut limit_warning_rx) =
158        unbounded_channel::<agentos_bridge::queue_tracker::LimitWarning>();
159    agentos_bridge::queue_tracker::set_limit_warning_handler(Box::new(move |warning| {
160        let _ = limit_warning_tx.send(warning.clone());
161    }));
162    let callback_transport = Arc::new(FrameSidecarRequestTransport::new(write_tx.clone()));
163    sidecar.set_sidecar_request_transport(callback_transport.clone());
164    // Live event sink: lets an extension stream `session/update` (and other)
165    // events to stdout mid-dispatch instead of batching them until the request
166    // resolves. Shares the same outbound `write_tx` channel as the batch path, so
167    // ordering and backpressure are identical.
168    let event_transport = Arc::new(FrameEventTransport::new(write_tx.clone()));
169    sidecar.set_event_transport(event_transport);
170    let mut event_pump = time::interval(EVENT_PUMP_INTERVAL);
171    let process_event_notify = Arc::new(Notify::new());
172    sidecar
173        .javascript_engine
174        .set_event_notify(Some(process_event_notify.clone()));
175    let writer_codec = codec.clone();
176    let reader_codec = codec.clone();
177    let writer_error_tx = write_error_tx.clone();
178    thread::spawn(move || {
179        let mut writer = io::BufWriter::new(io::stdout());
180        while let Ok(frame) = write_rx.recv() {
181            if let Err(error) = write_frame(&writer_codec, &mut writer, &frame) {
182                let _ = writer_error_tx.send(error.to_string());
183                break;
184            }
185        }
186    });
187
188    thread::spawn({
189        let callback_transport = callback_transport.clone();
190        let read_error_tx = write_error_tx.clone();
191        move || {
192            let mut stdin = io::stdin();
193            loop {
194                let frame = match read_frame(&reader_codec, &mut stdin) {
195                    Ok(Some(ProtocolFrame::SidecarResponseFrame(response))) => {
196                        if callback_transport.accept_response(response.clone()) {
197                            continue;
198                        }
199                        Ok(Some(ProtocolFrame::SidecarResponseFrame(response)))
200                    }
201                    Ok(Some(frame)) => Ok(Some(frame)),
202                    other => other,
203                }
204                .map_err(|error: Box<dyn Error>| error.to_string());
205                let should_stop = matches!(frame, Ok(None) | Err(_));
206                match enqueue_stdin_frame(&stdin_tx, frame) {
207                    Ok(()) => {
208                        // Sample inbound queue depth so the centralized tracker
209                        // can warn before host requests back up on the sidecar.
210                        stdin_gauge.observe_depth(
211                            stdin_tx.max_capacity().saturating_sub(stdin_tx.capacity()),
212                        );
213                    }
214                    Err(StdinFrameQueueError::Full(message)) => {
215                        let _ = read_error_tx.send(message);
216                        break;
217                    }
218                    Err(StdinFrameQueueError::Closed) => break,
219                }
220                if should_stop {
221                    break;
222                }
223            }
224        }
225    });
226
227    flush_sidecar_requests(&mut sidecar, &write_tx)?;
228    let mut pending_frame: Option<ProtocolFrame> = None;
229    let mut limit_warning_closed = false;
230
231    loop {
232        if let Some(frame) = pending_frame.take() {
233            handle_protocol_frame(
234                frame,
235                &mut sidecar,
236                &mut stdin_rx,
237                &mut pending_frame,
238                &write_tx,
239                &mut active_sessions,
240                &mut active_connections,
241            )
242            .await?;
243            continue;
244        }
245
246        tokio::select! {
247            maybe_frame = stdin_rx.recv() => {
248                let Some(frame) = maybe_frame else {
249                    break;
250                };
251                let Some(frame) = frame.map_err(io::Error::other)? else {
252                    break;
253                };
254                handle_protocol_frame(
255                    frame,
256                    &mut sidecar,
257                    &mut stdin_rx,
258                    &mut pending_frame,
259                    &write_tx,
260                    &mut active_sessions,
261                    &mut active_connections,
262                ).await?;
263            }
264            maybe_warning = limit_warning_rx.recv(), if !limit_warning_closed => {
265                match maybe_warning {
266                    Some(warning) => {
267                        // A limit warning is process-global; deliver it ONCE. The
268                        // stdio transport is single-client, so emit it to the first
269                        // active connection (if any) rather than fanning out a copy
270                        // per connection. Dropped if no client has authenticated yet
271                        // (only the tracing log survives, which is acceptable).
272                        if let Some(connection_id) = active_connections.iter().next() {
273                            let mut detail = std::collections::HashMap::new();
274                            detail.insert(String::from("limit"), warning.name.as_str().to_string());
275                            detail.insert(
276                                String::from("category"),
277                                warning.category.as_str().to_string(),
278                            );
279                            detail.insert(String::from("observed"), warning.observed.to_string());
280                            detail.insert(String::from("capacity"), warning.capacity.to_string());
281                            detail.insert(
282                                String::from("fillPercent"),
283                                warning.fill_percent.to_string(),
284                            );
285                            let frame = crate::service::structured_event_frame(
286                                connection_id,
287                                "limit_warning",
288                                detail,
289                            )?;
290                            send_output_frame(&write_tx, ProtocolFrame::EventFrame(frame))?;
291                        }
292                    }
293                    None => {
294                        // Sender dropped (only possible if another sidecar replaced
295                        // the global handler in-process). Disarm this branch so the
296                        // select! does not hot-spin on an always-ready closed
297                        // receiver; do NOT break — that would tear down the sidecar.
298                        limit_warning_closed = true;
299                    }
300                }
301            }
302            maybe_ready = event_ready_rx.recv() => {
303                let Some(()) = maybe_ready else {
304                    break;
305                };
306                loop {
307                    let mut emitted_frame = false;
308                    for session in active_sessions.iter().cloned().collect::<Vec<_>>() {
309                        if let Some(frame) = sidecar
310                            .poll_event_wire(&session.ownership_scope(), Duration::ZERO)
311                            .await?
312                        {
313                            send_output_frame(&write_tx, ProtocolFrame::EventFrame(frame))?;
314                            emitted_frame = true;
315                        }
316                    }
317
318                    if !emitted_frame {
319                        break;
320                    }
321                }
322                flush_sidecar_requests(&mut sidecar, &write_tx)?;
323            }
324            _ = process_event_notify.notified() => {
325                for session in active_sessions.iter().cloned().collect::<Vec<_>>() {
326                    if sidecar.pump_process_events(&session.compat_ownership_scope()).await? {
327                        let _ = event_ready_tx.try_send(());
328                    }
329                }
330                flush_sidecar_requests(&mut sidecar, &write_tx)?;
331            }
332            _ = event_pump.tick() => {
333                for session in active_sessions.iter().cloned().collect::<Vec<_>>() {
334                    if sidecar.pump_process_events(&session.compat_ownership_scope()).await? {
335                        let _ = event_ready_tx.try_send(());
336                    }
337                }
338                flush_sidecar_requests(&mut sidecar, &write_tx)?;
339            }
340            maybe_write_error = write_error_rx.recv() => {
341                if let Some(error) = maybe_write_error {
342                    return Err(io::Error::new(io::ErrorKind::BrokenPipe, error).into());
343                }
344            }
345        }
346    }
347
348    cleanup_connections(&mut sidecar, &active_connections, &mut active_sessions).await;
349    Ok(())
350}
351
352async fn handle_protocol_frame(
353    frame: ProtocolFrame,
354    sidecar: &mut NativeSidecar<LocalBridge>,
355    stdin_rx: &mut Receiver<Result<Option<ProtocolFrame>, String>>,
356    pending_frame: &mut Option<ProtocolFrame>,
357    write_tx: &TrackedSyncSender<ProtocolFrame>,
358    active_sessions: &mut BTreeSet<SessionScope>,
359    active_connections: &mut BTreeSet<String>,
360) -> Result<(), Box<dyn Error>> {
361    match frame {
362        ProtocolFrame::RequestFrame(request) => {
363            let (dispatch, extra_responses) =
364                dispatch_with_prompt_interrupt(sidecar, request.clone(), stdin_rx, pending_frame)
365                    .await?;
366            track_session_state(
367                &dispatch.response.payload,
368                active_sessions,
369                active_connections,
370            );
371
372            send_output_frame(write_tx, ProtocolFrame::ResponseFrame(dispatch.response))?;
373            for response in extra_responses {
374                send_output_frame(write_tx, ProtocolFrame::ResponseFrame(response))?;
375            }
376            for event in dispatch.events {
377                send_output_frame(write_tx, ProtocolFrame::EventFrame(event))?;
378            }
379            flush_sidecar_requests(sidecar, write_tx)?;
380        }
381        ProtocolFrame::SidecarResponseFrame(response) => {
382            sidecar.accept_wire_sidecar_response(response)?;
383            flush_sidecar_requests(sidecar, write_tx)?;
384        }
385        other => {
386            return Err(format!(
387                "expected request or sidecar_response frame on stdin, received {}",
388                frame_kind(&other)
389            )
390            .into());
391        }
392    }
393    // Drop any sessions the sidecar disposed while handling this frame from the
394    // active-session set so the event pump stops iterating dead sessions (M5).
395    untrack_disposed_sessions(&sidecar.take_disposed_sessions(), active_sessions);
396    Ok(())
397}
398
399/// Remove every disposed session scope from the stdio transport's active-session
400/// set. Without this the set is insert-only (`track_session_state` adds on
401/// `SessionOpenedResponse` but nothing ever removed), so it grew per session for
402/// the process lifetime and the ~250us event pump iterated every dead entry (M5).
403fn untrack_disposed_sessions(
404    disposed: &[(String, String)],
405    active_sessions: &mut BTreeSet<SessionScope>,
406) {
407    for (connection_id, session_id) in disposed {
408        active_sessions.remove(&SessionScope {
409            connection_id: connection_id.clone(),
410            session_id: session_id.clone(),
411        });
412    }
413}
414
415async fn dispatch_with_prompt_interrupt(
416    sidecar: &mut NativeSidecar<LocalBridge>,
417    request: RequestFrame,
418    stdin_rx: &mut Receiver<Result<Option<ProtocolFrame>, String>>,
419    pending_frame: &mut Option<ProtocolFrame>,
420) -> Result<(WireDispatchResult, Vec<ResponseFrame>), Box<dyn Error>> {
421    let Some(blocking_request) = blocking_extension_request(sidecar, &request) else {
422        return Ok((sidecar.dispatch_wire(request).await?, Vec::new()));
423    };
424
425    let mut dispatch = Box::pin(sidecar.dispatch_wire(request.clone()));
426    tokio::select! {
427        result = dispatch.as_mut() => Ok((result?, Vec::new())),
428        maybe_frame = stdin_rx.recv() => {
429            let frame = decode_stdin_frame(maybe_frame)?;
430            if let Some(frame) = frame {
431                if let Some(interrupt) = extension_interrupt_response(&blocking_request, &request, &frame) {
432                    drop(dispatch);
433                    let mut extra_responses = Vec::new();
434                    if let Some(response) = interrupt.interrupting_response {
435                        extra_responses.push(response);
436                    } else {
437                        *pending_frame = Some(frame);
438                    }
439                    return Ok((interrupt.interrupted_dispatch, extra_responses));
440                }
441                *pending_frame = Some(frame);
442            }
443            Ok((dispatch.await?, Vec::new()))
444        }
445    }
446}
447
448fn decode_stdin_frame(
449    maybe_frame: Option<Result<Option<ProtocolFrame>, String>>,
450) -> Result<Option<ProtocolFrame>, Box<dyn Error>> {
451    let Some(frame) = maybe_frame else {
452        return Ok(None);
453    };
454    Ok(frame.map_err(io::Error::other)?)
455}
456
457struct BlockingExtensionRequest {
458    namespace: String,
459    payload: Vec<u8>,
460    extension: Arc<dyn Extension>,
461}
462
463struct ExtensionInterruptDispatch {
464    interrupted_dispatch: WireDispatchResult,
465    interrupting_response: Option<ResponseFrame>,
466}
467
468fn blocking_extension_request(
469    sidecar: &NativeSidecar<LocalBridge>,
470    request: &RequestFrame,
471) -> Option<BlockingExtensionRequest> {
472    let RequestPayload::ExtEnvelope(envelope) = &request.payload else {
473        return None;
474    };
475    let extension = sidecar.extensions.get(&envelope.namespace)?.clone();
476    if !extension.is_blocking_request(&envelope.payload) {
477        return None;
478    }
479    Some(BlockingExtensionRequest {
480        namespace: envelope.namespace.clone(),
481        payload: envelope.payload.clone(),
482        extension,
483    })
484}
485
486fn extension_interrupt_response(
487    blocking_request: &BlockingExtensionRequest,
488    active_request: &RequestFrame,
489    frame: &ProtocolFrame,
490) -> Option<ExtensionInterruptDispatch> {
491    match frame {
492        ProtocolFrame::RequestFrame(request) => {
493            let interrupt = generated_wire_blocking_extension_interrupt(
494                active_request,
495                &blocking_request.namespace,
496                request,
497            )?;
498            let interrupt = blocking_request.extension.interrupt_blocking_request(
499                &blocking_request.payload,
500                match interrupt {
501                    BlockingExtensionInterrupt::ExtensionPayload(payload) => {
502                        ExtensionInterruptRequest::ExtensionPayload(payload)
503                    }
504                    BlockingExtensionInterrupt::KillProcess => {
505                        ExtensionInterruptRequest::KillProcess
506                    }
507                },
508            )?;
509            let interrupted_dispatch = interrupted_extension_dispatch(
510                active_request,
511                &blocking_request.namespace,
512                interrupt.interrupted_response_payload,
513            );
514            let interrupting_response = interrupt.interrupting_response_payload.map(|payload| {
515                response_frame(
516                    request.request_id,
517                    request.ownership.clone(),
518                    ResponsePayload::ExtEnvelope(ExtEnvelope {
519                        namespace: blocking_request.namespace.clone(),
520                        payload,
521                    }),
522                )
523            });
524            Some(ExtensionInterruptDispatch {
525                interrupted_dispatch,
526                interrupting_response,
527            })
528        }
529        // Response, Event, and SidecarRequest frames are sidecar-to-host only. If one
530        // arrives on stdin it is requeued and rejected as a protocol error by
531        // handle_protocol_frame, so it must not synthesize a cancelled prompt first.
532        // SidecarResponse frames answer sidecar-initiated callbacks and may be the very
533        // response the blocked prompt dispatch is waiting on, so they never interrupt.
534        ProtocolFrame::ResponseFrame(_)
535        | ProtocolFrame::EventFrame(_)
536        | ProtocolFrame::SidecarRequestFrame(_)
537        | ProtocolFrame::SidecarResponseFrame(_) => None,
538    }
539}
540
541fn interrupted_extension_dispatch(
542    request: &RequestFrame,
543    namespace: &str,
544    payload: Vec<u8>,
545) -> WireDispatchResult {
546    if !matches!(request.payload, RequestPayload::ExtEnvelope(_)) {
547        unreachable!("interrupted extension dispatch requires an extension request");
548    }
549
550    let response = ResponsePayload::ExtEnvelope(ExtEnvelope {
551        namespace: namespace.to_string(),
552        payload,
553    });
554    WireDispatchResult {
555        response: response_frame(request.request_id, request.ownership.clone(), response),
556        events: Vec::new(),
557    }
558}
559
560async fn cleanup_connections(
561    sidecar: &mut NativeSidecar<LocalBridge>,
562    active_connections: &BTreeSet<String>,
563    active_sessions: &mut BTreeSet<SessionScope>,
564) {
565    for connection_id in active_connections {
566        let _ = sidecar.remove_connection(connection_id).await;
567    }
568    untrack_disposed_sessions(&sidecar.take_disposed_sessions(), active_sessions);
569}
570
571fn track_session_state(
572    payload: &ResponsePayload,
573    active_sessions: &mut BTreeSet<SessionScope>,
574    active_connections: &mut BTreeSet<String>,
575) {
576    match payload {
577        ResponsePayload::AuthenticatedResponse(AuthenticatedResponse { connection_id, .. }) => {
578            active_connections.insert(connection_id.clone());
579        }
580        ResponsePayload::SessionOpenedResponse(SessionOpenedResponse {
581            session_id,
582            owner_connection_id,
583        }) => {
584            active_sessions.insert(SessionScope {
585                connection_id: owner_connection_id.clone(),
586                session_id: session_id.clone(),
587            });
588        }
589        _ => {}
590    }
591}
592
593fn read_frame(
594    codec: &WireFrameCodec,
595    reader: &mut impl Read,
596) -> Result<Option<ProtocolFrame>, Box<dyn Error>> {
597    let mut prefix = [0u8; 4];
598    match reader.read_exact(&mut prefix) {
599        Ok(()) => {}
600        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => {
601            return Ok(None);
602        }
603        Err(error) => return Err(error.into()),
604    }
605
606    let declared_len = u32::from_be_bytes(prefix) as usize;
607    if declared_len > codec.max_frame_bytes() {
608        return Err(ProtocolCodecError::FrameTooLarge {
609            size: declared_len,
610            max: codec.max_frame_bytes(),
611        }
612        .into());
613    }
614    let total_len = prefix.len().saturating_add(declared_len);
615    let mut bytes = Vec::with_capacity(total_len);
616    bytes.extend_from_slice(&prefix);
617    bytes.resize(total_len, 0);
618    reader.read_exact(&mut bytes[prefix.len()..])?;
619
620    Ok(Some(codec.decode(&bytes)?))
621}
622
623fn write_frame(
624    codec: &WireFrameCodec,
625    writer: &mut impl Write,
626    frame: &ProtocolFrame,
627) -> Result<(), Box<dyn Error>> {
628    let bytes = codec.encode(frame)?;
629    writer.write_all(&bytes)?;
630    writer.flush()?;
631    Ok(())
632}
633
634fn frame_kind(frame: &ProtocolFrame) -> &'static str {
635    match frame {
636        ProtocolFrame::RequestFrame(_) => "request",
637        ProtocolFrame::ResponseFrame(_) => "response",
638        ProtocolFrame::EventFrame(_) => "event",
639        ProtocolFrame::SidecarRequestFrame(_) => "sidecar_request",
640        ProtocolFrame::SidecarResponseFrame(_) => "sidecar_response",
641    }
642}
643
644#[derive(Debug, Clone, PartialEq, Eq)]
645enum StdinFrameQueueError {
646    Full(String),
647    Closed,
648}
649
650fn enqueue_stdin_frame(
651    sender: &tokio::sync::mpsc::Sender<Result<Option<ProtocolFrame>, String>>,
652    frame: Result<Option<ProtocolFrame>, String>,
653) -> Result<(), StdinFrameQueueError> {
654    sender.try_send(frame).map_err(|error| match error {
655        tokio::sync::mpsc::error::TrySendError::Full(_) => StdinFrameQueueError::Full(format!(
656            "stdin frame queue exceeded {MAX_STDIN_FRAME_QUEUE} pending frames"
657        )),
658        tokio::sync::mpsc::error::TrySendError::Closed(_) => StdinFrameQueueError::Closed,
659    })
660}
661
662fn flush_sidecar_requests(
663    sidecar: &mut NativeSidecar<LocalBridge>,
664    writer: &TrackedSyncSender<ProtocolFrame>,
665) -> Result<(), Box<dyn Error>> {
666    while let Some(request) = sidecar.pop_wire_sidecar_request()? {
667        send_output_frame(writer, ProtocolFrame::SidecarRequestFrame(request))?;
668    }
669    Ok(())
670}
671
672fn send_output_frame(
673    writer: &TrackedSyncSender<ProtocolFrame>,
674    frame: ProtocolFrame,
675) -> Result<(), io::Error> {
676    // Apply backpressure rather than killing the sidecar when the host reads
677    // stdout slowly. A full queue means the dedicated writer thread is blocked on
678    // the stdout pipe (the host has not drained it yet) — a transient, recoverable
679    // condition. Previously `try_send` turned that backlog into a `BrokenPipe`
680    // error that propagated up and exited the whole sidecar process (code 1),
681    // taking every session with it. A blocking `send` parks the producer until the
682    // writer drains a slot, which transitively backpressures the V8 event bridge
683    // and the guest. It never deadlocks: the writer thread runs independently, and
684    // if it dies (real broken pipe) the receiver is dropped and `send` returns
685    // `Disconnected`, which we still surface as a terminal `BrokenPipe`.
686    writer.send(frame).map_err(|_disconnected| {
687        io::Error::new(io::ErrorKind::BrokenPipe, "stdout writer disconnected")
688    })
689}
690
691fn default_compile_cache_root() -> PathBuf {
692    // Stable across sidecar processes so V8 compile-cache (cachedData) survives a
693    // fresh sidecar/VM and benefits cold starts. Previously keyed by PID, which
694    // gave every process an empty cache — cold module imports never reused
695    // compiled bytecode. Entries are namespaced+validated downstream by
696    // `stable_compile_cache_namespace_hash` + V8's source/version checks, so a
697    // shared root is safe; stale or mismatched entries are simply ignored.
698    std::env::temp_dir().join("agentos-native-sidecar-compile-cache")
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use crate::wire::{AuthenticateRequest, KillProcessRequest};
705    use crate::{ExtensionContext, ExtensionFuture, ExtensionInterruptResponse, ExtensionResponse};
706    use std::io::Cursor;
707
708    const TEST_EXTENSION_NAMESPACE: &str = "dev.rivet.secure-exec.test.blocking";
709
710    #[test]
711    fn read_frame_rejects_oversized_prefix_before_allocating_payload() {
712        let codec = WireFrameCodec::new(16);
713        let mut reader = Cursor::new((32_u32).to_be_bytes().to_vec());
714
715        let error = read_frame(&codec, &mut reader).expect_err("oversized frame should fail");
716        let error = error
717            .downcast::<ProtocolCodecError>()
718            .expect("protocol codec error");
719        assert!(matches!(
720            *error,
721            ProtocolCodecError::FrameTooLarge { size: 32, max: 16 }
722        ));
723    }
724
725    #[test]
726    fn stdio_work_queues_are_bounded() {
727        let (stdin_tx, _stdin_rx) =
728            channel::<Result<Option<ProtocolFrame>, String>>(MAX_STDIN_FRAME_QUEUE);
729        for _ in 0..MAX_STDIN_FRAME_QUEUE {
730            enqueue_stdin_frame(&stdin_tx, Ok(None))
731                .expect("stdin frame queue should accept capacity");
732        }
733        assert!(matches!(
734            enqueue_stdin_frame(&stdin_tx, Ok(None)),
735            Err(StdinFrameQueueError::Full(_))
736        ));
737
738        let (event_ready_tx, _event_ready_rx) = channel::<()>(MAX_EVENT_READY_QUEUE);
739        event_ready_tx
740            .try_send(())
741            .expect("event-ready queue should accept capacity");
742        assert!(matches!(
743            event_ready_tx.try_send(()),
744            Err(tokio::sync::mpsc::error::TrySendError::Full(_))
745        ));
746    }
747
748    // Regression: a full stdout frame queue must apply backpressure (block the
749    // producer until the writer drains a slot), NOT tear the sidecar down. The
750    // old `try_send` turned a slow host reader into a `BrokenPipe` error that
751    // propagated up and exited the whole sidecar process (code 1). Here a slow
752    // drainer forces the queue past capacity; with backpressure every send
753    // succeeds, and overflow only fails when the writer (receiver) is gone.
754    #[test]
755    fn stdout_frame_queue_applies_backpressure_instead_of_crashing() {
756        let queue_frame = |request_id: RequestId| {
757            ProtocolFrame::RequestFrame(request_frame(
758                request_id,
759                connection_ownership("conn-queue"),
760                RequestPayload::AuthenticateRequest(AuthenticateRequest {
761                    client_name: String::from("queue-test"),
762                    auth_token: String::from("token"),
763                    protocol_version: wire::PROTOCOL_VERSION,
764                    bridge_version: agentos_bridge::bridge_contract().version,
765                }),
766            ))
767        };
768
769        // Small fixed capacity (independent of the production constant) with a
770        // drainer slow enough that the queue fills and the producer is forced
771        // onto the blocking path. The old try_send path errored on the
772        // (capacity + 1)th frame; backpressure accepts all of them.
773        let queue_cap = 8usize;
774        let total_frames = queue_cap * 3;
775        let (stdout_tx, stdout_rx) =
776            tracked_sync_channel::<ProtocolFrame>(TrackedLimit::SidecarStdoutFrames, queue_cap);
777        let drainer = std::thread::spawn(move || {
778            let mut drained = 0usize;
779            while stdout_rx.recv().is_ok() {
780                drained += 1;
781                std::thread::sleep(std::time::Duration::from_millis(1));
782            }
783            drained
784        });
785
786        for request_id in 0..total_frames {
787            send_output_frame(&stdout_tx, queue_frame(request_id as RequestId))
788                .expect("backpressured stdout queue must accept frames, not crash");
789        }
790        drop(stdout_tx);
791        let drained = drainer.join().expect("drainer thread panicked");
792        assert_eq!(
793            drained, total_frames,
794            "every frame must survive the backpressured queue"
795        );
796
797        // When the writer (receiver) is gone, overflow is genuinely terminal and
798        // still surfaces as a BrokenPipe error rather than blocking forever.
799        let (closed_tx, closed_rx) =
800            tracked_sync_channel::<ProtocolFrame>(TrackedLimit::SidecarStdoutFrames, queue_cap);
801        drop(closed_rx);
802        let error = send_output_frame(&closed_tx, queue_frame(0))
803            .expect_err("send to a dropped writer must error");
804        assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
805    }
806
807    // Regression (M5): the active-session set must shrink when a session is
808    // disposed. `track_session_state` is insert-only, so the transport relies on
809    // `untrack_disposed_sessions` draining the sidecar's disposed-session signal;
810    // without it a long-lived connection's set grows per session forever and the
811    // ~250us event pump iterates every dead entry.
812    #[test]
813    fn disposed_sessions_are_untracked_from_active_sessions() {
814        let mut active_sessions = BTreeSet::<SessionScope>::new();
815        let mut active_connections = BTreeSet::<String>::new();
816        track_session_state(
817            &ResponsePayload::SessionOpenedResponse(SessionOpenedResponse {
818                session_id: String::from("session-1"),
819                owner_connection_id: String::from("conn-1"),
820            }),
821            &mut active_sessions,
822            &mut active_connections,
823        );
824        assert_eq!(
825            active_sessions.len(),
826            1,
827            "opening a session should track it for the event pump"
828        );
829
830        untrack_disposed_sessions(
831            &[(String::from("conn-1"), String::from("session-1"))],
832            &mut active_sessions,
833        );
834        assert!(
835            active_sessions.is_empty(),
836            "a disposed session must be removed from the active-session set"
837        );
838    }
839
840    #[test]
841    fn read_frame_decodes_wire_authenticate_request() {
842        let codec = WireFrameCodec::new(wire::DEFAULT_MAX_FRAME_BYTES);
843        let frame = ProtocolFrame::RequestFrame(request_frame(
844            1,
845            connection_ownership("client-hint"),
846            RequestPayload::AuthenticateRequest(AuthenticateRequest {
847                client_name: "probe".to_string(),
848                auth_token: "probe-token".to_string(),
849                protocol_version: wire::PROTOCOL_VERSION,
850                bridge_version: agentos_bridge::bridge_contract().version,
851            }),
852        ));
853        let encoded = codec.encode(&frame).expect("encode wire frame");
854        let mut reader = Cursor::new(encoded);
855
856        let decoded = read_frame(&codec, &mut reader)
857            .expect("decode bare frame")
858            .expect("frame present");
859
860        assert_eq!(decoded, frame);
861    }
862
863    #[test]
864    fn extension_close_interrupts_matching_blocking_request() {
865        let ownership = vm_ownership("conn-1", "session-1", "vm-1");
866        let prompt = test_extension_request_frame(10, ownership.clone(), "prompt:ext-session-1");
867        let close = ProtocolFrame::RequestFrame(test_extension_request_frame(
868            11,
869            ownership,
870            "close:ext-session-1",
871        ));
872
873        let blocking_request = blocking_extension_request(&prompt);
874        let interrupt = extension_interrupt_response(&blocking_request, &prompt, &close)
875            .expect("close should interrupt prompt");
876
877        assert_eq!(interrupt.interrupted_dispatch.response.request_id, 10);
878        let ResponsePayload::ExtEnvelope(envelope) =
879            interrupt.interrupted_dispatch.response.payload
880        else {
881            panic!("expected extension response");
882        };
883        assert_eq!(envelope.namespace, TEST_EXTENSION_NAMESPACE);
884        assert_eq!(envelope.payload, b"prompt-cancelled:ext-session-1");
885    }
886
887    #[test]
888    fn extension_cancel_interrupt_gets_synthetic_response() {
889        let ownership = vm_ownership("conn-1", "session-1", "vm-1");
890        let prompt = test_extension_request_frame(10, ownership.clone(), "prompt:ext-session-1");
891        let cancel = ProtocolFrame::RequestFrame(test_extension_request_frame(
892            11,
893            ownership,
894            "cancel:ext-session-1",
895        ));
896
897        let blocking_request = blocking_extension_request(&prompt);
898        let interrupt = extension_interrupt_response(&blocking_request, &prompt, &cancel)
899            .expect("cancel should interrupt prompt");
900        let response = interrupt
901            .interrupting_response
902            .expect("cancel should get a response");
903
904        assert_eq!(response.request_id, 11);
905        let ResponsePayload::ExtEnvelope(envelope) = response.payload else {
906            panic!("expected extension response");
907        };
908        assert_eq!(envelope.namespace, TEST_EXTENSION_NAMESPACE);
909        assert_eq!(envelope.payload, b"cancelled:ext-session-1");
910    }
911
912    #[test]
913    fn kill_process_interrupts_blocking_extension_request() {
914        let ownership = vm_ownership("conn-1", "session-1", "vm-1");
915        let prompt = test_extension_request_frame(10, ownership.clone(), "prompt:ext-session-1");
916        let kill = ProtocolFrame::RequestFrame(request_frame(
917            11,
918            ownership,
919            RequestPayload::KillProcessRequest(KillProcessRequest {
920                process_id: "adapter-process".to_string(),
921                signal: "SIGTERM".to_string(),
922            }),
923        ));
924
925        let blocking_request = blocking_extension_request(&prompt);
926        let interrupt = extension_interrupt_response(&blocking_request, &prompt, &kill)
927            .expect("kill should interrupt prompt");
928
929        assert_eq!(interrupt.interrupted_dispatch.response.request_id, 10);
930        assert!(interrupt.interrupting_response.is_none());
931    }
932
933    fn test_extension_request_frame(
934        request_id: RequestId,
935        ownership: OwnershipScope,
936        payload: &str,
937    ) -> RequestFrame {
938        request_frame(
939            request_id,
940            ownership,
941            RequestPayload::ExtEnvelope(ExtEnvelope {
942                namespace: TEST_EXTENSION_NAMESPACE.to_string(),
943                payload: payload.as_bytes().to_vec(),
944            }),
945        )
946    }
947
948    fn blocking_extension_request(request: &RequestFrame) -> BlockingExtensionRequest {
949        let RequestPayload::ExtEnvelope(envelope) = &request.payload else {
950            panic!("expected extension request");
951        };
952        BlockingExtensionRequest {
953            namespace: TEST_EXTENSION_NAMESPACE.to_string(),
954            payload: envelope.payload.clone(),
955            extension: Arc::new(TestBlockingInterruptExtension),
956        }
957    }
958
959    struct TestBlockingInterruptExtension;
960
961    impl Extension for TestBlockingInterruptExtension {
962        fn namespace(&self) -> &str {
963            TEST_EXTENSION_NAMESPACE
964        }
965
966        fn handle_request<'a>(
967            &'a self,
968            _ctx: ExtensionContext<'a>,
969            _payload: Vec<u8>,
970        ) -> ExtensionFuture<'a, ExtensionResponse> {
971            Box::pin(async { Ok(ExtensionResponse::new(Vec::new())) })
972        }
973
974        fn is_blocking_request(&self, payload: &[u8]) -> bool {
975            parse_test_payload(payload).is_some_and(|(kind, _session_id)| kind == "prompt")
976        }
977
978        fn interrupt_blocking_request(
979            &self,
980            blocking_payload: &[u8],
981            interrupt: ExtensionInterruptRequest<'_>,
982        ) -> Option<ExtensionInterruptResponse> {
983            let (blocking_kind, blocking_session_id) = parse_test_payload(blocking_payload)?;
984            if blocking_kind != "prompt" {
985                return None;
986            }
987
988            let interrupted_response_payload =
989                encode_test_response("prompt-cancelled", blocking_session_id);
990            match interrupt {
991                ExtensionInterruptRequest::KillProcess => Some(ExtensionInterruptResponse {
992                    interrupted_response_payload,
993                    interrupting_response_payload: None,
994                }),
995                ExtensionInterruptRequest::ExtensionPayload(payload) => {
996                    let (interrupt_kind, interrupt_session_id) = parse_test_payload(payload)?;
997                    match interrupt_kind {
998                        "close" if interrupt_session_id == blocking_session_id => {
999                            Some(ExtensionInterruptResponse {
1000                                interrupted_response_payload,
1001                                interrupting_response_payload: None,
1002                            })
1003                        }
1004                        "cancel" if interrupt_session_id == blocking_session_id => {
1005                            Some(ExtensionInterruptResponse {
1006                                interrupted_response_payload,
1007                                interrupting_response_payload: Some(encode_test_response(
1008                                    "cancelled",
1009                                    interrupt_session_id,
1010                                )),
1011                            })
1012                        }
1013                        "prompt" | "close" | "cancel" => None,
1014                        _ => None,
1015                    }
1016                }
1017            }
1018        }
1019    }
1020
1021    fn parse_test_payload(payload: &[u8]) -> Option<(&str, &str)> {
1022        let payload = std::str::from_utf8(payload).ok()?;
1023        payload.split_once(':')
1024    }
1025
1026    fn encode_test_response(kind: &str, session_id: &str) -> Vec<u8> {
1027        format!("{kind}:{session_id}").into_bytes()
1028    }
1029}
1030
1031#[derive(Debug, Clone)]
1032pub(crate) struct LocalBridge {
1033    started_at: Instant,
1034    next_timer_id: usize,
1035    snapshots: BTreeMap<String, FilesystemSnapshot>,
1036}
1037
1038impl Default for LocalBridge {
1039    fn default() -> Self {
1040        Self {
1041            started_at: Instant::now(),
1042            next_timer_id: 0,
1043            snapshots: BTreeMap::new(),
1044        }
1045    }
1046}
1047
1048impl BridgeTypes for LocalBridge {
1049    type Error = LocalBridgeError;
1050}
1051
1052impl FilesystemBridge for LocalBridge {
1053    fn read_file(&mut self, request: ReadFileRequest) -> Result<Vec<u8>, Self::Error> {
1054        fs::read(Self::host_path(&request.path))
1055            .map_err(|error| LocalBridgeError::io("read", &request.path, error))
1056    }
1057
1058    fn write_file(&mut self, request: WriteFileRequest) -> Result<(), Self::Error> {
1059        let host_path = Self::host_path(&request.path);
1060        if let Some(parent) = host_path.parent() {
1061            fs::create_dir_all(parent)
1062                .map_err(|error| LocalBridgeError::io("mkdir", &request.path, error))?;
1063        }
1064        fs::write(host_path, request.contents)
1065            .map_err(|error| LocalBridgeError::io("write", &request.path, error))
1066    }
1067
1068    fn stat(&mut self, request: PathRequest) -> Result<FileMetadata, Self::Error> {
1069        fs::metadata(Self::host_path(&request.path))
1070            .map(Self::file_metadata)
1071            .map_err(|error| LocalBridgeError::io("stat", &request.path, error))
1072    }
1073
1074    fn lstat(&mut self, request: PathRequest) -> Result<FileMetadata, Self::Error> {
1075        fs::symlink_metadata(Self::host_path(&request.path))
1076            .map(Self::file_metadata)
1077            .map_err(|error| LocalBridgeError::io("lstat", &request.path, error))
1078    }
1079
1080    fn read_dir(&mut self, request: ReadDirRequest) -> Result<Vec<DirectoryEntry>, Self::Error> {
1081        let mut entries = fs::read_dir(Self::host_path(&request.path))
1082            .map_err(|error| LocalBridgeError::io("readdir", &request.path, error))?
1083            .map(|entry| {
1084                let entry =
1085                    entry.map_err(|error| LocalBridgeError::io("readdir", &request.path, error))?;
1086                let kind = entry
1087                    .file_type()
1088                    .map(Self::file_kind)
1089                    .map_err(|error| LocalBridgeError::io("readdir", &request.path, error))?;
1090                Ok(DirectoryEntry {
1091                    name: entry.file_name().to_string_lossy().into_owned(),
1092                    kind,
1093                })
1094            })
1095            .collect::<Result<Vec<_>, LocalBridgeError>>()?;
1096        entries.sort_by(|left, right| left.name.cmp(&right.name));
1097        Ok(entries)
1098    }
1099
1100    fn create_dir(&mut self, request: CreateDirRequest) -> Result<(), Self::Error> {
1101        let host_path = Self::host_path(&request.path);
1102        if request.recursive {
1103            fs::create_dir_all(host_path)
1104        } else {
1105            fs::create_dir(host_path)
1106        }
1107        .map_err(|error| LocalBridgeError::io("mkdir", &request.path, error))
1108    }
1109
1110    fn remove_file(&mut self, request: PathRequest) -> Result<(), Self::Error> {
1111        fs::remove_file(Self::host_path(&request.path))
1112            .map_err(|error| LocalBridgeError::io("unlink", &request.path, error))
1113    }
1114
1115    fn remove_dir(&mut self, request: PathRequest) -> Result<(), Self::Error> {
1116        fs::remove_dir(Self::host_path(&request.path))
1117            .map_err(|error| LocalBridgeError::io("rmdir", &request.path, error))
1118    }
1119
1120    fn rename(&mut self, request: RenameRequest) -> Result<(), Self::Error> {
1121        let from_path = Self::host_path(&request.from_path);
1122        let to_path = Self::host_path(&request.to_path);
1123        if let Some(parent) = to_path.parent() {
1124            fs::create_dir_all(parent)
1125                .map_err(|error| LocalBridgeError::io("mkdir", &request.to_path, error))?;
1126        }
1127        fs::rename(from_path, to_path).map_err(|error| {
1128            LocalBridgeError::unsupported(format!(
1129                "rename {} -> {}: {}",
1130                request.from_path, request.to_path, error
1131            ))
1132        })
1133    }
1134
1135    fn symlink(&mut self, request: SymlinkRequest) -> Result<(), Self::Error> {
1136        let link_path = Self::host_path(&request.link_path);
1137        if let Some(parent) = link_path.parent() {
1138            fs::create_dir_all(parent)
1139                .map_err(|error| LocalBridgeError::io("mkdir", &request.link_path, error))?;
1140        }
1141        create_symlink(&request.target_path, link_path)
1142            .map_err(|error| LocalBridgeError::io("symlink", &request.link_path, error))
1143    }
1144
1145    fn read_link(&mut self, request: PathRequest) -> Result<String, Self::Error> {
1146        fs::read_link(Self::host_path(&request.path))
1147            .map(|target| target.to_string_lossy().into_owned())
1148            .map_err(|error| LocalBridgeError::io("readlink", &request.path, error))
1149    }
1150
1151    fn chmod(&mut self, request: ChmodRequest) -> Result<(), Self::Error> {
1152        let permissions = fs::Permissions::from_mode(request.mode);
1153        fs::set_permissions(Self::host_path(&request.path), permissions)
1154            .map_err(|error| LocalBridgeError::io("chmod", &request.path, error))
1155    }
1156
1157    fn truncate(&mut self, request: TruncateRequest) -> Result<(), Self::Error> {
1158        OpenOptions::new()
1159            .write(true)
1160            .create(false)
1161            .open(Self::host_path(&request.path))
1162            .and_then(|file| file.set_len(request.len))
1163            .map_err(|error| LocalBridgeError::io("truncate", &request.path, error))
1164    }
1165
1166    fn exists(&mut self, request: PathRequest) -> Result<bool, Self::Error> {
1167        Ok(fs::symlink_metadata(Self::host_path(&request.path)).is_ok())
1168    }
1169}
1170
1171impl PermissionBridge for LocalBridge {
1172    fn check_filesystem_access(
1173        &mut self,
1174        request: FilesystemPermissionRequest,
1175    ) -> Result<PermissionDecision, Self::Error> {
1176        Ok(PermissionDecision::deny(format!(
1177            "no static filesystem policy registered for {}:{}",
1178            request.vm_id, request.path
1179        )))
1180    }
1181
1182    fn check_network_access(
1183        &mut self,
1184        request: NetworkPermissionRequest,
1185    ) -> Result<PermissionDecision, Self::Error> {
1186        Ok(PermissionDecision::deny(format!(
1187            "no static network policy registered for {}:{}",
1188            request.vm_id, request.resource
1189        )))
1190    }
1191
1192    fn check_command_execution(
1193        &mut self,
1194        request: CommandPermissionRequest,
1195    ) -> Result<PermissionDecision, Self::Error> {
1196        Ok(PermissionDecision::deny(format!(
1197            "no static child_process policy registered for {}:{}",
1198            request.vm_id, request.command
1199        )))
1200    }
1201
1202    fn check_environment_access(
1203        &mut self,
1204        request: EnvironmentPermissionRequest,
1205    ) -> Result<PermissionDecision, Self::Error> {
1206        Ok(PermissionDecision::deny(format!(
1207            "no static env policy registered for {}:{}",
1208            request.vm_id, request.key
1209        )))
1210    }
1211}
1212
1213impl PersistenceBridge for LocalBridge {
1214    fn load_filesystem_state(
1215        &mut self,
1216        request: LoadFilesystemStateRequest,
1217    ) -> Result<Option<FilesystemSnapshot>, Self::Error> {
1218        Ok(self.snapshots.get(&request.vm_id).cloned())
1219    }
1220
1221    fn flush_filesystem_state(
1222        &mut self,
1223        request: FlushFilesystemStateRequest,
1224    ) -> Result<(), Self::Error> {
1225        self.snapshots.insert(request.vm_id, request.snapshot);
1226        Ok(())
1227    }
1228}
1229
1230impl ClockBridge for LocalBridge {
1231    fn wall_clock(&mut self, _request: ClockRequest) -> Result<SystemTime, Self::Error> {
1232        Ok(SystemTime::now())
1233    }
1234
1235    fn monotonic_clock(&mut self, _request: ClockRequest) -> Result<Duration, Self::Error> {
1236        Ok(self.started_at.elapsed())
1237    }
1238
1239    fn schedule_timer(
1240        &mut self,
1241        request: ScheduleTimerRequest,
1242    ) -> Result<ScheduledTimer, Self::Error> {
1243        self.next_timer_id += 1;
1244        Ok(ScheduledTimer {
1245            timer_id: format!("timer-{}", self.next_timer_id),
1246            delay: request.delay,
1247        })
1248    }
1249}
1250
1251impl RandomBridge for LocalBridge {
1252    fn fill_random_bytes(&mut self, request: RandomBytesRequest) -> Result<Vec<u8>, Self::Error> {
1253        Ok(vec![0u8; request.len])
1254    }
1255}
1256
1257impl EventBridge for LocalBridge {
1258    fn emit_structured_event(&mut self, _event: StructuredEventRecord) -> Result<(), Self::Error> {
1259        Ok(())
1260    }
1261
1262    fn emit_diagnostic(&mut self, _event: DiagnosticRecord) -> Result<(), Self::Error> {
1263        Ok(())
1264    }
1265
1266    fn emit_log(&mut self, _event: LogRecord) -> Result<(), Self::Error> {
1267        Ok(())
1268    }
1269
1270    fn emit_lifecycle(&mut self, _event: LifecycleEventRecord) -> Result<(), Self::Error> {
1271        Ok(())
1272    }
1273}
1274
1275impl ExecutionBridge for LocalBridge {
1276    fn create_javascript_context(
1277        &mut self,
1278        _request: CreateJavascriptContextRequest,
1279    ) -> Result<GuestContextHandle, Self::Error> {
1280        Err(LocalBridgeError::unsupported(
1281            "execution bridge is handled internally by the native sidecar",
1282        ))
1283    }
1284
1285    fn create_wasm_context(
1286        &mut self,
1287        _request: CreateWasmContextRequest,
1288    ) -> Result<GuestContextHandle, Self::Error> {
1289        Err(LocalBridgeError::unsupported(
1290            "execution bridge is handled internally by the native sidecar",
1291        ))
1292    }
1293
1294    fn start_execution(
1295        &mut self,
1296        _request: StartExecutionRequest,
1297    ) -> Result<StartedExecution, Self::Error> {
1298        Err(LocalBridgeError::unsupported(
1299            "execution bridge is handled internally by the native sidecar",
1300        ))
1301    }
1302
1303    fn write_stdin(&mut self, _request: WriteExecutionStdinRequest) -> Result<(), Self::Error> {
1304        Err(LocalBridgeError::unsupported(
1305            "execution bridge is handled internally by the native sidecar",
1306        ))
1307    }
1308
1309    fn close_stdin(&mut self, _request: ExecutionHandleRequest) -> Result<(), Self::Error> {
1310        Err(LocalBridgeError::unsupported(
1311            "execution bridge is handled internally by the native sidecar",
1312        ))
1313    }
1314
1315    fn kill_execution(&mut self, _request: KillExecutionRequest) -> Result<(), Self::Error> {
1316        Err(LocalBridgeError::unsupported(
1317            "execution bridge is handled internally by the native sidecar",
1318        ))
1319    }
1320
1321    fn poll_execution_event(
1322        &mut self,
1323        _request: PollExecutionEventRequest,
1324    ) -> Result<Option<ExecutionEvent>, Self::Error> {
1325        Ok(None)
1326    }
1327}
1328
1329#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1330struct SessionScope {
1331    connection_id: String,
1332    session_id: String,
1333}
1334
1335impl SessionScope {
1336    fn ownership_scope(&self) -> OwnershipScope {
1337        session_ownership(&self.connection_id, &self.session_id)
1338    }
1339
1340    fn compat_ownership_scope(&self) -> crate::protocol::OwnershipScope {
1341        wire::ownership_scope_to_compat(self.ownership_scope())
1342    }
1343}
1344
1345/// Live event sink backed by the outbound stdout channel. Writes each event as a
1346/// `ProtocolFrame::EventFrame` immediately, using the same blocking
1347/// backpressure semantics as the batch event path (`send_output_frame`): a full
1348/// queue parks the producer until the writer thread drains stdout rather than
1349/// tearing down the process.
1350struct FrameEventTransport {
1351    writer: TrackedSyncSender<ProtocolFrame>,
1352}
1353
1354impl FrameEventTransport {
1355    fn new(writer: TrackedSyncSender<ProtocolFrame>) -> Self {
1356        Self { writer }
1357    }
1358}
1359
1360impl EventSinkTransport for FrameEventTransport {
1361    fn emit_event(&self, event: crate::wire::EventFrame) -> Result<(), SidecarError> {
1362        send_output_frame(&self.writer, ProtocolFrame::EventFrame(event))
1363            .map_err(|error| SidecarError::Bridge(error.to_string()))
1364    }
1365}
1366
1367struct FrameSidecarRequestTransport {
1368    writer: TrackedSyncSender<ProtocolFrame>,
1369    pending: Arc<Mutex<BTreeMap<RequestId, mpsc::SyncSender<SidecarResponseFrame>>>>,
1370}
1371
1372impl FrameSidecarRequestTransport {
1373    fn new(writer: TrackedSyncSender<ProtocolFrame>) -> Self {
1374        Self {
1375            writer,
1376            pending: Arc::new(Mutex::new(BTreeMap::new())),
1377        }
1378    }
1379
1380    fn accept_response(&self, response: SidecarResponseFrame) -> bool {
1381        let sender = {
1382            let mut pending = match self.pending.lock() {
1383                Ok(pending) => pending,
1384                Err(_) => return false,
1385            };
1386            pending.remove(&response.request_id)
1387        };
1388        let Some(sender) = sender else {
1389            return false;
1390        };
1391        let _ = sender.send(response);
1392        true
1393    }
1394}
1395
1396impl SidecarRequestTransport for FrameSidecarRequestTransport {
1397    fn send_request(
1398        &self,
1399        request: crate::protocol::SidecarRequestFrame,
1400        timeout: Duration,
1401    ) -> Result<crate::protocol::SidecarResponseFrame, SidecarError> {
1402        let request =
1403            wire::sidecar_request_frame_from_compat(request).map_err(wire_protocol_error)?;
1404        let (sender, receiver) = mpsc::sync_channel(1);
1405        self.pending
1406            .lock()
1407            .map_err(|_| {
1408                SidecarError::Bridge(String::from("sidecar callback waiter map lock poisoned"))
1409            })?
1410            .insert(request.request_id, sender);
1411        // Bound the request-frame WRITE by the caller's deadline. The shared
1412        // `send_output_frame` blocks (correct backpressure for the fire-and-forget
1413        // event/response paths), but this request path has a `timeout` that the
1414        // response wait below already honors — so a stalled host stdout must not
1415        // make the *send* block past it. Poll try_send until a slot frees or the
1416        // deadline passes.
1417        let write_deadline = Instant::now() + timeout;
1418        let mut frame = ProtocolFrame::SidecarRequestFrame(request.clone());
1419        let write_result = loop {
1420            match self.writer.try_send(frame) {
1421                Ok(()) => break Ok(()),
1422                Err(mpsc::TrySendError::Disconnected(_)) => {
1423                    break Err(String::from("stdout writer disconnected"));
1424                }
1425                Err(mpsc::TrySendError::Full(returned)) => {
1426                    if Instant::now() >= write_deadline {
1427                        break Err(format!(
1428                            "timed out writing sidecar request frame after {}s",
1429                            timeout.as_secs()
1430                        ));
1431                    }
1432                    frame = returned;
1433                    thread::sleep(Duration::from_millis(1));
1434                }
1435            }
1436        };
1437        if let Err(message) = write_result {
1438            let _ = self
1439                .pending
1440                .lock()
1441                .map(|mut pending| pending.remove(&request.request_id));
1442            return Err(SidecarError::Io(format!(
1443                "failed to write sidecar request frame: {message}"
1444            )));
1445        }
1446        match receiver.recv_timeout(timeout) {
1447            Ok(response) => {
1448                wire::sidecar_response_frame_to_compat(response).map_err(wire_protocol_error)
1449            }
1450            Err(mpsc::RecvTimeoutError::Timeout) => {
1451                let _ = self
1452                    .pending
1453                    .lock()
1454                    .map(|mut pending| pending.remove(&request.request_id));
1455                Err(SidecarError::Io(format!(
1456                    "timed out waiting for sidecar response after {}s",
1457                    timeout.as_secs()
1458                )))
1459            }
1460            Err(mpsc::RecvTimeoutError::Disconnected) => Err(SidecarError::Io(String::from(
1461                "sidecar response waiter disconnected",
1462            ))),
1463        }
1464    }
1465}
1466
1467#[derive(Debug, Clone, PartialEq, Eq)]
1468pub(crate) struct LocalBridgeError {
1469    message: String,
1470}
1471
1472impl LocalBridgeError {
1473    fn unsupported(message: impl Into<String>) -> Self {
1474        Self {
1475            message: message.into(),
1476        }
1477    }
1478
1479    fn io(operation: &str, path: &str, error: io::Error) -> Self {
1480        Self::unsupported(format!("{operation} {path}: {error}"))
1481    }
1482}
1483
1484impl fmt::Display for LocalBridgeError {
1485    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1486        f.write_str(&self.message)
1487    }
1488}
1489
1490impl Error for LocalBridgeError {}
1491
1492impl LocalBridge {
1493    fn host_path(path: &str) -> PathBuf {
1494        let candidate = Path::new(path);
1495        if candidate.is_absolute() {
1496            candidate.to_path_buf()
1497        } else {
1498            std::env::current_dir()
1499                .unwrap_or_else(|_| PathBuf::from("."))
1500                .join(candidate)
1501        }
1502    }
1503
1504    fn file_metadata(metadata: fs::Metadata) -> FileMetadata {
1505        FileMetadata {
1506            mode: metadata.permissions().mode(),
1507            size: metadata.size(),
1508            kind: Self::file_kind(metadata.file_type()),
1509        }
1510    }
1511
1512    fn file_kind(file_type: fs::FileType) -> agentos_bridge::FileKind {
1513        if file_type.is_file() {
1514            agentos_bridge::FileKind::File
1515        } else if file_type.is_dir() {
1516            agentos_bridge::FileKind::Directory
1517        } else if file_type.is_symlink() {
1518            agentos_bridge::FileKind::SymbolicLink
1519        } else {
1520            agentos_bridge::FileKind::Other
1521        }
1522    }
1523}