Skip to main content

agentos_client/
net.rs

1//! Port-based virtual networking (`fetch`).
2//!
3//! Ported from `packages/core/src/agent-os.ts` `fetch`. Dispatches to a guest server on `port`
4//! inside the kernel, never the host. The request URL host is discarded (only `pathname`+`search`
5//! are used); the body is only attached for non-GET/HEAD methods; the response body is base64-decoded.
6//! Fully buffered both directions. Wire path is the existing `VmFetch` request/response.
7
8use std::collections::BTreeMap;
9
10use anyhow::{Context, Result};
11use base64::engine::general_purpose::STANDARD as BASE64;
12use base64::Engine as _;
13use serde::{Deserialize, Serialize};
14
15use agentos_sidecar_client::wire;
16
17use crate::agent_os::AgentOs;
18use crate::error::ClientError;
19use crate::fs::FileContent;
20
21/// Maximum fully buffered fetch component size. `VmFetch` is a single request/response frame, so
22/// keeping this at the default frame size prevents fetch-specific buffers from growing just because
23/// a sidecar was configured with a larger transport frame limit for another API.
24const VM_FETCH_BUFFER_LIMIT_BYTES: usize = agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES;
25
26/// The shape of the JSON string returned in [`VmFetchResponse::response_json`], mirroring the TS
27/// `{ status, statusText?, headers?: [k,v][], body?: base64 }` payload.
28#[derive(Debug, Deserialize)]
29struct VmFetchResponsePayload {
30    status: u16,
31    #[serde(rename = "statusText", default)]
32    status_text: Option<String>,
33    #[serde(default)]
34    headers: Option<Vec<(String, String)>>,
35    /// Base64-encoded response body.
36    #[serde(default)]
37    body: Option<String>,
38}
39
40#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
41#[cfg_attr(feature = "contract", ts(rename = "ActorHttpRequest"))]
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase", deny_unknown_fields)]
44pub struct HttpRequest {
45    pub port: u16,
46    pub path: String,
47    #[cfg_attr(feature = "contract", ts(optional, as = "Option<_>"))]
48    #[serde(default = "default_http_method")]
49    pub method: String,
50    #[cfg_attr(feature = "contract", ts(optional, as = "Option<_>"))]
51    #[serde(default)]
52    pub headers: BTreeMap<String, String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub body: Option<FileContent>,
55}
56
57fn default_http_method() -> String {
58    "GET".to_string()
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct HttpResponse {
63    pub status: u16,
64    #[serde(rename = "statusText")]
65    pub status_text: String,
66    pub headers: Vec<(String, String)>,
67    pub body: Vec<u8>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct HttpStreamHead {
73    pub stream_id: String,
74    pub status: u16,
75    pub status_text: String,
76    pub headers: Vec<(String, String)>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct HttpStreamChunk {
81    pub body: Vec<u8>,
82    pub done: bool,
83}
84
85#[derive(Debug, Deserialize)]
86#[serde(rename_all = "camelCase")]
87struct VmFetchStreamHeadPayload {
88    stream_id: String,
89    status: u16,
90    #[serde(default)]
91    status_text: Option<String>,
92    #[serde(default)]
93    headers: Option<Vec<(String, String)>>,
94}
95
96#[derive(Debug, Deserialize)]
97struct VmFetchStreamChunkPayload {
98    body: String,
99    done: bool,
100}
101
102struct PreparedHttpRequest {
103    port: u16,
104    path: String,
105    method: String,
106    headers_json: String,
107    body_base64: Option<String>,
108}
109
110impl AgentOs {
111    /// Fetch from a guest server listening on `port` inside the VM.
112    ///
113    /// `path` is derived from the request URI's `pathname`+`search`; the host is ignored. The body
114    /// is only sent for methods other than GET/HEAD. The response body is base64-decoded.
115    pub async fn http_request(&self, request: HttpRequest) -> Result<HttpResponse> {
116        let buffer_limit = self.fetch_buffer_limit();
117        let request = prepare_http_request(request, buffer_limit)?;
118        let response_json = self
119            .vm_fetch_response(wire::VmFetchRequest {
120                port: request.port,
121                method: request.method,
122                path: request.path,
123                headers_json: request.headers_json,
124                body: None,
125                body_base64: request.body_base64,
126                stream_operation: None,
127                stream_id: None,
128                max_bytes: None,
129            })
130            .await?;
131        ensure_fetch_component_within_limit(
132            "fetch response json",
133            response_json.len(),
134            buffer_limit,
135        )?;
136
137        let payload: VmFetchResponsePayload =
138            serde_json::from_str(&response_json).context("parsing vm_fetch response json")?;
139        if !(100..=599).contains(&payload.status) {
140            return Err(ClientError::Sidecar(format!(
141                "HTTP response has invalid status {}",
142                payload.status
143            ))
144            .into());
145        }
146
147        // Base64-decode the response body (TS `Buffer.from(body ?? "", "base64")`). An absent body is
148        // an empty body.
149        let decoded_body = match payload.body {
150            Some(encoded) => {
151                ensure_fetch_base64_body_within_limit(&encoded, buffer_limit)?;
152                BASE64
153                    .decode(encoded.as_bytes())
154                    .context("decoding base64 fetch response body")?
155            }
156            None => Vec::new(),
157        };
158        Ok(HttpResponse {
159            status: payload.status,
160            status_text: payload.status_text.unwrap_or_default(),
161            headers: payload.headers.unwrap_or_default(),
162            body: decoded_body,
163        })
164    }
165
166    /// Starts a bounded streaming request to a guest HTTP listener.
167    pub async fn http_request_stream_start(&self, request: HttpRequest) -> Result<HttpStreamHead> {
168        let buffer_limit = self.fetch_buffer_limit();
169        let request = prepare_http_request(request, buffer_limit)?;
170        let response_json = self
171            .vm_fetch_response(wire::VmFetchRequest {
172                port: request.port,
173                method: request.method,
174                path: request.path,
175                headers_json: request.headers_json,
176                body: None,
177                body_base64: request.body_base64,
178                stream_operation: Some(String::from("start")),
179                stream_id: None,
180                max_bytes: None,
181            })
182            .await?;
183        ensure_fetch_component_within_limit(
184            "fetch stream response head",
185            response_json.len(),
186            buffer_limit,
187        )?;
188        let payload: VmFetchStreamHeadPayload =
189            serde_json::from_str(&response_json).context("parsing vm_fetch stream head")?;
190        if !(100..=599).contains(&payload.status) {
191            return Err(ClientError::Sidecar(format!(
192                "HTTP stream response has invalid status {}",
193                payload.status
194            ))
195            .into());
196        }
197        Ok(HttpStreamHead {
198            stream_id: payload.stream_id,
199            status: payload.status,
200            status_text: payload.status_text.unwrap_or_default(),
201            headers: payload.headers.unwrap_or_default(),
202        })
203    }
204
205    /// Reads at most `max_bytes` from an existing guest HTTP response stream.
206    pub async fn http_request_stream_read(
207        &self,
208        stream_id: &str,
209        max_bytes: u32,
210    ) -> Result<HttpStreamChunk> {
211        if stream_id.is_empty() {
212            return Err(
213                ClientError::Sidecar(String::from("fetch stream id cannot be empty")).into(),
214            );
215        }
216        if max_bytes == 0 {
217            return Err(ClientError::Sidecar(String::from(
218                "fetch stream max_bytes must be greater than zero",
219            ))
220            .into());
221        }
222        let response_json = self
223            .vm_fetch_response(wire::VmFetchRequest {
224                port: 0,
225                method: String::from("GET"),
226                path: String::from("/"),
227                headers_json: String::from("{}"),
228                body: None,
229                body_base64: None,
230                stream_operation: Some(String::from("read")),
231                stream_id: Some(stream_id.to_owned()),
232                max_bytes: Some(max_bytes),
233            })
234            .await?;
235        let payload: VmFetchStreamChunkPayload =
236            serde_json::from_str(&response_json).context("parsing vm_fetch stream chunk")?;
237        ensure_fetch_base64_body_within_limit(
238            &payload.body,
239            usize::try_from(max_bytes).unwrap_or(usize::MAX),
240        )?;
241        let body = BASE64
242            .decode(payload.body.as_bytes())
243            .context("decoding vm_fetch stream chunk")?;
244        Ok(HttpStreamChunk {
245            body,
246            done: payload.done,
247        })
248    }
249
250    /// Cancels an existing guest HTTP response stream.
251    pub async fn http_request_stream_cancel(&self, stream_id: &str) -> Result<()> {
252        if stream_id.is_empty() {
253            return Err(
254                ClientError::Sidecar(String::from("fetch stream id cannot be empty")).into(),
255            );
256        }
257        let response_json = self
258            .vm_fetch_response(wire::VmFetchRequest {
259                port: 0,
260                method: String::from("GET"),
261                path: String::from("/"),
262                headers_json: String::from("{}"),
263                body: None,
264                body_base64: None,
265                stream_operation: Some(String::from("cancel")),
266                stream_id: Some(stream_id.to_owned()),
267                max_bytes: None,
268            })
269            .await?;
270        let cancelled = serde_json::from_str::<serde_json::Value>(&response_json)
271            .context("parsing vm_fetch stream cancellation")?
272            .get("cancelled")
273            .and_then(serde_json::Value::as_bool)
274            .unwrap_or(false);
275        if !cancelled {
276            return Err(ClientError::Sidecar(String::from(
277                "fetch stream cancellation was not acknowledged",
278            ))
279            .into());
280        }
281        Ok(())
282    }
283
284    async fn vm_fetch_response(&self, payload: wire::VmFetchRequest) -> Result<String> {
285        let response = self
286            .transport()
287            .request_wire_bounded(
288                self.vm_fetch_ownership(),
289                wire::RequestPayload::VmFetchRequest(payload),
290                self.fetch_buffer_limit(),
291            )
292            .await?;
293        match response {
294            wire::ResponsePayload::VmFetchResponse(result) => Ok(result.response_json),
295            wire::ResponsePayload::RejectedResponse(rejected) => {
296                Err(ClientError::from_rejection(rejected).into())
297            }
298            other => {
299                Err(ClientError::Sidecar(format!("fetch: unexpected response {other:?}")).into())
300            }
301        }
302    }
303
304    /// The VM-scoped ownership used for the `VmFetch` wire request.
305    fn vm_fetch_ownership(&self) -> wire::OwnershipScope {
306        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
307            connection_id: self.connection_id().to_string(),
308            session_id: self.wire_session_id().to_string(),
309            vm_id: self.vm_id().to_string(),
310        })
311    }
312
313    fn fetch_buffer_limit(&self) -> usize {
314        self.transport()
315            .max_frame_bytes()
316            .min(VM_FETCH_BUFFER_LIMIT_BYTES)
317    }
318}
319
320fn prepare_http_request(request: HttpRequest, buffer_limit: usize) -> Result<PreparedHttpRequest> {
321    let HttpRequest {
322        port,
323        path,
324        method,
325        headers,
326        body,
327    } = request;
328    if port == 0 {
329        return Err(ClientError::Sidecar(String::from(
330            "HTTP request port must be greater than zero",
331        ))
332        .into());
333    }
334    if !path.starts_with('/') {
335        return Err(
336            ClientError::Sidecar(format!("HTTP request path must be absolute: {path}")).into(),
337        );
338    }
339    ensure_fetch_component_within_limit("HTTP request path", path.len(), buffer_limit)?;
340    let method = method.to_uppercase();
341    if method.is_empty() {
342        return Err(
343            ClientError::Sidecar(String::from("HTTP request method cannot be empty")).into(),
344        );
345    }
346    let raw_header_bytes = headers.iter().fold(0usize, |size, (name, value)| {
347        size.saturating_add(name.len()).saturating_add(value.len())
348    });
349    ensure_fetch_component_within_limit("fetch request headers", raw_header_bytes, buffer_limit)?;
350    let headers_json =
351        serde_json::to_string(&headers).context("serializing fetch request headers")?;
352    ensure_fetch_component_within_limit(
353        "fetch request headers json",
354        headers_json.len(),
355        buffer_limit,
356    )?;
357    let body_base64 = if method == "GET" || method == "HEAD" {
358        None
359    } else {
360        body.map(|body| BASE64.encode(body.into_bytes()))
361    };
362    if let Some(body) = &body_base64 {
363        ensure_fetch_component_within_limit("HTTP request body base64", body.len(), buffer_limit)?;
364    }
365    ensure_fetch_request_payload_within_limit(
366        &method,
367        &path,
368        &headers_json,
369        body_base64.as_deref(),
370        buffer_limit,
371    )?;
372    Ok(PreparedHttpRequest {
373        port,
374        path,
375        method,
376        headers_json,
377        body_base64,
378    })
379}
380
381fn ensure_fetch_component_within_limit(
382    component: &str,
383    size: usize,
384    limit: usize,
385) -> Result<(), ClientError> {
386    if size > limit {
387        return Err(ClientError::Sidecar(format!(
388            "{component} is {size} bytes, limit is {limit}"
389        )));
390    }
391    Ok(())
392}
393
394fn ensure_fetch_base64_body_within_limit(encoded: &str, limit: usize) -> Result<(), ClientError> {
395    ensure_fetch_component_within_limit("fetch response body base64", encoded.len(), limit)?;
396    ensure_fetch_component_within_limit(
397        "fetch response body",
398        base64_decoded_upper_bound(encoded.len()),
399        limit,
400    )
401}
402
403fn ensure_fetch_request_payload_within_limit(
404    method: &str,
405    path: &str,
406    headers_json: &str,
407    body: Option<&str>,
408    limit: usize,
409) -> Result<(), ClientError> {
410    let size = method
411        .len()
412        .saturating_add(path.len())
413        .saturating_add(headers_json.len())
414        .saturating_add(body.map(str::len).unwrap_or_default());
415    ensure_fetch_component_within_limit("fetch request payload", size, limit)
416}
417
418fn base64_decoded_upper_bound(encoded_len: usize) -> usize {
419    encoded_len.saturating_add(3) / 4 * 3
420}
421
422#[cfg(test)]
423mod tests {
424    use super::{
425        base64_decoded_upper_bound, ensure_fetch_base64_body_within_limit,
426        ensure_fetch_component_within_limit, ensure_fetch_request_payload_within_limit,
427        prepare_http_request, HttpRequest, VmFetchResponsePayload, BASE64,
428        VM_FETCH_BUFFER_LIMIT_BYTES,
429    };
430    use std::collections::BTreeMap;
431
432    #[test]
433    fn fetch_component_limit_rejects_oversized_buffers() {
434        assert!(ensure_fetch_component_within_limit("component", 8, 8).is_ok());
435
436        let error =
437            ensure_fetch_component_within_limit("component", 9, 8).expect_err("limit violation");
438        assert!(
439            error.to_string().contains("component is 9 bytes"),
440            "unexpected error: {error}"
441        );
442    }
443
444    #[test]
445    fn fetch_component_limit_rejects_expanded_request_text() {
446        let replacement = String::from_utf8_lossy(&[0xff]).into_owned();
447        assert_eq!(replacement.len(), 3);
448
449        let error = ensure_fetch_component_within_limit("fetch request body text", 3, 2)
450            .expect_err("expanded body text should exceed limit");
451        assert!(
452            error
453                .to_string()
454                .contains("fetch request body text is 3 bytes"),
455            "unexpected error: {error}"
456        );
457    }
458
459    #[test]
460    fn fetch_request_payload_limit_rejects_aggregate_oversize() {
461        let error =
462            ensure_fetch_request_payload_within_limit("POST", "/abc", "{}", Some("body"), 8)
463                .expect_err("aggregate request payload should exceed limit");
464        assert!(
465            error
466                .to_string()
467                .contains("fetch request payload is 14 bytes"),
468            "unexpected error: {error}"
469        );
470    }
471
472    #[test]
473    fn fetch_base64_guard_bounds_decoded_response_size() {
474        assert_eq!(base64_decoded_upper_bound(4), 3);
475        assert!(ensure_fetch_base64_body_within_limit("AAAA", 4).is_ok());
476
477        let error = ensure_fetch_base64_body_within_limit("AAAA", 2)
478            .expect_err("encoded body should exceed limit");
479        assert!(
480            error
481                .to_string()
482                .contains("fetch response body base64 is 4 bytes"),
483            "unexpected error: {error}"
484        );
485    }
486
487    #[test]
488    fn fetch_buffer_limit_is_fixed_to_default_frame_size() {
489        assert_eq!(
490            VM_FETCH_BUFFER_LIMIT_BYTES,
491            agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES
492        );
493    }
494
495    #[test]
496    fn fetch_preserves_binary_request_bodies() {
497        let prepared = prepare_http_request(
498            HttpRequest {
499                port: 3000,
500                path: String::from("/upload"),
501                method: String::from("POST"),
502                headers: BTreeMap::new(),
503                body: Some(vec![0xff, 0x00, 0x80].into()),
504            },
505            VM_FETCH_BUFFER_LIMIT_BYTES,
506        )
507        .expect("prepare request");
508        assert_eq!(prepared.body_base64.as_deref(), Some("/wCA"));
509    }
510
511    #[test]
512    fn fetch_response_keeps_repeated_headers() {
513        let payload: VmFetchResponsePayload = serde_json::from_str(
514            r#"{"status":200,"headers":[["set-cookie","a=1"],["set-cookie","b=2"]]}"#,
515        )
516        .expect("parse repeated response headers");
517        assert_eq!(payload.headers.expect("headers").len(), 2);
518    }
519
520    // ── Security: AOSCLIENT-P3-fetch (N-010 guest-server VmFetch response) ───────────────────────
521    //
522    // Threat: a guest server controls the `VmFetch` RESPONSE JSON that the client parses. A hostile
523    // server returns an out-of-range status (70000 / 0), a malformed base64 body ("!!!"), or an
524    // over-limit base64 body. Each must be handled as a clean `Err` on the client — never a panic
525    // (a panic in the shared host process is cross-tenant DoS, F.4). This is a regression guard for
526    // the parse path in `AgentOs::fetch`: `serde_json::from_str` -> `StatusCode::from_u16` ->
527    // `ensure_fetch_base64_body_within_limit` -> `BASE64.decode`.
528    use base64::Engine as _;
529
530    /// A status that overflows u16 (70000) must fail JSON deserialization of the response payload,
531    /// not panic. `status` is typed `u16`, so serde rejects the out-of-range value.
532    #[test]
533    fn vm_fetch_response_overflowing_status_fails_deserialization_without_panic() {
534        let json = r#"{"status":70000}"#;
535        let parsed: Result<VmFetchResponsePayload, _> = serde_json::from_str(json);
536        assert!(
537            parsed.is_err(),
538            "AOSCLIENT-P3-fetch: status 70000 overflows u16 and must fail to deserialize, not panic"
539        );
540    }
541
542    /// A status of 0 deserializes (it is a valid u16) but must be rejected by
543    /// `http::StatusCode::from_u16`, mirroring the `fetch` status construction, without panic.
544    #[test]
545    fn vm_fetch_response_zero_status_is_rejected_by_status_code_without_panic() {
546        let json = r#"{"status":0}"#;
547        let payload: VmFetchResponsePayload =
548            serde_json::from_str(json).expect("status 0 is a valid u16 and should deserialize");
549        let status = http::StatusCode::from_u16(payload.status);
550        assert!(
551            status.is_err(),
552            "AOSCLIENT-P3-fetch: status code 0 must be rejected by StatusCode::from_u16, not panic"
553        );
554    }
555
556    /// A malformed base64 body ("!!!") must produce a decode `Err`, never a panic.
557    #[test]
558    fn vm_fetch_response_malformed_base64_body_errors_without_panic() {
559        // First the size guard passes for a tiny body, so we reach the decode step the way
560        // `fetch` does.
561        ensure_fetch_base64_body_within_limit("!!!", VM_FETCH_BUFFER_LIMIT_BYTES)
562            .expect("tiny body is within the limit");
563        let decoded = BASE64.decode("!!!".as_bytes());
564        assert!(
565            decoded.is_err(),
566            "AOSCLIENT-P3-fetch: malformed base64 response body \"!!!\" must error on decode, not panic"
567        );
568    }
569
570    /// An over-limit base64 body must be rejected by the size guard BEFORE any allocation/decode,
571    /// without panic.
572    #[test]
573    fn vm_fetch_response_over_limit_base64_body_is_rejected_before_decode() {
574        // An encoded length strictly greater than the limit trips the guard on the encoded size.
575        let oversized = "A".repeat(VM_FETCH_BUFFER_LIMIT_BYTES + 4);
576        let result = ensure_fetch_base64_body_within_limit(&oversized, VM_FETCH_BUFFER_LIMIT_BYTES);
577        let error = result.expect_err(
578            "AOSCLIENT-P3-fetch: an over-limit base64 body must be rejected before decode",
579        );
580        assert!(
581            error.to_string().contains("fetch response body base64"),
582            "AOSCLIENT-P3-fetch: over-limit base64 body must be rejected by the size guard, got: {error}"
583        );
584    }
585}