Skip to main content

agentos_execution/
v8_runtime.rs

1//! V8 isolate runtime manager backed by the embedded V8 runtime.
2
3use crate::v8_ipc::{self, BinaryFrame};
4use agentos_runtime::RuntimeContext;
5use agentos_v8_runtime::embedded_runtime::{spawn_embedded_runtime_ipc, EmbeddedRuntimeHandle};
6use serde_json::Value;
7use std::io::{self, BufReader, Read, Write};
8use std::os::unix::net::UnixStream;
9use std::sync::{Arc, Mutex};
10
11/// Manages an embedded V8 runtime and its IPC connection.
12pub struct V8Runtime {
13    runtime: EmbeddedRuntimeHandle,
14    reader: BufReader<UnixStream>,
15    writer: UnixStream,
16}
17
18impl V8Runtime {
19    /// Spawn the embedded V8 runtime and connect over IPC.
20    pub fn spawn(runtime_context: &RuntimeContext) -> io::Result<Self> {
21        let (stream, runtime) = spawn_embedded_runtime_ipc(None, runtime_context.clone())?;
22        let writer = stream.try_clone()?;
23        let reader = BufReader::new(stream);
24
25        Ok(V8Runtime {
26            runtime,
27            reader,
28            writer,
29        })
30    }
31
32    /// Create a new V8 isolate session.
33    pub fn create_session(
34        &mut self,
35        session_id: &str,
36        heap_limit_mb: u32,
37        cpu_time_limit_ms: u32,
38        wall_clock_limit_ms: u32,
39    ) -> io::Result<()> {
40        self.send_frame(&BinaryFrame::CreateSession {
41            session_id: session_id.to_owned(),
42            heap_limit_mb,
43            cpu_time_limit_ms,
44            wall_clock_limit_ms,
45        })
46    }
47
48    /// Inject per-session globals (processConfig, osConfig) as CBOR payload.
49    pub fn inject_globals(&mut self, session_id: &str, payload: Vec<u8>) -> io::Result<()> {
50        self.send_frame(&BinaryFrame::InjectGlobals {
51            session_id: session_id.to_owned(),
52            payload,
53        })
54    }
55
56    /// Execute bridge code + user code in a session.
57    pub fn execute(
58        &mut self,
59        session_id: &str,
60        mode: u8,
61        file_path: &str,
62        bridge_code: &str,
63        user_code: &str,
64    ) -> io::Result<()> {
65        self.send_frame(&BinaryFrame::Execute {
66            session_id: session_id.to_owned(),
67            mode,
68            file_path: file_path.to_owned(),
69            bridge_code: bridge_code.to_owned(),
70            post_restore_script: String::new(),
71            userland_code: String::new(),
72            high_resolution_time: false,
73            user_code: user_code.to_owned(),
74        })
75    }
76
77    /// Send a bridge response back to the V8 isolate.
78    pub fn send_bridge_response(
79        &mut self,
80        session_id: &str,
81        call_id: u64,
82        status: u8,
83        payload: Vec<u8>,
84    ) -> io::Result<()> {
85        self.send_frame(&BinaryFrame::BridgeResponse {
86            session_id: session_id.to_owned(),
87            call_id,
88            status,
89            payload,
90        })
91    }
92
93    /// Send a stream event to the V8 isolate (stdin data, timer, child process events).
94    pub fn send_stream_event(
95        &mut self,
96        session_id: &str,
97        event_type: &str,
98        payload: Vec<u8>,
99    ) -> io::Result<()> {
100        self.send_frame(&BinaryFrame::StreamEvent {
101            session_id: session_id.to_owned(),
102            event_type: event_type.to_owned(),
103            payload,
104        })
105    }
106
107    /// Terminate execution in a session.
108    pub fn terminate_execution(&mut self, session_id: &str) -> io::Result<()> {
109        self.send_frame(&BinaryFrame::TerminateExecution {
110            session_id: session_id.to_owned(),
111        })
112    }
113
114    /// Destroy a session.
115    pub fn destroy_session(&mut self, session_id: &str) -> io::Result<()> {
116        self.send_frame(&BinaryFrame::DestroySession {
117            session_id: session_id.to_owned(),
118        })
119    }
120
121    /// Read the next frame from the V8 runtime.
122    pub fn read_frame(&mut self) -> io::Result<BinaryFrame> {
123        let mut len_buf = [0u8; 4];
124        self.reader.read_exact(&mut len_buf)?;
125        let total_len = u32::from_be_bytes(len_buf);
126
127        if total_len > 64 * 1024 * 1024 {
128            return Err(io::Error::new(
129                io::ErrorKind::InvalidData,
130                format!("frame size {total_len} exceeds maximum"),
131            ));
132        }
133
134        let mut buf = vec![0u8; total_len as usize];
135        self.reader.read_exact(&mut buf)?;
136        v8_ipc::decode_frame(&buf)
137    }
138
139    fn send_frame(&mut self, frame: &BinaryFrame) -> io::Result<()> {
140        let bytes = v8_ipc::encode_frame(frame)?;
141        self.writer.write_all(&bytes)?;
142        self.writer.flush()
143    }
144}
145
146impl Drop for V8Runtime {
147    fn drop(&mut self) {
148        self.runtime.shutdown();
149    }
150}
151
152/// Thread-safe wrapper for V8Runtime that allows sending from multiple threads.
153pub struct SharedV8Runtime {
154    inner: Arc<Mutex<V8Runtime>>,
155}
156
157impl SharedV8Runtime {
158    pub fn new(runtime: V8Runtime) -> Self {
159        Self {
160            inner: Arc::new(Mutex::new(runtime)),
161        }
162    }
163
164    pub fn lock(&self) -> std::sync::MutexGuard<'_, V8Runtime> {
165        self.inner.lock().expect("V8 runtime lock poisoned")
166    }
167}
168
169impl Clone for SharedV8Runtime {
170    fn clone(&self) -> Self {
171        Self {
172            inner: self.inner.clone(),
173        }
174    }
175}
176
177/// Bridge call method name mapping from V8 polyfill names to sidecar sync RPC names.
178/// The V8 polyfills use underscore-prefixed camelCase names while the sidecar
179/// uses dot-separated category.method names. The mapping lives in
180/// `bridge-contract.json` so bridge installation and dispatch drift together.
181pub fn map_bridge_method(method: &str) -> (&str, bool) {
182    if let Some(target) = agentos_bridge::bridge_contract().dispatch.get(method) {
183        (target.method.as_str(), target.translate_args)
184    } else {
185        (method, false)
186    }
187}
188
189/// Deserialize a CBOR payload into a JSON array of arguments.
190/// The V8 bridge serializes bridge call args as a CBOR array.
191pub fn cbor_payload_to_json_args(payload: &[u8]) -> io::Result<Vec<Value>> {
192    if payload.is_empty() {
193        return Ok(vec![]);
194    }
195    let cbor_value: ciborium::value::Value = ciborium::de::from_reader(payload).map_err(|e| {
196        io::Error::new(
197            io::ErrorKind::InvalidData,
198            format!("failed to deserialize CBOR bridge call payload: {e}"),
199        )
200    })?;
201    match cbor_to_json(cbor_value) {
202        Value::Array(arr) => Ok(arr),
203        single => Ok(vec![single]),
204    }
205}
206
207pub fn cbor_payload_raw_byte_arg(payload: &[u8], index: usize) -> io::Result<Option<Vec<u8>>> {
208    if payload.is_empty() {
209        return Ok(None);
210    }
211    let cbor_value: ciborium::value::Value = ciborium::de::from_reader(payload).map_err(|e| {
212        io::Error::new(
213            io::ErrorKind::InvalidData,
214            format!("failed to deserialize CBOR bridge call payload: {e}"),
215        )
216    })?;
217    let Some(value) = cbor_array_arg(&cbor_value, index) else {
218        return Ok(None);
219    };
220    Ok(cbor_raw_bytes(value).map(ToOwned::to_owned))
221}
222
223/// Serialize a JSON value to CBOR bytes for bridge responses.
224pub fn json_to_cbor_payload(value: &Value) -> io::Result<Vec<u8>> {
225    let cbor_value = json_to_cbor(value);
226    let mut buf = Vec::new();
227    ciborium::ser::into_writer(&cbor_value, &mut buf).map_err(|e| {
228        io::Error::new(
229            io::ErrorKind::InvalidData,
230            format!("failed to serialize CBOR bridge response: {e}"),
231        )
232    })?;
233    Ok(buf)
234}
235
236fn cbor_array_arg(value: &ciborium::value::Value, index: usize) -> Option<&ciborium::value::Value> {
237    match value {
238        ciborium::value::Value::Array(values) => values.get(index),
239        value if index == 0 => Some(value),
240        _ => None,
241    }
242}
243
244fn cbor_raw_bytes(value: &ciborium::value::Value) -> Option<&[u8]> {
245    match value {
246        ciborium::value::Value::Bytes(bytes) => Some(bytes),
247        ciborium::value::Value::Tag(_, inner) => cbor_raw_bytes(inner),
248        _ => None,
249    }
250}
251
252fn cbor_to_json(value: ciborium::value::Value) -> Value {
253    use ciborium::value::Value as Cbor;
254    match value {
255        Cbor::Null => Value::Null,
256        Cbor::Bool(b) => Value::Bool(b),
257        Cbor::Integer(i) => {
258            let n: i128 = i.into();
259            if let Ok(n) = i64::try_from(n) {
260                Value::Number(n.into())
261            } else if let Ok(n) = u64::try_from(n) {
262                Value::Number(n.into())
263            } else {
264                Value::Number(serde_json::Number::from_f64(n as f64).unwrap_or(0.into()))
265            }
266        }
267        Cbor::Float(f) => serde_json::Number::from_f64(f)
268            .map(Value::Number)
269            .unwrap_or(Value::Null),
270        Cbor::Text(s) => Value::String(s),
271        Cbor::Bytes(b) => {
272            use serde_json::json;
273            // Encode binary data as base64 with a type marker
274            json!({ "__type": "Buffer", "data": base64_encode(&b) })
275        }
276        Cbor::Array(arr) => Value::Array(arr.into_iter().map(cbor_to_json).collect()),
277        Cbor::Map(map) => {
278            let mut obj = serde_json::Map::new();
279            for (k, v) in map {
280                let key = match k {
281                    Cbor::Text(s) => s,
282                    Cbor::Integer(i) => {
283                        let n: i128 = i.into();
284                        n.to_string()
285                    }
286                    other => format!("{other:?}"),
287                };
288                obj.insert(key, cbor_to_json(v));
289            }
290            Value::Object(obj)
291        }
292        Cbor::Tag(_, inner) => cbor_to_json(*inner),
293        _ => Value::Null,
294    }
295}
296
297fn json_to_cbor(value: &Value) -> ciborium::value::Value {
298    use ciborium::value::Value as Cbor;
299    match value {
300        Value::Null => Cbor::Null,
301        Value::Bool(b) => Cbor::Bool(*b),
302        Value::Number(n) => {
303            if let Some(i) = n.as_i64() {
304                Cbor::Integer(i.into())
305            } else if let Some(u) = n.as_u64() {
306                Cbor::Integer(u.into())
307            } else if let Some(f) = n.as_f64() {
308                Cbor::Float(f)
309            } else {
310                Cbor::Null
311            }
312        }
313        Value::String(s) => Cbor::Text(s.clone()),
314        Value::Array(arr) => Cbor::Array(arr.iter().map(json_to_cbor).collect()),
315        Value::Object(map) => {
316            // Check for Buffer type marker
317            if map.get("__type").and_then(Value::as_str) == Some("Buffer") {
318                if let Some(data) = map.get("data").and_then(Value::as_str) {
319                    if let Ok(bytes) = base64_decode(data) {
320                        return Cbor::Bytes(bytes);
321                    }
322                }
323            }
324            Cbor::Map(
325                map.iter()
326                    .map(|(k, v)| (Cbor::Text(k.clone()), json_to_cbor(v)))
327                    .collect(),
328            )
329        }
330    }
331}
332
333/// Public base64 encode for use in bridge call handlers.
334pub fn base64_encode_pub(data: &[u8]) -> String {
335    base64_encode(data)
336}
337
338pub fn base64_decode_pub(input: &str) -> Option<Vec<u8>> {
339    base64_decode(input).ok()
340}
341
342fn base64_encode(data: &[u8]) -> String {
343    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
344    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
345    for chunk in data.chunks(3) {
346        let b0 = chunk[0] as u32;
347        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
348        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
349        let triple = (b0 << 16) | (b1 << 8) | b2;
350        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
351        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
352        if chunk.len() > 1 {
353            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
354        } else {
355            result.push('=');
356        }
357        if chunk.len() > 2 {
358            result.push(CHARS[(triple & 0x3F) as usize] as char);
359        } else {
360            result.push('=');
361        }
362    }
363    result
364}
365
366fn base64_decode(input: &str) -> Result<Vec<u8>, ()> {
367    fn decode_char(c: u8) -> Result<u8, ()> {
368        match c {
369            b'A'..=b'Z' => Ok(c - b'A'),
370            b'a'..=b'z' => Ok(c - b'a' + 26),
371            b'0'..=b'9' => Ok(c - b'0' + 52),
372            b'+' => Ok(62),
373            b'/' => Ok(63),
374            b'=' => Ok(0),
375            _ => Err(()),
376        }
377    }
378    let bytes = input.as_bytes();
379    let mut result = Vec::with_capacity(bytes.len() * 3 / 4);
380    for chunk in bytes.chunks(4) {
381        if chunk.len() < 4 {
382            return Err(());
383        }
384        let a = decode_char(chunk[0])?;
385        let b = decode_char(chunk[1])?;
386        let c = decode_char(chunk[2])?;
387        let d = decode_char(chunk[3])?;
388        let triple = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
389        result.push((triple >> 16) as u8);
390        if chunk[2] != b'=' {
391            result.push((triple >> 8) as u8);
392        }
393        if chunk[3] != b'=' {
394            result.push(triple as u8);
395        }
396    }
397    Ok(result)
398}
399
400#[cfg(test)]
401mod tests {
402    use super::{json_to_cbor_payload, map_bridge_method};
403    use serde_json::json;
404
405    #[test]
406    fn cbor_byte_string_boundary_includes_five_byte_header() {
407        let payload_limit = 256 * 1024;
408        let raw_bytes = payload_limit - 5;
409        let encoded = json_to_cbor_payload(&json!({
410            "__type": "Buffer",
411            "data": super::base64_encode_pub(&vec![0xA5; raw_bytes]),
412        }))
413        .expect("encode boundary byte string");
414        assert_eq!(encoded.len(), payload_limit);
415
416        let oversized = json_to_cbor_payload(&json!({
417            "__type": "Buffer",
418            "data": super::base64_encode_pub(&vec![0xA5; raw_bytes + 1]),
419        }))
420        .expect("encode oversized byte string");
421        assert_eq!(oversized.len(), payload_limit + 1);
422    }
423
424    #[test]
425    fn audited_bridge_methods_map_to_named_handlers() {
426        for method in [
427            "_cryptoHashDigest",
428            "_cryptoSubtle",
429            "_networkHttp2ServerListenRaw",
430            "_networkHttpServerRequestRaw",
431            "_networkHttp2SessionConnectRaw",
432            "_networkHttp2StreamRespondRaw",
433            "_upgradeSocketWriteRaw",
434            "_netSocketSetNoDelayRaw",
435            "_kernelStdioWriteRaw",
436            "_kernelPollRaw",
437            "_kernelFlockRaw",
438            "_kernelTtySizeRaw",
439            "_netSocketUpgradeTlsRaw",
440            "_tlsGetCiphersRaw",
441            "_dgramSocketAddressRaw",
442            "_dgramSocketSetBufferSizeRaw",
443        ] {
444            let (mapped, _) = map_bridge_method(method);
445            assert_ne!(mapped, method, "missing bridge-method mapping for {method}");
446        }
447    }
448
449    #[test]
450    fn http_request_bridge_shortcut_is_not_mapped() {
451        assert_eq!(
452            map_bridge_method("_networkHttpRequestRaw"),
453            ("_networkHttpRequestRaw", false)
454        );
455    }
456}