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;
19
20/// Maximum fully buffered fetch component size. `VmFetch` is a single request/response frame, so
21/// keeping this at the default frame size prevents fetch-specific buffers from growing just because
22/// a sidecar was configured with a larger transport frame limit for another API.
23const VM_FETCH_BUFFER_LIMIT_BYTES: usize = agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES;
24
25/// The shape of the JSON string returned in [`VmFetchResponse::response_json`], mirroring the TS
26/// `{ status, statusText?, headers?: [k,v][], body?: base64 }` payload.
27#[derive(Debug, Deserialize)]
28struct VmFetchResponsePayload {
29    status: u16,
30    #[serde(rename = "statusText", default)]
31    status_text: Option<String>,
32    #[serde(default)]
33    headers: Option<Vec<(String, String)>>,
34    /// Base64-encoded response body.
35    #[serde(default)]
36    body: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct HttpRequest {
41    pub port: u16,
42    pub path: String,
43    #[serde(default = "default_http_method")]
44    pub method: String,
45    #[serde(default)]
46    pub headers: BTreeMap<String, String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub body: Option<Vec<u8>>,
49}
50
51fn default_http_method() -> String {
52    "GET".to_string()
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct HttpResponse {
57    pub status: u16,
58    #[serde(rename = "statusText")]
59    pub status_text: String,
60    pub headers: BTreeMap<String, String>,
61    pub body: Vec<u8>,
62}
63
64impl AgentOs {
65    /// Fetch from a guest server listening on `port` inside the VM.
66    ///
67    /// `path` is derived from the request URI's `pathname`+`search`; the host is ignored. The body
68    /// is only sent for methods other than GET/HEAD. The response body is base64-decoded.
69    pub async fn http_request(&self, request: HttpRequest) -> Result<HttpResponse> {
70        let buffer_limit = self.fetch_buffer_limit();
71        let HttpRequest {
72            port,
73            path,
74            method,
75            headers: header_map,
76            body,
77        } = request;
78        if !path.starts_with('/') {
79            return Err(ClientError::Sidecar(format!(
80                "HTTP request path must be absolute: {path}"
81            ))
82            .into());
83        }
84        ensure_fetch_component_within_limit("HTTP request path", path.len(), buffer_limit)?;
85        let method = method.to_uppercase();
86        let raw_header_bytes = header_map.iter().fold(0usize, |size, (name, value)| {
87            size.saturating_add(name.len()).saturating_add(value.len())
88        });
89        ensure_fetch_component_within_limit(
90            "fetch request headers",
91            raw_header_bytes,
92            buffer_limit,
93        )?;
94        let headers_json =
95            serde_json::to_string(&header_map).context("serializing fetch request headers")?;
96        ensure_fetch_component_within_limit(
97            "fetch request headers json",
98            headers_json.len(),
99            buffer_limit,
100        )?;
101
102        // Body is only attached for methods other than GET/HEAD (TS `request.method !== "GET" && ...`).
103        let wire_body = if method == "GET" || method == "HEAD" {
104            None
105        } else {
106            body.map(|body| String::from_utf8_lossy(&body).into_owned())
107        };
108        if let Some(body) = &wire_body {
109            ensure_fetch_component_within_limit("HTTP request body", body.len(), buffer_limit)?;
110        }
111        ensure_fetch_request_payload_within_limit(
112            &method,
113            &path,
114            &headers_json,
115            wire_body.as_deref(),
116            buffer_limit,
117        )?;
118
119        let response = self
120            .transport()
121            .request_wire_bounded(
122                self.vm_fetch_ownership(),
123                wire::RequestPayload::VmFetchRequest(wire::VmFetchRequest {
124                    port,
125                    method,
126                    path,
127                    headers_json,
128                    body: wire_body,
129                    body_base64: None,
130                    stream_operation: None,
131                    stream_id: None,
132                    max_bytes: None,
133                }),
134                buffer_limit,
135            )
136            .await?;
137
138        let response_json = match response {
139            wire::ResponsePayload::VmFetchResponse(result) => result.response_json,
140            wire::ResponsePayload::RejectedResponse(rejected) => {
141                return Err(ClientError::from_rejection(rejected).into());
142            }
143            other => {
144                return Err(
145                    ClientError::Sidecar(format!("fetch: unexpected response {other:?}")).into(),
146                );
147            }
148        };
149        ensure_fetch_component_within_limit(
150            "fetch response json",
151            response_json.len(),
152            buffer_limit,
153        )?;
154
155        let payload: VmFetchResponsePayload =
156            serde_json::from_str(&response_json).context("parsing vm_fetch response json")?;
157        if !(100..=599).contains(&payload.status) {
158            return Err(ClientError::Sidecar(format!(
159                "HTTP response has invalid status {}",
160                payload.status
161            ))
162            .into());
163        }
164
165        // Base64-decode the response body (TS `Buffer.from(body ?? "", "base64")`). An absent body is
166        // an empty body.
167        let decoded_body = match payload.body {
168            Some(encoded) => {
169                ensure_fetch_base64_body_within_limit(&encoded, buffer_limit)?;
170                BASE64
171                    .decode(encoded.as_bytes())
172                    .context("decoding base64 fetch response body")?
173            }
174            None => Vec::new(),
175        };
176        Ok(HttpResponse {
177            status: payload.status,
178            status_text: payload.status_text.unwrap_or_default(),
179            headers: payload.headers.unwrap_or_default().into_iter().collect(),
180            body: decoded_body,
181        })
182    }
183
184    /// The VM-scoped ownership used for the `VmFetch` wire request.
185    fn vm_fetch_ownership(&self) -> wire::OwnershipScope {
186        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
187            connection_id: self.connection_id().to_string(),
188            session_id: self.wire_session_id().to_string(),
189            vm_id: self.vm_id().to_string(),
190        })
191    }
192
193    fn fetch_buffer_limit(&self) -> usize {
194        self.transport()
195            .max_frame_bytes()
196            .min(VM_FETCH_BUFFER_LIMIT_BYTES)
197    }
198}
199
200fn ensure_fetch_component_within_limit(
201    component: &str,
202    size: usize,
203    limit: usize,
204) -> Result<(), ClientError> {
205    if size > limit {
206        return Err(ClientError::Sidecar(format!(
207            "{component} is {size} bytes, limit is {limit}"
208        )));
209    }
210    Ok(())
211}
212
213fn ensure_fetch_base64_body_within_limit(encoded: &str, limit: usize) -> Result<(), ClientError> {
214    ensure_fetch_component_within_limit("fetch response body base64", encoded.len(), limit)?;
215    ensure_fetch_component_within_limit(
216        "fetch response body",
217        base64_decoded_upper_bound(encoded.len()),
218        limit,
219    )
220}
221
222fn ensure_fetch_request_payload_within_limit(
223    method: &str,
224    path: &str,
225    headers_json: &str,
226    body: Option<&str>,
227    limit: usize,
228) -> Result<(), ClientError> {
229    let size = method
230        .len()
231        .saturating_add(path.len())
232        .saturating_add(headers_json.len())
233        .saturating_add(body.map(str::len).unwrap_or_default());
234    ensure_fetch_component_within_limit("fetch request payload", size, limit)
235}
236
237fn base64_decoded_upper_bound(encoded_len: usize) -> usize {
238    encoded_len.saturating_add(3) / 4 * 3
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{
244        base64_decoded_upper_bound, ensure_fetch_base64_body_within_limit,
245        ensure_fetch_component_within_limit, ensure_fetch_request_payload_within_limit,
246        VM_FETCH_BUFFER_LIMIT_BYTES,
247    };
248
249    #[test]
250    fn fetch_component_limit_rejects_oversized_buffers() {
251        assert!(ensure_fetch_component_within_limit("component", 8, 8).is_ok());
252
253        let error =
254            ensure_fetch_component_within_limit("component", 9, 8).expect_err("limit violation");
255        assert!(
256            error.to_string().contains("component is 9 bytes"),
257            "unexpected error: {error}"
258        );
259    }
260
261    #[test]
262    fn fetch_component_limit_rejects_expanded_request_text() {
263        let replacement = String::from_utf8_lossy(&[0xff]).into_owned();
264        assert_eq!(replacement.len(), 3);
265
266        let error = ensure_fetch_component_within_limit("fetch request body text", 3, 2)
267            .expect_err("expanded body text should exceed limit");
268        assert!(
269            error
270                .to_string()
271                .contains("fetch request body text is 3 bytes"),
272            "unexpected error: {error}"
273        );
274    }
275
276    #[test]
277    fn fetch_request_payload_limit_rejects_aggregate_oversize() {
278        let error =
279            ensure_fetch_request_payload_within_limit("POST", "/abc", "{}", Some("body"), 8)
280                .expect_err("aggregate request payload should exceed limit");
281        assert!(
282            error
283                .to_string()
284                .contains("fetch request payload is 14 bytes"),
285            "unexpected error: {error}"
286        );
287    }
288
289    #[test]
290    fn fetch_base64_guard_bounds_decoded_response_size() {
291        assert_eq!(base64_decoded_upper_bound(4), 3);
292        assert!(ensure_fetch_base64_body_within_limit("AAAA", 4).is_ok());
293
294        let error = ensure_fetch_base64_body_within_limit("AAAA", 2)
295            .expect_err("encoded body should exceed limit");
296        assert!(
297            error
298                .to_string()
299                .contains("fetch response body base64 is 4 bytes"),
300            "unexpected error: {error}"
301        );
302    }
303
304    #[test]
305    fn fetch_buffer_limit_is_fixed_to_default_frame_size() {
306        assert_eq!(
307            VM_FETCH_BUFFER_LIMIT_BYTES,
308            agentos_sidecar_client::wire::DEFAULT_MAX_FRAME_BYTES
309        );
310    }
311
312    // ── Security: AOSCLIENT-P3-fetch (N-010 guest-server VmFetch response) ───────────────────────
313    //
314    // Threat: a guest server controls the `VmFetch` RESPONSE JSON that the client parses. A hostile
315    // server returns an out-of-range status (70000 / 0), a malformed base64 body ("!!!"), or an
316    // over-limit base64 body. Each must be handled as a clean `Err` on the client — never a panic
317    // (a panic in the shared host process is cross-tenant DoS, F.4). This is a regression guard for
318    // the parse path in `AgentOs::fetch`: `serde_json::from_str` -> `StatusCode::from_u16` ->
319    // `ensure_fetch_base64_body_within_limit` -> `BASE64.decode`.
320    use super::{VmFetchResponsePayload, BASE64};
321    use base64::Engine as _;
322
323    /// A status that overflows u16 (70000) must fail JSON deserialization of the response payload,
324    /// not panic. `status` is typed `u16`, so serde rejects the out-of-range value.
325    #[test]
326    fn vm_fetch_response_overflowing_status_fails_deserialization_without_panic() {
327        let json = r#"{"status":70000}"#;
328        let parsed: Result<VmFetchResponsePayload, _> = serde_json::from_str(json);
329        assert!(
330            parsed.is_err(),
331            "AOSCLIENT-P3-fetch: status 70000 overflows u16 and must fail to deserialize, not panic"
332        );
333    }
334
335    /// A status of 0 deserializes (it is a valid u16) but must be rejected by
336    /// `http::StatusCode::from_u16`, mirroring the `fetch` status construction, without panic.
337    #[test]
338    fn vm_fetch_response_zero_status_is_rejected_by_status_code_without_panic() {
339        let json = r#"{"status":0}"#;
340        let payload: VmFetchResponsePayload =
341            serde_json::from_str(json).expect("status 0 is a valid u16 and should deserialize");
342        let status = http::StatusCode::from_u16(payload.status);
343        assert!(
344            status.is_err(),
345            "AOSCLIENT-P3-fetch: status code 0 must be rejected by StatusCode::from_u16, not panic"
346        );
347    }
348
349    /// A malformed base64 body ("!!!") must produce a decode `Err`, never a panic.
350    #[test]
351    fn vm_fetch_response_malformed_base64_body_errors_without_panic() {
352        // First the size guard passes for a tiny body, so we reach the decode step the way
353        // `fetch` does.
354        ensure_fetch_base64_body_within_limit("!!!", VM_FETCH_BUFFER_LIMIT_BYTES)
355            .expect("tiny body is within the limit");
356        let decoded = BASE64.decode("!!!".as_bytes());
357        assert!(
358            decoded.is_err(),
359            "AOSCLIENT-P3-fetch: malformed base64 response body \"!!!\" must error on decode, not panic"
360        );
361    }
362
363    /// An over-limit base64 body must be rejected by the size guard BEFORE any allocation/decode,
364    /// without panic.
365    #[test]
366    fn vm_fetch_response_over_limit_base64_body_is_rejected_before_decode() {
367        // An encoded length strictly greater than the limit trips the guard on the encoded size.
368        let oversized = "A".repeat(VM_FETCH_BUFFER_LIMIT_BYTES + 4);
369        let result = ensure_fetch_base64_body_within_limit(&oversized, VM_FETCH_BUFFER_LIMIT_BYTES);
370        let error = result.expect_err(
371            "AOSCLIENT-P3-fetch: an over-limit base64 body must be rejected before decode",
372        );
373        assert!(
374            error.to_string().contains("fetch response body base64"),
375            "AOSCLIENT-P3-fetch: over-limit base64 body must be rejected by the size guard, got: {error}"
376        );
377    }
378}