Skip to main content

agent_first_http/shared/
envelope.rs

1//! Protocol envelope writer.
2//!
3//! Every `afhttp` command emits one structured value per invocation: a
4//! single line of AFDATA JSON, wrapped with a top-level `code` field
5//! (`"fetch"`, `"health"`, `"error"`, etc.) and a trailing newline. JSON is
6//! the only output format — one request in, one line of structured JSON out.
7
8use serde::Serialize;
9use std::io::Write;
10
11use crate::shared::error::Error;
12
13/// Emit a single envelope payload to `writer`. The payload is wrapped with
14/// a top-level `code` field and written as one line of JSON followed by a
15/// newline.
16///
17/// Redacts AFDATA `_secret` fields by default and never panics on well-formed
18/// input — but we still funnel through this single seam so `print_stdout` /
19/// `print_stderr` stay clippy-denied at crate level.
20pub fn emit<W: Write, T: Serialize>(writer: &mut W, code: &str, payload: &T) -> Result<(), Error> {
21    emit_inner(writer, code, payload, RedactionMode::Default)
22}
23
24/// Emit a payload without AFDATA redaction. Use only for commands that require
25/// an explicit reveal flag and whose payload contains no unrelated secrets.
26pub fn emit_unredacted<W: Write, T: Serialize>(
27    writer: &mut W,
28    code: &str,
29    payload: &T,
30) -> Result<(), Error> {
31    emit_inner(writer, code, payload, RedactionMode::None)
32}
33
34#[derive(Clone, Copy)]
35enum RedactionMode {
36    Default,
37    None,
38}
39
40fn emit_inner<W: Write, T: Serialize>(
41    writer: &mut W,
42    code: &str,
43    payload: &T,
44    redaction: RedactionMode,
45) -> Result<(), Error> {
46    let mut value = serde_json::to_value(payload).map_err(|e| {
47        Error::new(
48            crate::shared::error::ErrorCode::InternalError,
49            format!("envelope: failed to serialize payload: {e}"),
50        )
51    })?;
52    value = wrap_payload(code, value)?;
53
54    let line = match redaction {
55        RedactionMode::Default => agent_first_data::output_json(&value),
56        RedactionMode::None => agent_first_data::output_json_with(
57            &value,
58            agent_first_data::RedactionPolicy::RedactionNone,
59        ),
60    };
61    writer.write_all(line.as_bytes())?;
62    writer.write_all(b"\n")?;
63    Ok(())
64}
65
66fn wrap_payload(code: &str, value: serde_json::Value) -> Result<serde_json::Value, Error> {
67    let serde_json::Value::Object(mut map) = value else {
68        return Err(Error::new(
69            crate::shared::error::ErrorCode::InternalError,
70            "envelope: payload must serialize to a JSON object",
71        ));
72    };
73
74    match map.get("code") {
75        Some(serde_json::Value::String(existing)) if existing == code => {}
76        Some(_) => {
77            return Ok(serde_json::json!({
78                "code": code,
79                "result": serde_json::Value::Object(map),
80            }));
81        }
82        None => {
83            map.insert("code".into(), serde_json::Value::String(code.to_string()));
84        }
85    }
86    Ok(serde_json::Value::Object(map))
87}
88
89/// Convenience: emit an `Error` value with `code: "error"`.
90pub fn emit_error<W: Write>(writer: &mut W, err: &Error) -> Result<(), Error> {
91    emit(writer, "error", err)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[derive(Serialize)]
99    struct HealthPayload {
100        status: &'static str,
101        uptime_s: u64,
102    }
103
104    #[test]
105    fn json_envelope_is_single_line_with_code_field() {
106        let mut buf = Vec::new();
107        let payload = HealthPayload {
108            status: "ok",
109            uptime_s: 42,
110        };
111        emit(&mut buf, "health", &payload).unwrap();
112        let s = String::from_utf8(buf).unwrap_or_default();
113        assert!(s.ends_with('\n'));
114        let trimmed = s.trim_end();
115        let parsed: serde_json::Value = serde_json::from_str(trimmed).unwrap();
116        assert_eq!(parsed["code"], "health");
117        assert_eq!(parsed["status"], "ok");
118        assert_eq!(parsed["uptime_s"], 42);
119        assert_eq!(trimmed.lines().count(), 1);
120    }
121
122    #[test]
123    fn error_envelope_uses_error_code_tag() {
124        let mut buf = Vec::new();
125        let err = Error::new(
126            crate::shared::error::ErrorCode::NavigationTimeout,
127            "no load",
128        );
129        emit_error(&mut buf, &err).unwrap();
130        let parsed: serde_json::Value =
131            serde_json::from_slice(&buf).unwrap_or(serde_json::Value::Null);
132        assert_eq!(parsed["code"], "error");
133        assert_eq!(parsed["error_code"], "navigation_timeout");
134        assert_eq!(parsed["error"], "no load");
135        assert_eq!(parsed["retryable"], true);
136    }
137
138    #[derive(Serialize)]
139    struct SecretPayload {
140        token_secret: &'static str,
141    }
142
143    #[test]
144    fn envelope_redacts_afdata_secret_fields() {
145        let mut buf = Vec::new();
146        emit(
147            &mut buf,
148            "container_status",
149            &SecretPayload {
150                token_secret: "supersecret",
151            },
152        )
153        .unwrap();
154        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
155        assert_eq!(parsed["token_secret"], "***");
156    }
157
158    #[test]
159    fn envelope_wraps_payload_when_code_would_collide() {
160        let mut buf = Vec::new();
161        let payload = serde_json::json!({"code": -32000, "message": "cdp error"});
162        emit(&mut buf, "cdp", &payload).unwrap();
163        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
164        assert_eq!(parsed["code"], "cdp");
165        assert_eq!(parsed["result"]["code"], -32000);
166    }
167}