1use serde::Serialize;
4use std::process::ExitCode;
5use std::sync::{Mutex, mpsc};
6use std::thread::JoinHandle;
7use std::time::Instant;
8
9pub 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
37pub 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
91fn 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 && let serde_json::Value::String(raw_url) = field
103 {
104 *raw_url = agent_first_data::redact_url_secrets(raw_url);
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
118fn 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 && let serde_json::Value::String(selector) = field
127 {
128 *selector = redact_urls_in_text(selector);
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 pub fn new(format: agent_first_data::OutputFormat) -> Self {
150 Self::with_output_to(format, agent_first_data::OutputTo::Split)
151 }
152
153 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 fn emit(&self, event: agent_first_data::Event) {
207 let _ = self.sender.send(OutputMessage::Emit(event));
208 }
209
210 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 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 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 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 pub fn error(&self, error_code: &str, message: &str) -> ExitCode {
310 self.error_hint(error_code, message, None)
311 }
312
313 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 pub fn error_from(&self, error_code: &str, err: &anyhow::Error) -> ExitCode {
330 self.error(error_code, &err.to_string())
331 }
332
333 pub fn error_hypha(&self, err: &crate::HyphaError) -> ExitCode {
335 self.error_hint(&err.code, &err.message, err.hint.as_deref())
336 }
337
338 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 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 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 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 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 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": crate::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 && let Some(worker) = worker.take()
456 {
457 let _ = worker.join();
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
487pub 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}