Skip to main content

hypha/api/
mod.rs

1// Agent-First Data output layer. All CLI code emits through Output methods.
2
3use serde::Serialize;
4use std::process::ExitCode;
5use std::sync::{mpsc, Mutex};
6use std::thread::JoinHandle;
7use std::time::Instant;
8
9/// Result type that handles output formatting and exit codes.
10///
11/// A dedicated writer thread owns one [`agent_first_data::CliEmitter`] for the
12/// complete command lifecycle. This keeps [`Output`] usable through the
13/// thread-safe [`crate::EventSink`] trait while preserving AFDATA ordering,
14/// routing, and single-terminal-event enforcement.
15pub struct Output {
16    sender: mpsc::Sender<OutputMessage>,
17    worker: Mutex<Option<JoinHandle<()>>>,
18    started: Instant,
19}
20
21enum OutputMessage {
22    Emit(agent_first_data::Event),
23    Finish {
24        event: agent_first_data::Event,
25        success_code: u8,
26        reply: mpsc::SyncSender<u8>,
27    },
28    Shutdown,
29}
30
31fn built_event(
32    result: Result<agent_first_data::Event, agent_first_data::BuildError>,
33) -> agent_first_data::Event {
34    result.unwrap_or_else(|err| agent_first_data::build_cli_error(&err.to_string(), None))
35}
36
37/// Scrub every scheme-prefixed URL embedded in diagnostic prose.
38///
39/// AFDATA intentionally does not scan arbitrary prose, so Hypha performs this
40/// boundary pass before placing messages into protocol events.
41pub fn redact_urls_in_text(text: &str) -> String {
42    let bytes = text.as_bytes();
43    let mut rendered = String::with_capacity(text.len());
44    let mut copied_until = 0;
45    let mut search_from = 0;
46
47    while let Some(relative) = text[search_from..].find("://") {
48        let separator = search_from + relative;
49        let mut start = separator;
50        while start > 0
51            && bytes[start - 1].is_ascii()
52            && (bytes[start - 1].is_ascii_alphanumeric()
53                || matches!(bytes[start - 1], b'+' | b'-' | b'.'))
54        {
55            start -= 1;
56        }
57        if start == separator || !bytes[start].is_ascii_alphabetic() {
58            search_from = separator + 3;
59            continue;
60        }
61
62        let mut end = separator + 3;
63        while end < bytes.len()
64            && !bytes[end].is_ascii_whitespace()
65            && !matches!(
66                bytes[end],
67                b'"' | b'\'' | b'<' | b'>' | b'{' | b'}' | b'\\' | b'|'
68            )
69        {
70            end += 1;
71        }
72        while end > separator + 3
73            && matches!(
74                bytes[end - 1],
75                b'.' | b',' | b';' | b':' | b'!' | b')' | b']'
76            )
77        {
78            end -= 1;
79        }
80
81        rendered.push_str(&text[copied_until..start]);
82        rendered.push_str(&agent_first_data::redact_url_secrets(&text[start..end]));
83        copied_until = end;
84        search_from = end.max(separator + 3);
85    }
86
87    rendered.push_str(&text[copied_until..]);
88    rendered
89}
90
91/// Redact credential-bearing values in embedded CMN documents without
92/// rewriting their protocol-defined `url` field name.
93///
94/// Hypha-owned URL fields use AFDATA's `_url` suffix and are scrubbed by the
95/// emitter. CMN v1 documents instead define bare `url` fields, so those values
96/// need this schema-preserving adapter before they reach any serializer.
97fn redact_embedded_cmn_urls(value: &mut serde_json::Value) {
98    match value {
99        serde_json::Value::Object(fields) => {
100            for (name, field) in fields {
101                if name == "url" {
102                    if let serde_json::Value::String(raw_url) = field {
103                        *raw_url = agent_first_data::redact_url_secrets(raw_url);
104                    }
105                }
106                redact_embedded_cmn_urls(field);
107            }
108        }
109        serde_json::Value::Array(items) => {
110            for item in items {
111                redact_embedded_cmn_urls(item);
112            }
113        }
114        _ => {}
115    }
116}
117
118/// A Synapse selector accepts either a configured domain or a whole URL. Keep
119/// the selector name honest while scrubbing it whenever the supplied value is
120/// URL-shaped.
121fn redact_url_selectors(value: &mut serde_json::Value) {
122    match value {
123        serde_json::Value::Object(fields) => {
124            for (name, field) in fields {
125                if name == "synapse_selector" {
126                    if let serde_json::Value::String(selector) = field {
127                        *selector = redact_urls_in_text(selector);
128                    }
129                }
130                redact_url_selectors(field);
131            }
132        }
133        serde_json::Value::Array(items) => {
134            for item in items {
135                redact_url_selectors(item);
136            }
137        }
138        _ => {}
139    }
140}
141
142fn redact_boundary_values(value: &mut serde_json::Value) {
143    redact_embedded_cmn_urls(value);
144    redact_url_selectors(value);
145}
146
147impl Output {
148    /// Create a finite one-shot emitter using AFDATA's default split routing.
149    pub fn new(format: agent_first_data::OutputFormat) -> Self {
150        Self::with_output_to(format, agent_first_data::OutputTo::Split)
151    }
152
153    /// Create an emitter using the requested AFDATA destination policy.
154    pub fn with_output_to(
155        format: agent_first_data::OutputFormat,
156        output_to: agent_first_data::OutputTo,
157    ) -> Self {
158        let started = Instant::now();
159        let (sender, receiver) = mpsc::channel();
160        let worker = std::thread::spawn(move || {
161            let mut emitter = agent_first_data::CliEmitter::from_output_to(output_to, format)
162                .with_strict_protocol();
163            while let Ok(message) = receiver.recv() {
164                match message {
165                    OutputMessage::Emit(event) => {
166                        let _ = emitter.emit(event);
167                    }
168                    OutputMessage::Finish {
169                        event,
170                        success_code,
171                        reply,
172                    } => {
173                        let code = emitter.finish(event, success_code);
174                        let _ = reply.send(code);
175                        break;
176                    }
177                    OutputMessage::Shutdown => break,
178                }
179            }
180        });
181        Self {
182            sender,
183            worker: Mutex::new(Some(worker)),
184            started,
185        }
186    }
187
188    fn elapsed_ms(&self) -> u64 {
189        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
190    }
191
192    fn elapsed_trace(&self) -> serde_json::Value {
193        serde_json::json!({"duration_ms": self.elapsed_ms()})
194    }
195
196    fn with_elapsed_trace(&self, mut trace: serde_json::Value) -> serde_json::Value {
197        if let serde_json::Value::Object(fields) = &mut trace {
198            fields.insert("duration_ms".to_string(), self.elapsed_ms().into());
199        }
200        trace
201    }
202
203    /// Best-effort emit for mid-stream events (progress/log/warn): a failed
204    /// write (including a hung-up reader) is intentionally ignored so
205    /// diagnostics never abort the command.
206    fn emit(&self, event: agent_first_data::Event) {
207        let _ = self.sender.send(OutputMessage::Emit(event));
208    }
209
210    /// Emit a terminal event (`result`/`error`) and resolve the process exit
211    /// code through [`agent_first_data::CliEmitter::finish`]: `success_code` on
212    /// a clean write, `0` on broken pipe, and `4` on any other output failure.
213    fn finish(&self, event: agent_first_data::Event, success_code: u8) -> ExitCode {
214        let (reply, result) = mpsc::sync_channel(1);
215        if self
216            .sender
217            .send(OutputMessage::Finish {
218                event,
219                success_code,
220                reply,
221            })
222            .is_err()
223        {
224            return ExitCode::from(4);
225        }
226        ExitCode::from(result.recv().unwrap_or(4))
227    }
228
229    /// `{kind: "result", result: ..., trace: {duration_ms}}` → stdout.
230    pub fn ok<T: Serialize>(&self, result: T) -> ExitCode {
231        match serde_json::to_value(&result) {
232            Ok(mut result_value) => {
233                redact_boundary_values(&mut result_value);
234                self.finish(
235                    agent_first_data::json_result(result_value)
236                        .trace(self.elapsed_trace())
237                        .build(),
238                    0,
239                )
240            }
241            Err(err) => self.error(
242                "serialize_error",
243                &format!("Failed to serialize command result: {err}"),
244            ),
245        }
246    }
247
248    /// `{kind: "result", result: ..., trace: ...}` → stdout.
249    pub fn ok_trace<T: Serialize>(&self, result: T, trace: impl Serialize) -> ExitCode {
250        let mut result_value = match serde_json::to_value(&result) {
251            Ok(value) => value,
252            Err(err) => {
253                return self.error(
254                    "serialize_error",
255                    &format!("Failed to serialize command result: {err}"),
256                )
257            }
258        };
259        let mut trace_value = match serde_json::to_value(&trace) {
260            Ok(value) if value.is_object() => value,
261            Ok(_) => {
262                return self.error(
263                    "serialize_error",
264                    "Command trace must serialize as an object",
265                )
266            }
267            Err(err) => {
268                return self.error(
269                    "serialize_error",
270                    &format!("Failed to serialize command trace: {err}"),
271                )
272            }
273        };
274        redact_boundary_values(&mut result_value);
275        redact_boundary_values(&mut trace_value);
276        self.finish(
277            agent_first_data::json_result(result_value)
278                .trace(self.with_elapsed_trace(trace_value))
279                .build(),
280            0,
281        )
282    }
283
284    /// `{kind: "result", result: ...}` → stdout, with a caller-chosen exit code.
285    ///
286    /// For machine-checkable "is there drift" style results (e.g. `hatch bond
287    /// sync --check`) where the outcome is a normal, well-formed result (not an
288    /// error) but CI still needs a distinct non-zero exit to gate on.
289    pub fn ok_with_code<T: Serialize>(&self, result: T, code: u8) -> ExitCode {
290        match serde_json::to_value(&result) {
291            Ok(mut result_value) => {
292                redact_boundary_values(&mut result_value);
293                self.finish(
294                    agent_first_data::json_result(result_value)
295                        .trace(self.elapsed_trace())
296                        .build(),
297                    code,
298                )
299            }
300            Err(err) => self.error(
301                "serialize_error",
302                &format!("Failed to serialize command result: {err}"),
303            ),
304        }
305    }
306
307    /// `{kind: "error", error: {code, message, hint, retryable}, trace}` →
308    /// the configured diagnostic destination.
309    pub fn error(&self, error_code: &str, message: &str) -> ExitCode {
310        self.error_hint(error_code, message, None)
311    }
312
313    /// Like [`error`] but with an actionable hint for remediation.
314    pub fn error_hint(&self, error_code: &str, message: &str, hint: Option<&str>) -> ExitCode {
315        let message = redact_urls_in_text(message);
316        let hint = redact_urls_in_text(actionable_hint(error_code, hint));
317        self.finish(
318            built_event(
319                agent_first_data::json_error(error_code, &message)
320                    .hint(&hint)
321                    .trace(self.elapsed_trace())
322                    .build(),
323            ),
324            1,
325        )
326    }
327
328    /// Output error from anyhow::Error
329    pub fn error_from(&self, error_code: &str, err: &anyhow::Error) -> ExitCode {
330        self.error(error_code, &err.to_string())
331    }
332
333    /// Output error from [`crate::HyphaError`] (includes hint when present).
334    pub fn error_hypha(&self, err: &crate::HyphaError) -> ExitCode {
335        self.error_hint(&err.code, &err.message, err.hint.as_deref())
336    }
337
338    /// Agent-First Data progress step → the configured diagnostic destination.
339    /// `{kind: "progress", progress: {current, total, message, ...}, trace}`.
340    pub fn progress(&self, step: u32, total: u32, message: &str, data: serde_json::Value) {
341        let mut fields = match data {
342            serde_json::Value::Object(map) => map,
343            _ => serde_json::Map::new(),
344        };
345        fields.insert("current".into(), step.into());
346        fields.insert("total".into(), total.into());
347        fields.insert("message".into(), redact_urls_in_text(message).into());
348        let mut progress = serde_json::Value::Object(fields);
349        redact_boundary_values(&mut progress);
350        self.emit(
351            agent_first_data::json_progress(progress)
352                .trace(self.elapsed_trace())
353                .build(),
354        );
355    }
356
357    /// Byte-level protocol-v1 download progress → the configured diagnostic destination.
358    /// `{kind: "progress", progress: {event, downloaded_bytes, total_bytes}}`.
359    pub fn download_progress(&self, downloaded_bytes: u64, total_bytes: Option<u64>) {
360        self.emit(
361            agent_first_data::json_progress(serde_json::json!({
362                "event": "download_progress",
363                "downloaded_bytes": downloaded_bytes,
364                "total_bytes": total_bytes,
365            }))
366            .trace(self.elapsed_trace())
367            .build(),
368        );
369    }
370
371    /// Non-fatal informational log → the configured diagnostic destination.
372    pub fn log(&self, code: &str, message: &str) {
373        self.log_data(
374            "info",
375            code,
376            message,
377            serde_json::Value::Object(Default::default()),
378        );
379    }
380
381    /// Structured non-fatal log with additional tool-defined fields.
382    pub fn log_data(&self, level: &str, code: &str, message: &str, data: serde_json::Value) {
383        let mut fields = match data {
384            serde_json::Value::Object(map) => map,
385            _ => serde_json::Map::new(),
386        };
387        fields.insert("level".into(), level.into());
388        fields.insert("event".into(), code.into());
389        fields.insert("message".into(), redact_urls_in_text(message).into());
390        let mut log = serde_json::Value::Object(fields);
391        redact_boundary_values(&mut log);
392        self.emit(
393            agent_first_data::json_log(log)
394                .trace(self.elapsed_trace())
395                .build(),
396        );
397    }
398
399    /// Non-fatal warning → the configured diagnostic destination.
400    pub fn warn(&self, code: &str, message: &str) {
401        self.log_data(
402            "warn",
403            code,
404            message,
405            serde_json::Value::Object(Default::default()),
406        );
407    }
408
409    /// Emit a protocol-v1 startup log event to the diagnostic destination.
410    pub fn startup(&self, args: serde_json::Value) {
411        let (config, config_error) = match crate::config::HyphaConfig::load() {
412            Ok(cfg) => match serde_json::to_value(&cfg) {
413                Ok(config) => (config, None),
414                Err(err) => (
415                    serde_json::Value::Null,
416                    Some(serde_json::json!({
417                        "code": "config_serialize_error",
418                        "message": err.to_string(),
419                    })),
420                ),
421            },
422            Err(err) => (
423                serde_json::Value::Null,
424                Some(serde_json::json!({
425                    "code": err.code,
426                    "message": err.message,
427                    "hint": err.hint,
428                })),
429            ),
430        };
431
432        let env = serde_json::json!({
433            "CMN_HOME": std::env::var("CMN_HOME").ok(),
434            "SYNAPSE_TOKEN_SECRET": std::env::var("SYNAPSE_TOKEN_SECRET").ok(),
435        });
436        self.log_data(
437            "info",
438            "startup",
439            "Hypha command started",
440            serde_json::json!({
441                "category": "startup",
442                "config": config,
443                "config_error": config_error,
444                "args": args,
445                "env": env
446            }),
447        );
448    }
449}
450
451impl Drop for Output {
452    fn drop(&mut self) {
453        let _ = self.sender.send(OutputMessage::Shutdown);
454        if let Ok(worker) = self.worker.get_mut() {
455            if let Some(worker) = worker.take() {
456                let _ = worker.join();
457            }
458        }
459    }
460}
461
462fn actionable_hint<'a>(error_code: &str, hint: Option<&'a str>) -> &'a str {
463    if let Some(hint) = hint.filter(|h| !h.trim().is_empty()) {
464        return hint;
465    }
466    match error_code {
467        "invalid_args" | "invalid_value" | "unknown_key" | "missing_domain" => {
468            "run hypha --help or the relevant subcommand --help, then retry with valid inputs"
469        }
470        "invalid_uri" | "uri_error" | "cmn_invalid" => {
471            "use a CMN URI in the form cmn://domain or cmn://domain/b3.hash"
472        }
473        "synapse_error" | "network_error" | "NETWORK_ERR" => {
474            "check the synapse URL, network connectivity, and any configured auth token"
475        }
476        "not_found" | "missing_spore" | "not_cached" | "spore_not_found" => {
477            "verify the domain/hash and run hypha sense or hypha cache list before retrying"
478        }
479        "spore_security_rejected" => {
480            "received content targets protected control paths; inspect the spore and only relax cache.spore_reject_path_components if you accept that risk"
481        }
482        "skill_error" => "run hypha skill --help and retry with the suggested options",
483        _ => "read the error field, check hypha --help for the expected input, and retry",
484    }
485}
486
487/// Bridges [`crate::EventSink`] to an existing [`Output`].
488///
489/// Used in `handle_*` CLI wrappers so inner lib functions (which now take
490/// `&dyn EventSink`) can still emit warnings through the normal CLI output.
491pub struct OutSink<'a>(pub &'a Output);
492
493impl crate::EventSink for OutSink<'_> {
494    fn emit(&self, event: crate::HyphaEvent) {
495        match event {
496            crate::HyphaEvent::Progress {
497                current,
498                total,
499                message,
500            } => {
501                self.0
502                    .progress(current, total, &message, serde_json::Value::Null);
503            }
504            crate::HyphaEvent::DownloadProgress {
505                downloaded_bytes,
506                total_bytes,
507            } => {
508                self.0.download_progress(downloaded_bytes, total_bytes);
509            }
510            crate::HyphaEvent::Log { message } => {
511                self.0.log("log", &message);
512            }
513            crate::HyphaEvent::Warn { message } => {
514                self.0.warn("warn", &message);
515            }
516        }
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::{redact_boundary_values, redact_embedded_cmn_urls, redact_urls_in_text};
523
524    #[test]
525    fn diagnostic_prose_scrubs_urls_without_damaging_punctuation() {
526        let secret = "diagnostic-query-secret";
527        let message = format!(
528            "request failed (https://user:password@example.com/api?token_secret={secret}), fallback cmn://example.com/b3.test"
529        );
530
531        let redacted = redact_urls_in_text(&message);
532
533        assert!(!redacted.contains("password"));
534        assert!(!redacted.contains(secret));
535        assert!(redacted.contains("), fallback cmn://example.com/b3.test"));
536    }
537
538    #[test]
539    fn embedded_cmn_url_is_scrubbed_without_renaming_the_protocol_field() {
540        let secret = "embedded-cmn-secret";
541        let mut document = serde_json::json!({
542            "$schema": "https://cmn.dev/schemas/v1/spore.json",
543            "capsule": {
544                "uri": "cmn://example.com/b3.test",
545                "dist": [{
546                    "type": "git",
547                    "url": format!(
548                        "https://user:password@example.com/repo?token_secret={secret}"
549                    )
550                }]
551            }
552        });
553
554        redact_embedded_cmn_urls(&mut document);
555
556        let distribution = &document["capsule"]["dist"][0];
557        let rendered_url = distribution["url"].as_str().unwrap();
558        assert!(!rendered_url.contains("password"));
559        assert!(!rendered_url.contains(secret));
560        assert!(distribution.get("url").is_some());
561        assert!(distribution.get("distribution_url").is_none());
562        assert_eq!(document["capsule"]["uri"], "cmn://example.com/b3.test");
563    }
564
565    #[test]
566    fn synapse_selector_scrubs_urls_but_preserves_domain_selectors() {
567        let secret = "selector-secret";
568        let mut values = serde_json::json!([
569            {"synapse_selector": "synapse.example.com"},
570            {
571                "synapse_selector": format!(
572                    "https://user:password@synapse.example.com?token_secret={secret}"
573                )
574            }
575        ]);
576
577        redact_boundary_values(&mut values);
578
579        assert_eq!(values[0]["synapse_selector"], "synapse.example.com");
580        let rendered_selector = values[1]["synapse_selector"].as_str().unwrap();
581        assert!(!rendered_selector.contains("password"));
582        assert!(!rendered_selector.contains(secret));
583    }
584}