Skip to main content

agentos_sidecar_browser/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Agent OS browser sidecar wrapper.
4
5#[cfg(target_arch = "wasm32")]
6mod wasm;
7#[cfg(target_arch = "wasm32")]
8pub use wasm::AgentOsBrowserSidecarWasm;
9
10mod acp_host;
11
12use std::collections::BTreeMap;
13use std::sync::Mutex;
14
15use agentos_native_sidecar_browser::{
16    BrowserExtension, BrowserExtensionContext, BrowserSidecar, BrowserSidecarBridge,
17    BrowserSidecarConfig, BrowserSidecarError,
18};
19use agentos_sidecar_core::{codec, error_response, AcpCore, AcpCoreError};
20
21use crate::acp_host::BrowserAcpHost;
22
23/// The browser ACP extension: decodes ACP wire requests, dispatches them through
24/// the host-free `agentos-sidecar-core` engine, and drives the agent process via a
25/// `BrowserAcpHost` over the converged executor. This crate stays host-free (no
26/// tokio / native agentos-native-sidecar) so it compiles to wasm32; the kernel remains
27/// the sole enforcement point and all guest syscalls route through the converged
28/// sync bridge. `Mutex` (not `RefCell`) satisfies the `Send + Sync` trait bound; the
29/// browser runs single-threaded so there is no real contention.
30pub struct BrowserAcpExtension {
31    core: Mutex<AcpCore>,
32    /// process_id -> execution_id, persisted across requests for a session.
33    executions: Mutex<BTreeMap<String, String>>,
34}
35
36impl BrowserAcpExtension {
37    pub fn new() -> Self {
38        Self {
39            core: Mutex::new(AcpCore::new()),
40            executions: Mutex::new(BTreeMap::new()),
41        }
42    }
43}
44
45impl Default for BrowserAcpExtension {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51fn to_browser_error(error: AcpCoreError) -> BrowserSidecarError {
52    BrowserSidecarError::InvalidState(error.to_string())
53}
54
55impl BrowserExtension for BrowserAcpExtension {
56    fn namespace(&self) -> &str {
57        agentos_protocol::ACP_EXTENSION_NAMESPACE
58    }
59
60    fn handle_request(
61        &self,
62        context: &mut BrowserExtensionContext<'_>,
63        payload: &[u8],
64    ) -> Result<Vec<u8>, BrowserSidecarError> {
65        let request = codec::decode_request(payload).map_err(to_browser_error)?;
66        let connection_id = context.connection_id().unwrap_or_default().to_string();
67        let vm_id = context
68            .vm_id()
69            .ok_or_else(|| {
70                BrowserSidecarError::InvalidState(String::from(
71                    "ACP requests require VM ownership (no vm_id on the request)",
72                ))
73            })?
74            .to_string();
75
76        let mut executions = self.executions.lock().map_err(|_| {
77            BrowserSidecarError::InvalidState(String::from("ACP executions lock poisoned"))
78        })?;
79        let mut host = BrowserAcpHost::new(context, vm_id, &mut executions);
80        let mut core = self.core.lock().map_err(|_| {
81            BrowserSidecarError::InvalidState(String::from("ACP core lock poisoned"))
82        })?;
83        // Browser uses the RESUMABLE path (AGENTOS-WEB-ASYNC-AGENTS.md §3.2.1):
84        // create_session / session/prompt return AcpPending{processId} and the kernel
85        // worker drives them via deliver_agent_output, so the worker never blocks
86        // inside pushFrame while the agent makes a mid-turn syscall. A handler error
87        // becomes an `AcpErrorResponse` (matching native), not a rejected wire frame.
88        let response = match core.dispatch_resumable(&mut host, &connection_id, request) {
89            Ok(response) => response,
90            Err(error) => error_response(&error),
91        };
92        codec::encode_response(&response).map_err(to_browser_error)
93    }
94}
95
96pub fn extensions() -> Vec<Box<dyn BrowserExtension>> {
97    vec![Box::new(BrowserAcpExtension::new())]
98}
99
100pub fn browser_sidecar<B>(
101    bridge: B,
102    config: BrowserSidecarConfig,
103) -> Result<BrowserSidecar<B>, BrowserSidecarError>
104where
105    B: BrowserSidecarBridge,
106    <B as agentos_bridge::BridgeTypes>::Error: std::fmt::Debug,
107{
108    BrowserSidecar::with_extensions(bridge, config, extensions())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use agentos_protocol::ACP_EXTENSION_NAMESPACE;
115
116    #[test]
117    fn browser_extensions_register_acp_namespace() {
118        let extensions = extensions();
119
120        assert_eq!(extensions.len(), 1);
121        assert_eq!(extensions[0].namespace(), ACP_EXTENSION_NAMESPACE);
122    }
123
124    #[test]
125    fn browser_acp_extension_rejects_invalid_payload() {
126        // A bogus payload fails ACP wire decoding before any host work — proving the
127        // request is now routed through the core codec (not a stub).
128        let extension = BrowserAcpExtension::new();
129        let mut host = NullBrowserExtensionHost;
130        let mut context = BrowserExtensionContext::new(&mut host);
131
132        let error = extension
133            .handle_request(&mut context, b"not-a-valid-acp-frame")
134            .expect_err("invalid ACP payload must be rejected");
135        assert!(error.to_string().contains("invalid ACP request"));
136    }
137
138    #[test]
139    fn browser_acp_extension_requires_vm_ownership() {
140        // A well-formed request with no vm_id on the context fails closed (the seam
141        // that threads vm ownership into the extension is exercised).
142        use agentos_protocol::generated::v1::{AcpGetSessionStateRequest, AcpRequest};
143        let extension = BrowserAcpExtension::new();
144        let mut host = NullBrowserExtensionHost;
145        let mut context = BrowserExtensionContext::new(&mut host);
146
147        let payload = serde_bare::to_vec(&AcpRequest::AcpGetSessionStateRequest(
148            AcpGetSessionStateRequest {
149                session_id: "s1".into(),
150            },
151        ))
152        .expect("encode");
153        let error = extension
154            .handle_request(&mut context, &payload)
155            .expect_err("missing vm ownership must fail");
156        assert!(error.to_string().contains("require VM ownership"));
157    }
158
159    struct NullBrowserExtensionHost;
160
161    impl agentos_native_sidecar_browser::BrowserExtensionHost for NullBrowserExtensionHost {
162        fn write_file(
163            &mut self,
164            _vm_id: &str,
165            _path: &str,
166            _contents: Vec<u8>,
167        ) -> Result<(), BrowserSidecarError> {
168            unreachable!("test ACP extension does not call browser context")
169        }
170
171        fn read_file(&mut self, _vm_id: &str, _path: &str) -> Result<Vec<u8>, BrowserSidecarError> {
172            unreachable!("test ACP extension does not call browser context")
173        }
174
175        fn mkdir(
176            &mut self,
177            _vm_id: &str,
178            _path: &str,
179            _recursive: bool,
180        ) -> Result<(), BrowserSidecarError> {
181            unreachable!("test ACP extension does not call browser context")
182        }
183
184        fn read_dir(
185            &mut self,
186            _vm_id: &str,
187            _path: &str,
188        ) -> Result<Vec<String>, BrowserSidecarError> {
189            unreachable!("test ACP extension does not call browser context")
190        }
191
192        fn create_javascript_context(
193            &mut self,
194            _request: agentos_bridge::CreateJavascriptContextRequest,
195        ) -> Result<agentos_bridge::GuestContextHandle, BrowserSidecarError> {
196            unreachable!("test ACP extension does not call browser context")
197        }
198
199        fn create_wasm_context(
200            &mut self,
201            _request: agentos_bridge::CreateWasmContextRequest,
202        ) -> Result<agentos_bridge::GuestContextHandle, BrowserSidecarError> {
203            unreachable!("test ACP extension does not call browser context")
204        }
205
206        fn start_execution(
207            &mut self,
208            _request: agentos_bridge::StartExecutionRequest,
209        ) -> Result<agentos_bridge::StartedExecution, BrowserSidecarError> {
210            unreachable!("test ACP extension does not call browser context")
211        }
212
213        fn write_stdin(
214            &mut self,
215            _request: agentos_bridge::WriteExecutionStdinRequest,
216        ) -> Result<(), BrowserSidecarError> {
217            unreachable!("test ACP extension does not call browser context")
218        }
219
220        fn close_stdin(
221            &mut self,
222            _request: agentos_bridge::ExecutionHandleRequest,
223        ) -> Result<(), BrowserSidecarError> {
224            unreachable!("test ACP extension does not call browser context")
225        }
226
227        fn kill_execution(
228            &mut self,
229            _request: agentos_bridge::KillExecutionRequest,
230        ) -> Result<(), BrowserSidecarError> {
231            unreachable!("test ACP extension does not call browser context")
232        }
233
234        fn poll_execution_event(
235            &mut self,
236            _request: agentos_bridge::PollExecutionEventRequest,
237        ) -> Result<Option<agentos_bridge::ExecutionEvent>, BrowserSidecarError> {
238            unreachable!("test ACP extension does not call browser context")
239        }
240    }
241}