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