Skip to main content

agent_first_http/shared/
afdata.rs

1//! AFDATA protocol adapter.
2//!
3//! Every `afhttp` command emits one AFDATA protocol-v1 event per invocation:
4//! a single-line `{"kind":"result","result":...}` or `{"kind":"error",
5//! "error":...}` JSON value followed by a newline.
6
7use serde::Serialize;
8use serde::de::DeserializeOwned;
9use std::io::Write;
10use std::sync::OnceLock;
11
12use crate::shared::error::Error;
13
14static OUTPUT_TO: OnceLock<agent_first_data::OutputTo> = OnceLock::new();
15
16/// Install the process-wide AFDATA stream selector for this run.
17///
18/// Which selector that is comes from the resolved invocation's output plan —
19/// the CLI registry has already decided which destinations the matched shape
20/// admits and which one applies — so nothing here re-reads argv.
21pub fn install_output_to(selector: agent_first_data::OutputTo) -> Result<(), Error> {
22    OUTPUT_TO.set(selector).map_err(|_| {
23        Error::new(
24            crate::shared::error::ErrorCode::InternalError,
25            "AFDATA output routing was initialized more than once",
26        )
27    })
28}
29
30#[must_use]
31pub fn output_to() -> agent_first_data::OutputTo {
32    OUTPUT_TO
33        .get()
34        .copied()
35        .unwrap_or(agent_first_data::OutputTo::Split)
36}
37
38/// Emit a typed result event to `writer` and write it as one line of JSON followed
39/// by a newline. The command-specific `code` remains inside `result`.
40///
41/// Redacts AFDATA `_secret` fields by default and never panics on well-formed
42/// input — but we still funnel through this single seam so `print_stdout` /
43/// `print_stderr` stay clippy-denied at crate level.
44pub fn emit<W: Write, T: Serialize>(writer: &mut W, code: &str, payload: &T) -> Result<(), Error> {
45    emit_inner(writer, code, payload, RedactionMode::Default)
46}
47
48/// Emit a payload without AFDATA redaction. Use only for commands that require
49/// an explicit reveal flag and whose payload contains no unrelated secrets.
50pub fn emit_unredacted<W: Write, T: Serialize>(
51    writer: &mut W,
52    code: &str,
53    payload: &T,
54) -> Result<(), Error> {
55    emit_inner(writer, code, payload, RedactionMode::None)
56}
57
58/// Emit a result through the process-wide `--output-to` route.
59pub fn emit_process<T: Serialize>(code: &str, payload: &T) -> Result<(), Error> {
60    emit_process_inner(code, payload, RedactionMode::Default)
61}
62
63/// Emit a result with redaction disabled through the process-wide route.
64///
65/// This is reserved for explicit reveal commands whose payload contains no
66/// unrelated secrets.
67pub fn emit_process_unredacted<T: Serialize>(code: &str, payload: &T) -> Result<(), Error> {
68    emit_process_inner(code, payload, RedactionMode::None)
69}
70
71/// Emit an otherwise-redacted result while intentionally revealing only the
72/// short-lived takeover capability field. The explicit `panel` and
73/// `fetch --takeover` operations are the only callers.
74pub fn emit_process_revealing_takeover<T: Serialize>(code: &str, payload: &T) -> Result<(), Error> {
75    let value = prepare_revealed_takeover_payload(code, payload)?;
76    emit_process_value(value, RedactionMode::None)
77}
78
79#[derive(Clone, Copy)]
80enum RedactionMode {
81    Default,
82    None,
83}
84
85fn emit_inner<W: Write, T: Serialize>(
86    writer: &mut W,
87    code: &str,
88    payload: &T,
89    redaction: RedactionMode,
90) -> Result<(), Error> {
91    let value = prepare_payload(code, payload)?;
92
93    let options = output_options(redaction);
94    let mut emitter = agent_first_data::CliEmitter::with_options(
95        writer,
96        agent_first_data::OutputFormat::Json,
97        options,
98    )
99    .with_strict_protocol();
100    emitter.emit_result(value).map_err(|err| {
101        Error::new(
102            crate::shared::error::ErrorCode::InternalError,
103            err.to_string(),
104        )
105    })?;
106    Ok(())
107}
108
109fn emit_process_inner<T: Serialize>(
110    code: &str,
111    payload: &T,
112    redaction: RedactionMode,
113) -> Result<(), Error> {
114    emit_process_value(prepare_payload(code, payload)?, redaction)
115}
116
117fn emit_process_value(value: serde_json::Value, redaction: RedactionMode) -> Result<(), Error> {
118    let mut emitter = agent_first_data::CliEmitter::from_output_to_with(
119        output_to(),
120        agent_first_data::OutputFormat::Json,
121        output_options(redaction),
122    )
123    .with_strict_protocol();
124    emitter.emit_result(value).map_err(|err| {
125        Error::new(
126            crate::shared::error::ErrorCode::InternalError,
127            err.to_string(),
128        )
129    })
130}
131
132fn output_options(redaction: RedactionMode) -> agent_first_data::OutputOptions {
133    match redaction {
134        RedactionMode::Default => agent_first_data::OutputOptions {
135            redaction: agent_first_data::Redactor::new(),
136            style: agent_first_data::PlainStyle::Raw,
137        },
138        RedactionMode::None => agent_first_data::OutputOptions {
139            redaction: agent_first_data::Redactor::new()
140                .policy(agent_first_data::RedactionPolicy::Off),
141            style: agent_first_data::PlainStyle::Raw,
142        },
143    }
144}
145
146fn prepare_payload<T: Serialize>(code: &str, payload: &T) -> Result<serde_json::Value, Error> {
147    let value = serde_json::to_value(payload).map_err(|e| {
148        Error::new(
149            crate::shared::error::ErrorCode::InternalError,
150            format!("AFDATA: failed to serialize payload: {e}"),
151        )
152    })?;
153    wrap_payload(code, value).map(|value| crate::shared::redact::redact_url_fields(&value))
154}
155
156fn prepare_revealed_takeover_payload<T: Serialize>(
157    code: &str,
158    payload: &T,
159) -> Result<serde_json::Value, Error> {
160    let original = prepare_payload(code, payload)?;
161    let mut redacted = agent_first_data::Redactor::new().value(&original);
162    restore_named_field(&original, &mut redacted, "takeover_url_secret");
163    Ok(redacted)
164}
165
166fn restore_named_field(
167    original: &serde_json::Value,
168    redacted: &mut serde_json::Value,
169    field_name: &str,
170) {
171    match (original, redacted) {
172        (serde_json::Value::Object(original), serde_json::Value::Object(redacted)) => {
173            for (key, original_value) in original {
174                let Some(redacted_value) = redacted.get_mut(key) else {
175                    continue;
176                };
177                if key == field_name {
178                    *redacted_value = original_value.clone();
179                } else {
180                    restore_named_field(original_value, redacted_value, field_name);
181                }
182            }
183        }
184        (serde_json::Value::Array(original), serde_json::Value::Array(redacted)) => {
185            for (original, redacted) in original.iter().zip(redacted.iter_mut()) {
186                restore_named_field(original, redacted, field_name);
187            }
188        }
189        _ => {}
190    }
191}
192
193fn wrap_payload(code: &str, value: serde_json::Value) -> Result<serde_json::Value, Error> {
194    let serde_json::Value::Object(mut map) = value else {
195        return Err(Error::new(
196            crate::shared::error::ErrorCode::InternalError,
197            "AFDATA result payload must serialize to a JSON object",
198        ));
199    };
200
201    map.insert("code".into(), serde_json::Value::String(code.to_string()));
202    Ok(serde_json::Value::Object(map))
203}
204
205/// Convenience: emit an SDK-built AFDATA error event.
206pub fn emit_error<W: Write>(writer: &mut W, err: &Error) -> Result<(), Error> {
207    let mut emitter = agent_first_data::CliEmitter::with_options(
208        writer,
209        agent_first_data::OutputFormat::Json,
210        output_options(RedactionMode::Default),
211    )
212    .with_strict_protocol();
213    let event = agent_first_data::json_error(err.error_code.as_str(), &err.detail)
214        .retryable_if(err.retryable)
215        .build()
216        .map_err(|err| {
217            Error::new(
218                crate::shared::error::ErrorCode::InternalError,
219                err.to_string(),
220            )
221        })?;
222    emitter.emit(event).map_err(|emit_err| {
223        Error::new(
224            crate::shared::error::ErrorCode::InternalError,
225            emit_err.to_string(),
226        )
227    })
228}
229
230/// Emit an SDK-built AFDATA error event through `--output-to`.
231pub fn emit_process_error(err: &Error) -> Result<(), Error> {
232    let event = agent_first_data::json_error(err.error_code.as_str(), &err.detail)
233        .retryable_if(err.retryable)
234        .build()
235        .map_err(|error| {
236            Error::new(
237                crate::shared::error::ErrorCode::InternalError,
238                error.to_string(),
239            )
240        })?;
241    let mut emitter = agent_first_data::CliEmitter::from_output_to_with(
242        output_to(),
243        agent_first_data::OutputFormat::Json,
244        output_options(RedactionMode::Default),
245    )
246    .with_strict_protocol();
247    emitter.emit(event).map_err(|error| {
248        Error::new(
249            crate::shared::error::ErrorCode::InternalError,
250            error.to_string(),
251        )
252    })
253}
254
255/// Emit an error event with caller-owned extension fields and trace.
256pub fn emit_error_with<W: Write>(
257    writer: &mut W,
258    code: &str,
259    message: &str,
260    fields: serde_json::Value,
261    trace: serde_json::Value,
262) -> Result<(), Error> {
263    let mut emitter = agent_first_data::CliEmitter::with_options(
264        writer,
265        agent_first_data::OutputFormat::Json,
266        output_options(RedactionMode::Default),
267    )
268    .with_strict_protocol();
269    let retryable = fields
270        .get("retryable")
271        .and_then(serde_json::Value::as_bool)
272        .unwrap_or(false);
273    let fields = crate::shared::redact::redact_url_fields(&fields);
274    let trace = crate::shared::redact::redact_url_fields(&trace);
275    let fields = match fields {
276        serde_json::Value::Object(mut fields) => {
277            fields.remove("retryable");
278            serde_json::Value::Object(fields)
279        }
280        other => other,
281    };
282    let event = agent_first_data::json_error(code, message)
283        .retryable_if(retryable)
284        .fields(fields)
285        .trace(trace)
286        .build()
287        .map_err(|err| {
288            Error::new(
289                crate::shared::error::ErrorCode::InternalError,
290                err.to_string(),
291            )
292        })?;
293    emitter.emit(event).map_err(|err| {
294        Error::new(
295            crate::shared::error::ErrorCode::InternalError,
296            err.to_string(),
297        )
298    })
299}
300
301/// Emit an error with extension fields and trace through `--output-to`.
302pub fn emit_process_error_with(
303    code: &str,
304    message: &str,
305    fields: serde_json::Value,
306    trace: serde_json::Value,
307) -> Result<(), Error> {
308    let retryable = fields
309        .get("retryable")
310        .and_then(serde_json::Value::as_bool)
311        .unwrap_or(false);
312    let fields = crate::shared::redact::redact_url_fields(&fields);
313    let trace = crate::shared::redact::redact_url_fields(&trace);
314    let fields = match fields {
315        serde_json::Value::Object(mut fields) => {
316            fields.remove("retryable");
317            serde_json::Value::Object(fields)
318        }
319        other => other,
320    };
321    let event = agent_first_data::json_error(code, message)
322        .retryable_if(retryable)
323        .fields(fields)
324        .trace(trace)
325        .build()
326        .map_err(|error| {
327            Error::new(
328                crate::shared::error::ErrorCode::InternalError,
329                error.to_string(),
330            )
331        })?;
332    let mut emitter = agent_first_data::CliEmitter::from_output_to_with(
333        output_to(),
334        agent_first_data::OutputFormat::Json,
335        output_options(RedactionMode::Default),
336    )
337    .with_strict_protocol();
338    emitter.emit(event).map_err(|error| {
339        Error::new(
340            crate::shared::error::ErrorCode::InternalError,
341            error.to_string(),
342        )
343    })
344}
345
346/// Build a strict AFDATA error event for HTTP response bodies.
347pub fn error_value(code: &str, message: &str, retryable: bool) -> serde_json::Value {
348    let value: serde_json::Value = agent_first_data::json_error(code, message)
349        .retryable_if(retryable)
350        .build()
351        .map(Into::into)
352        .unwrap_or_else(|_| serde_json::json!({}));
353    crate::shared::redact::redact_value(&value)
354}
355
356/// Build a strict AFDATA result event for HTTP response bodies.
357pub fn result_value(code: &str, mut payload: serde_json::Value) -> serde_json::Value {
358    let payload = match &mut payload {
359        serde_json::Value::Object(fields) => {
360            fields
361                .entry("code".to_string())
362                .or_insert_with(|| serde_json::Value::String(code.to_string()));
363            payload
364        }
365        _ => serde_json::json!({"code": code, "value": payload}),
366    };
367    let value: serde_json::Value = agent_first_data::json_result(payload).build().into();
368    crate::shared::redact::redact_value(&value)
369}
370
371/// Build a strict HTTP result while intentionally revealing only
372/// `takeover_url_secret`. Used by the authenticated handoff-minting endpoint.
373pub fn result_value_revealing_takeover(
374    code: &str,
375    mut payload: serde_json::Value,
376) -> serde_json::Value {
377    let payload = match &mut payload {
378        serde_json::Value::Object(fields) => {
379            fields
380                .entry("code".to_string())
381                .or_insert_with(|| serde_json::Value::String(code.to_string()));
382            payload
383        }
384        _ => serde_json::json!({"code": code, "value": payload}),
385    };
386    let original: serde_json::Value = agent_first_data::json_result(payload).build().into();
387    let original = crate::shared::redact::redact_url_fields(&original);
388    let mut redacted = agent_first_data::Redactor::new().value(&original);
389    restore_named_field(&original, &mut redacted, "takeover_url_secret");
390    redacted
391}
392
393pub fn decode_result<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, Error> {
394    let text = std::str::from_utf8(bytes).map_err(|error| {
395        Error::new(
396            crate::shared::error::ErrorCode::InternalError,
397            format!("decode AFDATA event: {error}"),
398        )
399    })?;
400    match agent_first_data::decode_protocol_event(text) {
401        Ok(agent_first_data::DecodedEvent::Result(result)) => serde_json::from_value(result.result)
402            .map_err(|error| {
403                Error::new(
404                    crate::shared::error::ErrorCode::InternalError,
405                    format!("decode AFDATA result payload: {error}"),
406                )
407            }),
408        Ok(_) => Err(Error::new(
409            crate::shared::error::ErrorCode::InternalError,
410            "expected AFDATA result event",
411        )),
412        Err(error) => Err(Error::new(
413            crate::shared::error::ErrorCode::InternalError,
414            format!("invalid AFDATA event: {error}"),
415        )),
416    }
417}
418
419pub fn decode_error(bytes: &[u8]) -> Result<Error, Error> {
420    let text = std::str::from_utf8(bytes).map_err(|error| {
421        Error::new(
422            crate::shared::error::ErrorCode::InternalError,
423            format!("decode AFDATA error event: {error}"),
424        )
425    })?;
426    match agent_first_data::decode_protocol_event(text) {
427        Ok(agent_first_data::DecodedEvent::Error(error)) => {
428            let code = serde_json::from_value(serde_json::Value::String(error.code))
429                .unwrap_or(crate::shared::error::ErrorCode::InternalError);
430            Ok(Error::new(code, error.message).with_retryable(error.retryable))
431        }
432        Ok(_) => Err(Error::new(
433            crate::shared::error::ErrorCode::InternalError,
434            "expected AFDATA error event",
435        )),
436        Err(error) => Err(Error::new(
437            crate::shared::error::ErrorCode::InternalError,
438            format!("invalid AFDATA error event: {error}"),
439        )),
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[derive(Serialize)]
448    struct HealthPayload {
449        status: &'static str,
450        uptime_s: u64,
451    }
452
453    #[test]
454    fn json_result_event_is_single_line_with_code_field() {
455        let mut buf = Vec::new();
456        let payload = HealthPayload {
457            status: "ok",
458            uptime_s: 42,
459        };
460        emit(&mut buf, "health", &payload).unwrap();
461        let s = String::from_utf8(buf).unwrap_or_default();
462        assert!(s.ends_with('\n'));
463        let trimmed = s.trim_end();
464        let parsed: serde_json::Value = serde_json::from_str(trimmed).unwrap();
465        assert_eq!(parsed["kind"], "result");
466        assert_eq!(parsed["result"]["code"], "health");
467        assert_eq!(parsed["result"]["status"], "ok");
468        assert_eq!(parsed["result"]["uptime_s"], 42);
469        assert_eq!(trimmed.lines().count(), 1);
470    }
471
472    #[test]
473    fn error_event_uses_error_code_tag() {
474        let mut buf = Vec::new();
475        let err = Error::new(
476            crate::shared::error::ErrorCode::NavigationTimeout,
477            "no load",
478        );
479        emit_error(&mut buf, &err).unwrap();
480        let parsed: serde_json::Value =
481            serde_json::from_slice(&buf).unwrap_or(serde_json::Value::Null);
482        assert_eq!(parsed["kind"], "error");
483        assert_eq!(parsed["error"]["code"], "navigation_timeout");
484        assert_eq!(parsed["error"]["message"], "no load");
485        assert_eq!(parsed["error"]["retryable"], true);
486    }
487
488    #[test]
489    fn error_extension_fields_are_flattened_into_error_payload() {
490        let mut buf = Vec::new();
491        emit_error_with(
492            &mut buf,
493            "navigation_timeout",
494            "no load",
495            serde_json::json!({
496                "retryable": true,
497                "stage": "capture_text",
498                "details": "scalar detail remains an explicitly named field"
499            }),
500            serde_json::json!({"duration_ms": 10}),
501        )
502        .unwrap();
503        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
504        assert_eq!(parsed["error"]["stage"], "capture_text");
505        assert_eq!(parsed["error"]["retryable"], true);
506        assert_eq!(
507            parsed["error"]["details"],
508            "scalar detail remains an explicitly named field"
509        );
510        assert!(parsed["error"].get("fields").is_none());
511    }
512
513    #[derive(Serialize)]
514    struct SecretPayload {
515        token_secret: &'static str,
516    }
517
518    #[test]
519    fn afdata_event_redacts_secret_fields() {
520        let mut buf = Vec::new();
521        emit(
522            &mut buf,
523            "container_status",
524            &SecretPayload {
525                token_secret: "supersecret",
526            },
527        )
528        .unwrap();
529        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
530        assert_eq!(parsed["result"]["token_secret"], "***");
531    }
532
533    #[test]
534    fn http_result_redacts_common_url_query_credentials() {
535        let value = result_value(
536            "fetch",
537            serde_json::json!({
538                "request_url": "https://user:pass@example.test/?token=abc&safe=ok"
539            }),
540        );
541        assert_eq!(
542            value["result"]["request_url"],
543            "https://user:***@example.test/?token=***&safe=ok"
544        );
545    }
546
547    #[test]
548    fn takeover_result_reveals_only_the_explicit_capability() {
549        let value = result_value_revealing_takeover(
550            "takeover_handoff",
551            serde_json::json!({
552                "takeover_url_secret":
553                    "https://example.test/takeover?handoff_secret=short-lived",
554                "host_token_secret": "long-lived",
555                "request_url": "https://example.test/?token=also-secret"
556            }),
557        );
558        assert_eq!(
559            value["result"]["takeover_url_secret"],
560            "https://example.test/takeover?handoff_secret=short-lived"
561        );
562        assert_eq!(value["result"]["host_token_secret"], "***");
563        assert_eq!(
564            value["result"]["request_url"],
565            "https://example.test/?token=***"
566        );
567    }
568
569    #[test]
570    fn envelope_uses_sdk_result_payload_without_nested_envelope() {
571        let mut buf = Vec::new();
572        let payload = serde_json::json!({"code": -32000, "message": "cdp error"});
573        emit(&mut buf, "cdp", &payload).unwrap();
574        let parsed: serde_json::Value = serde_json::from_slice(&buf).unwrap();
575        assert_eq!(parsed["kind"], "result");
576        assert_eq!(parsed["result"]["code"], "cdp");
577        assert_eq!(parsed["result"]["code"], "cdp");
578        assert_eq!(parsed["result"]["message"], "cdp error");
579        assert!(parsed["result"].get("result").is_none());
580    }
581}