Skip to main content

agent_core/core/
error.rs

1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum RuntimeError {
5    #[error("API error: {0}")]
6    Api(#[from] reqwest::Error),
7    #[error("{0}")]
8    ApiStatus(String),
9    #[error("Auth error: {0}")]
10    Auth(String),
11    #[error("Config error: {0}")]
12    Config(String),
13    #[error("Session error: {0}")]
14    Session(String),
15    #[error("Tool execution failed: {0}")]
16    Tool(String),
17    #[error("Network request timed out while {0}. Check your connection and try again.")]
18    NetworkTimeout(&'static str),
19    #[error("Request timed out")]
20    Timeout,
21    #[error("Operation canceled")]
22    Canceled,
23}
24
25impl RuntimeError {
26    /// Actionable transport text, without a potentially credential-bearing URL.
27    pub fn user_message(&self) -> String {
28        match self {
29            Self::Api(error) => humanize_network_error(error),
30            _ => self.to_string(),
31        }
32    }
33}
34
35/// Translate an Anthropic API error response into a human-actionable message.
36///
37/// SECURITY (spec §5.1): the response `body` — including any nested
38/// `error.message` / `error.type` — is UNTRUSTED. A hostile or misconfigured
39/// provider can echo the entire request (prompts, system text, tool schemas,
40/// credentials) inside it, so no body-derived text is ever reproduced in the
41/// returned message. The body is used only for *classification* (matching
42/// against fixed, vetted patterns); output is built exclusively from static
43/// guidance plus the numeric status.
44pub fn humanize_api_error(status: u16, body: &str) -> String {
45    humanize_api_error_with_reset(status, body, None)
46}
47
48/// Anthropic wire error types we recognise. Matching one lets the message
49/// name the class via OUR static string — never the provider's bytes.
50const VETTED_ERROR_TYPES: &[&str] = &[
51    "invalid_request_error",
52    "authentication_error",
53    "permission_error",
54    "not_found_error",
55    "request_too_large",
56    "rate_limit_error",
57    "api_error",
58    "overloaded_error",
59    "billing_error",
60    "timeout_error",
61];
62
63/// Extract `error.type` from an Anthropic error envelope and map it onto a
64/// vetted static label. Returns `None` for anything unrecognised — the
65/// untrusted value itself is never surfaced.
66fn vetted_error_type(body: &str) -> Option<&'static str> {
67    let v: serde_json::Value = serde_json::from_str(body).ok()?;
68    let ty = v.get("error")?.get("type")?.as_str()?;
69    VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
70}
71
72/// Wire error classes we recognise on broker-proxied (OpenAI-compatible)
73/// non-2xx responses. Superset of [`VETTED_ERROR_TYPES`]: it adds the
74/// OpenAI/Kimi/xAI-style `error.type` / `error.code` values so a proxied
75/// failure can be named via OUR static label — never the provider's bytes.
76///
77/// INVARIANT: no label may contain `':'`. Engine-side
78/// `redact_provider_proxy_error` truncates the transport message at the
79/// first `':'` after the `provider request failed: {status}` marker, so a
80/// colon in a label would silently strip it.
81pub const VETTED_PROXY_ERROR_TYPES: &[&str] = &[
82    // Anthropic classes (kept in sync with `VETTED_ERROR_TYPES`).
83    "invalid_request_error",
84    "authentication_error",
85    "permission_error",
86    "not_found_error",
87    "request_too_large",
88    "rate_limit_error",
89    "api_error",
90    "overloaded_error",
91    "billing_error",
92    "timeout_error",
93    // OpenAI-compatible / Kimi / xAI classes.
94    "access_terminated_error",
95    "invalid_authentication_error",
96    "insufficient_quota",
97    "quota_exceeded",
98    "server_error",
99    "model_not_found",
100    "invalid_api_key",
101];
102
103/// Classify an untrusted proxied error body onto a vetted static label.
104///
105/// Parses `body` as JSON and reads, in order, `error.type`, `error.code`,
106/// then top-level `type` / `code` (each must be a JSON string). Returns OUR
107/// constant only on an exact match; the untrusted value itself is never
108/// surfaced. Non-JSON, oversized-truncated, or unrecognised bodies yield
109/// `None` — classification never fails loudly.
110pub fn vetted_proxy_error_label(body: &str) -> Option<&'static str> {
111    let v: serde_json::Value = serde_json::from_str(body).ok()?;
112    let candidates = [
113        v.get("error").and_then(|e| e.get("type")),
114        v.get("error").and_then(|e| e.get("code")),
115        v.get("type"),
116        v.get("code"),
117    ];
118    let label = candidates
119        .into_iter()
120        .flatten()
121        .filter_map(serde_json::Value::as_str)
122        .find_map(|ty| VETTED_PROXY_ERROR_TYPES.iter().find(|t| **t == ty).copied());
123    label
124}
125
126/// User-actionable message for a broker-proxied non-2xx response when the
127/// (status, vetted label) pair is one we understand; `None` otherwise so the
128/// caller keeps the raw `provider request failed: …` prefix.
129///
130/// `provider` is a runtime-owned key (e.g. `kimi-code`, `xai-auth`) — never
131/// provider bytes. `label` must be one of OUR constants (typically from
132/// [`vetted_proxy_error_label`]); an unknown label is treated as absent.
133pub fn humanize_proxy_status_error(
134    provider: &str,
135    status: u16,
136    label: Option<&str>,
137) -> Option<String> {
138    let label = label.and_then(|l| VETTED_PROXY_ERROR_TYPES.iter().find(|t| **t == l).copied());
139    match (status, label?) {
140        (403, "access_terminated_error") => {
141            let mut msg = format!(
142                "{provider}: usage quota exhausted (HTTP 403). Your plan's usage window is used up — \
143                 wait for the reset or upgrade, or switch models with /model."
144            );
145            if provider == "kimi-code" {
146                msg.push_str(
147                    " Kimi Code quotas are weekly; the Kimi membership page shows the reset time.",
148                );
149            }
150            Some(msg)
151        }
152        (401, "invalid_authentication_error" | "authentication_error" | "invalid_api_key") => {
153            Some(format!(
154                "{provider}: authentication rejected (HTTP 401). Run `synaps login --provider {provider}`."
155            ))
156        }
157        (429, "rate_limit_error") => Some(format!(
158            "{provider}: rate limited (HTTP 429). Wait for the limit to reset, or switch models with /model."
159        )),
160        (429, "insufficient_quota" | "quota_exceeded") => Some(format!(
161            "{provider}: usage quota exhausted (HTTP 429). Add credit or wait for the quota to reset, \
162             or switch models with /model."
163        )),
164        _ => None,
165    }
166}
167
168/// Classification-only peek at the untrusted error body: true when it
169/// contains the given fixed pattern. The body text itself is never emitted.
170fn body_mentions(body: &str, pattern: &str) -> bool {
171    body.contains(pattern)
172}
173
174/// Map an untrusted provider error-type string onto a vetted static label.
175///
176/// Used by streaming/sync callers that already parsed the envelope: the
177/// returned `&'static str` is OUR constant, safe to log/display; the input
178/// itself must never be reproduced.
179pub fn sanitize_error_type(ty: &str) -> Option<&'static str> {
180    VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
181}
182
183/// Like [`humanize_api_error`] but surfaces a known rate-limit reset time in
184/// the 429 message so the failure is honest rather than cryptic.
185/// `reset_hint` is a human-readable duration string, e.g. `"47s"`.
186pub fn humanize_api_error_with_reset(status: u16, body: &str, reset_hint: Option<&str>) -> String {
187    // Vetted static class label, e.g. " [api_error]" — safe because it is
188    // one of OUR constants, selected (not copied) via the untrusted body.
189    let kind = vetted_error_type(body)
190        .map(|t| format!(" [{t}]"))
191        .unwrap_or_default();
192
193    match status {
194        529 => "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.".to_string(),
195        429 => {
196            if let Some(reset) = reset_hint {
197                format!(
198                    "Rate limit exhausted — retries used up while waiting for reset (next window in {}). \
199                     Try again shortly, or switch models with /model.",
200                    reset
201                )
202            } else {
203                format!("Rate limited by Anthropic (HTTP 429{kind}). Wait for the limit to reset, or switch models with /model.")
204            }
205        }
206        401 => "Authentication rejected. Run `synaps login` to re-authenticate.".to_string(),
207        403 => format!("Access denied (HTTP 403{kind}). Your account may not have access to this model."),
208        404 => format!("Model or endpoint not found (HTTP 404{kind}). Check the model name with /model."),
209        413 => "Request too large. Run /compact to shrink the conversation, or reduce tool output sizes.".to_string(),
210        400 if body_mentions(body, "Consumer Terms") =>
211            "Anthropic requires accepting updated Consumer Terms: sign in at claude.ai with this account, accept the terms, then retry.".to_string(),
212        // Anthropic's whole-request image rule: past 20 images in one request,
213        // every image must be at most 2000 px per side. The runtime trims the
214        // outgoing copy (runtime::image_limits); this names it if it ever slips.
215        400 if body_mentions(body, "many-image") =>
216            "Too many large images (HTTP 400): once a request holds more than 20 images, Anthropic accepts only images up to 2000 px per side. Run /compact to drop old images from the conversation.".to_string(),
217        400 if body_mentions(body, "extended-cache-ttl") =>
218            "Bad request (HTTP 400) — your account may not support 1h cache TTL; set cache_ttl = 5m in config.".to_string(),
219        400 if body_mentions(body, "prompt is too long") || body_mentions(body, "max_tokens") || body_mentions(body, "context") =>
220            "Context window exceeded (HTTP 400). Run /compact to shrink the conversation.".to_string(),
221        400 => format!("Bad request (HTTP 400{kind}). Provider error details withheld — they can echo request content."),
222        500 | 502 | 503 => format!("Anthropic server error (HTTP {status}{kind}). Retries exhausted — usually transient, try again shortly."),
223        _ => format!("API error (HTTP {status}{kind}). Provider error details withheld — they can echo request content."),
224    }
225}
226
227/// Translate a reqwest transport error into a human-actionable message.
228pub fn humanize_network_error(e: &reqwest::Error) -> String {
229    if e.is_timeout() {
230        "Request to api.anthropic.com timed out. Check your connection and try again.".to_string()
231    } else if e.is_connect() {
232        "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.".to_string()
233    } else if e.is_body() || e.is_decode() {
234        "Connection lost mid-response. Partial reply kept — send again to continue.".to_string()
235    } else {
236        "Network request failed. Check your connection and try again.".to_string()
237    }
238}
239
240/// Render an error's full cause chain (`Display` of every level, `: `-joined).
241///
242/// `format!("{e}")` on a `reqwest::Error` prints only the top level — e.g.
243/// `error sending request for url (…)` — and silently drops the source that
244/// says *why* (`operation timed out`, `dns error`, `connection refused`, …).
245/// That cost a real postmortem hours; always surface the chain.
246pub fn error_chain_string(e: &(dyn std::error::Error + 'static)) -> String {
247    let mut msg = e.to_string();
248    let mut source = e.source();
249    while let Some(cause) = source {
250        let cause_str = cause.to_string();
251        // Some wrappers already embed their source's Display; skip duplicates.
252        if !msg.contains(&cause_str) {
253            msg.push_str(": ");
254            msg.push_str(&cause_str);
255        }
256        source = cause.source();
257    }
258    msg
259}
260
261/// Host-aware variant of [`humanize_network_error`] for non-Anthropic
262/// providers (OpenAI Codex, Groq, local OpenAI-compat endpoints, …).
263///
264/// Unlike the Anthropic-specific helper this derives the host from the
265/// failing request's URL and preserves the underlying cause chain, so
266/// `chatgpt.com` failures are never misattributed and the *reason*
267/// (timeout vs connect vs mid-stream drop) survives into the UI.
268pub fn humanize_provider_network_error(e: &reqwest::Error) -> String {
269    let host = e
270        .url()
271        .and_then(|u| u.host_str())
272        .map(String::from)
273        .unwrap_or_else(|| "the provider endpoint".to_string());
274    let chain = error_chain_string(e);
275    if e.is_timeout() {
276        format!(
277            "Request to {host} timed out — usually transient; check your connection and try again. [{chain}]"
278        )
279    } else if e.is_connect() {
280        format!(
281            "Could not reach {host} (connection failed). Check your network, DNS, or proxy settings. [{chain}]"
282        )
283    } else if e.is_body() || e.is_decode() {
284        format!("Connection to {host} lost mid-response — usually transient; try again. [{chain}]")
285    } else {
286        format!("Network error talking to {host}: {chain}")
287    }
288}
289
290pub type Result<T> = std::result::Result<T, RuntimeError>;
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    const KIMI_403_BODY: &str = r#"{"error":{"type":"access_terminated_error","message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends."}}"#;
297
298    #[test]
299    fn proxy_labels_never_contain_colon_and_are_superset_of_anthropic() {
300        for label in VETTED_PROXY_ERROR_TYPES {
301            assert!(
302                !label.contains(':'),
303                "label {label:?} would be truncated by the engine"
304            );
305            assert!(
306                !label.contains('['),
307                "label {label:?} breaks the [label] suffix"
308            );
309        }
310        for label in VETTED_ERROR_TYPES {
311            assert!(
312                VETTED_PROXY_ERROR_TYPES.contains(label),
313                "proxy list must be a superset: missing {label}"
314            );
315        }
316    }
317
318    #[test]
319    fn proxy_label_reads_error_type() {
320        assert_eq!(
321            vetted_proxy_error_label(KIMI_403_BODY),
322            Some("access_terminated_error")
323        );
324    }
325
326    #[test]
327    fn proxy_label_falls_back_to_error_code_then_top_level() {
328        assert_eq!(
329            vetted_proxy_error_label(r#"{"error":{"code":"invalid_api_key","message":"x"}}"#),
330            Some("invalid_api_key")
331        );
332        // error.type present but unvetted → fall through to error.code.
333        assert_eq!(
334            vetted_proxy_error_label(r#"{"error":{"type":"weird","code":"insufficient_quota"}}"#),
335            Some("insufficient_quota")
336        );
337        assert_eq!(
338            vetted_proxy_error_label(r#"{"type":"server_error"}"#),
339            Some("server_error")
340        );
341        assert_eq!(
342            vetted_proxy_error_label(r#"{"code":"model_not_found"}"#),
343            Some("model_not_found")
344        );
345    }
346
347    #[test]
348    fn proxy_label_rejects_non_string_non_json_and_hostile_values() {
349        assert_eq!(vetted_proxy_error_label(r#"{"error":{"type":42}}"#), None);
350        assert_eq!(
351            vetted_proxy_error_label(r#"{"error":{"code":["a"]}}"#),
352            None
353        );
354        assert_eq!(
355            vetted_proxy_error_label(r#"{"error":{"type":"<script>ECHO:secret"}}"#),
356            None
357        );
358        // Prefix/suffix variants must not match — exact only.
359        assert_eq!(
360            vetted_proxy_error_label(r#"{"error":{"type":"access_terminated_error2"}}"#),
361            None
362        );
363        assert_eq!(
364            vetted_proxy_error_label(r#"{"error":{"type":"Access_Terminated_Error"}}"#),
365            None
366        );
367        assert_eq!(vetted_proxy_error_label("<html>403 Forbidden</html>"), None);
368        assert_eq!(vetted_proxy_error_label(""), None);
369        // Truncated JSON (oversized body cut at the cap) must not parse.
370        assert_eq!(vetted_proxy_error_label(&KIMI_403_BODY[..40]), None);
371    }
372
373    #[test]
374    fn humanize_proxy_403_access_terminated_is_quota_not_auth() {
375        let msg = humanize_proxy_status_error("kimi-code", 403, Some("access_terminated_error"))
376            .expect("known combo");
377        assert!(
378            msg.starts_with("kimi-code: usage quota exhausted (HTTP 403)"),
379            "got: {msg}"
380        );
381        assert!(msg.contains("/model"), "got: {msg}");
382        assert!(msg.contains("weekly"), "kimi-specific hint missing: {msg}");
383        assert!(msg.contains("membership page"), "got: {msg}");
384        assert!(
385            !msg.contains("login"),
386            "quota must not be misreported as auth: {msg}"
387        );
388
389        let generic = humanize_proxy_status_error("xai-auth", 403, Some("access_terminated_error"))
390            .expect("known combo");
391        assert!(
392            generic.starts_with("xai-auth: usage quota exhausted (HTTP 403)"),
393            "got: {generic}"
394        );
395        assert!(
396            !generic.contains("weekly"),
397            "kimi hint leaked to other provider: {generic}"
398        );
399    }
400
401    #[test]
402    fn humanize_proxy_401_points_to_provider_login() {
403        for label in [
404            "invalid_authentication_error",
405            "authentication_error",
406            "invalid_api_key",
407        ] {
408            let msg = humanize_proxy_status_error("xai-auth", 401, Some(label)).expect(label);
409            assert!(msg.contains("HTTP 401"), "got: {msg}");
410            assert!(
411                msg.contains("synaps login --provider xai-auth"),
412                "got: {msg}"
413            );
414        }
415    }
416
417    #[test]
418    fn humanize_proxy_429_distinguishes_rate_limit_from_quota() {
419        let rl = humanize_proxy_status_error("groq", 429, Some("rate_limit_error")).unwrap();
420        let q1 = humanize_proxy_status_error("groq", 429, Some("insufficient_quota")).unwrap();
421        let q2 = humanize_proxy_status_error("groq", 429, Some("quota_exceeded")).unwrap();
422        assert!(rl.contains("rate limited"), "got: {rl}");
423        assert!(q1.contains("quota exhausted"), "got: {q1}");
424        assert_eq!(q1, q2);
425        assert_ne!(rl, q1);
426    }
427
428    #[test]
429    fn humanize_proxy_unknown_combos_return_none() {
430        assert_eq!(humanize_proxy_status_error("kimi-code", 403, None), None);
431        assert_eq!(
432            humanize_proxy_status_error("kimi-code", 403, Some("rate_limit_error")),
433            None
434        );
435        assert_eq!(
436            humanize_proxy_status_error("kimi-code", 500, Some("server_error")),
437            None
438        );
439        assert_eq!(
440            humanize_proxy_status_error("kimi-code", 401, Some("access_terminated_error")),
441            None
442        );
443        // Unvetted label strings are treated as absent, never echoed.
444        assert_eq!(
445            humanize_proxy_status_error("kimi-code", 403, Some("<script>ECHO")),
446            None
447        );
448    }
449
450    #[test]
451    fn test_humanize_529_overloaded() {
452        let msg = humanize_api_error(
453            529,
454            r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
455        );
456        assert!(msg.contains("overloaded"), "got: {msg}");
457        assert!(!msg.contains('{'), "raw JSON leaked: {msg}");
458    }
459
460    #[test]
461    fn test_humanize_401_points_to_login() {
462        let msg = humanize_api_error(401, r#"{"error":{"message":"invalid x-api-key"}}"#);
463        assert!(msg.contains("synaps login"), "got: {msg}");
464    }
465
466    #[test]
467    fn test_humanize_400_context_suggests_compact() {
468        let msg = humanize_api_error(
469            400,
470            r#"{"error":{"message":"prompt is too long: 250000 tokens"}}"#,
471        );
472        assert!(msg.contains("/compact"), "got: {msg}");
473    }
474
475    #[test]
476    fn test_humanize_400_many_image_names_the_limit() {
477        let msg = humanize_api_error(
478            400,
479            r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages.433.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}"#,
480        );
481        assert!(msg.contains("20 images"), "got: {msg}");
482        assert!(msg.contains("2000 px"), "got: {msg}");
483        assert!(msg.contains("/compact"), "got: {msg}");
484        assert!(
485            !msg.contains("messages.433"),
486            "untrusted detail leaked: {msg}"
487        );
488    }
489
490    #[test]
491    fn test_humanize_400_cache_ttl_names_config_key() {
492        let msg = humanize_api_error(
493            400,
494            r#"{"error":{"message":"The extended-cache-ttl-2025-04-11 beta is not enabled for this account"}}"#,
495        );
496        assert!(msg.contains("cache_ttl = 5m"), "got: {msg}");
497    }
498
499    #[test]
500    fn test_humanize_unknown_status_names_status_but_withholds_detail() {
501        // Provider `error.message` is untrusted (can echo the request) —
502        // only the status and vetted guidance may appear.
503        let msg = humanize_api_error(418, r#"{"error":{"message":"teapot"}}"#);
504        assert!(msg.contains("418"), "got: {msg}");
505        assert!(!msg.contains("teapot"), "untrusted detail leaked: {msg}");
506    }
507
508    #[test]
509    fn test_humanize_non_json_body_truncated() {
510        let long_body = "x".repeat(500);
511        let msg = humanize_api_error(418, &long_body);
512        assert!(msg.len() < 300, "not truncated: {} chars", msg.len());
513    }
514
515    /// BUG-1 regression: byte-slicing at offset 200 panics when a multibyte
516    /// char (e.g. the 4-byte emoji 🔥) straddles that boundary.
517    /// Craft a body where the emoji starts at byte 198 (i.e. bytes 198-201),
518    /// so `&trimmed[..200]` would land in the middle of a char → panic.
519    /// The fixed code must NOT panic and must truncate cleanly at a char boundary.
520    #[test]
521    fn test_humanize_multibyte_boundary_no_panic() {
522        // 198 ASCII bytes + 🔥 (4 bytes, positions 198-201) + filler to exceed 200 total
523        let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
524        assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
525        // This must NOT panic — the bug causes a panic on unpatched code
526        let msg = humanize_api_error(418, &body);
527        // The result must be valid UTF-8 (it's a &str / String, always is if no panic)
528        // and must contain the truncated prefix (not the raw emoji bytes mid-char)
529        assert!(msg.contains("418"), "status should appear: {msg}");
530        // The body portion forwarded must be ≤ 200 bytes (emoji trimmed at boundary)
531        // After truncation at 198 bytes (the last safe boundary before 200), we get
532        // 198 'a's — confirm no garbled bytes leaked through
533        assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
534    }
535
536    // ── error_chain_string: full cause chain, not just the top Display ──────
537
538    #[derive(Debug)]
539    struct Outer(Inner);
540    #[derive(Debug)]
541    struct Inner;
542
543    impl std::fmt::Display for Outer {
544        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545            write!(f, "error sending request for url (https://example.com/x)")
546        }
547    }
548    impl std::fmt::Display for Inner {
549        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
550            write!(f, "operation timed out")
551        }
552    }
553    impl std::error::Error for Outer {
554        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
555            Some(&self.0)
556        }
557    }
558    impl std::error::Error for Inner {}
559
560    #[test]
561    fn error_chain_string_joins_all_sources() {
562        let msg = error_chain_string(&Outer(Inner));
563        assert!(
564            msg.contains("error sending request") && msg.contains("operation timed out"),
565            "chain must include top-level AND source: {msg}"
566        );
567    }
568
569    #[test]
570    fn error_chain_string_single_level_has_no_separator_suffix() {
571        let msg = error_chain_string(&Inner);
572        assert_eq!(msg, "operation timed out");
573    }
574
575    // ── humanize_provider_network_error: host-aware transport messaging ─────
576
577    /// Listener that accepts connections but never responds → client-side
578    /// timeout while awaiting response headers (the incident failure mode).
579    #[tokio::test]
580    async fn humanize_provider_timeout_names_host_and_says_transient() {
581        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
582        let addr = listener.local_addr().unwrap();
583        // Keep the listener alive but never accept/respond.
584        let client = reqwest::Client::builder()
585            .timeout(std::time::Duration::from_millis(100))
586            .build()
587            .unwrap();
588        let err = client
589            .post(format!("http://{addr}/codex/responses"))
590            .send()
591            .await
592            .expect_err("must time out");
593        drop(listener);
594        assert!(err.is_timeout(), "precondition: {err:?}");
595        let msg = humanize_provider_network_error(&err);
596        assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
597        assert!(msg.contains("timed out"), "must say timed out: {msg}");
598        assert!(msg.contains("transient"), "must flag transience: {msg}");
599        assert!(
600            !msg.contains("api.anthropic.com"),
601            "must not claim the Anthropic host: {msg}"
602        );
603    }
604
605    #[tokio::test]
606    async fn humanize_provider_connect_error_names_host() {
607        // Bind then drop → guaranteed-refused port.
608        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
609        let addr = listener.local_addr().unwrap();
610        drop(listener);
611        let client = reqwest::Client::new();
612        let err = client
613            .post(format!("http://{addr}/codex/responses"))
614            .send()
615            .await
616            .expect_err("must fail to connect");
617        assert!(err.is_connect(), "precondition: {err:?}");
618        let msg = humanize_provider_network_error(&err);
619        assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
620        assert!(
621            msg.contains("Could not reach"),
622            "must describe connection failure: {msg}"
623        );
624    }
625
626    // ── Phase 1 holdout regression: hostile provider echoes the request ─────
627    //
628    // A hostile/misconfigured provider can put the ENTIRE request body
629    // (user messages, system prompts, tool schemas, credentials) into
630    // `error.message`. spec §5.1 / Task 1: no raw-content sentinel may
631    // appear in logs or user-visible errors at any level. The humanizer
632    // must therefore never echo any body-derived text.
633
634    const HOSTILE_SENTINEL: &str = "HOLDOUT-SENTINEL-http500-77aa";
635
636    /// Anthropic-shaped error envelope whose message echoes a request body.
637    fn hostile_json_body() -> String {
638        format!(
639            r#"{{"type":"error","error":{{"type":"api_error","message":"ECHOED:{{\"messages\":[{{\"role\":\"user\",\"content\":\"{} tell me things\"}}],\"system\":[{{\"text\":\"You are a secret system prompt\"}}],\"tools\":[{{\"name\":\"bash\",\"input_schema\":{{\"properties\":{{}}}}}}]}}"}}}}"#,
640            HOSTILE_SENTINEL
641        )
642    }
643
644    /// Request-shaped markers that must never surface in a humanized error.
645    fn assert_no_request_content(msg: &str, ctx: &str) {
646        assert!(
647            !msg.contains(HOSTILE_SENTINEL),
648            "{ctx}: sentinel leaked: {msg}"
649        );
650        assert!(!msg.contains("ECHOED"), "{ctx}: echoed body leaked: {msg}");
651        assert!(
652            !msg.contains("input_schema") && !msg.contains("secret system prompt"),
653            "{ctx}: request-shaped content leaked: {msg}"
654        );
655    }
656
657    #[test]
658    fn hostile_error_message_never_leaks_for_any_status() {
659        let body = hostile_json_body();
660        for status in [400u16, 403, 404, 429, 500, 418] {
661            let msg = humanize_api_error(status, &body);
662            assert_no_request_content(&msg, &format!("status {status}"));
663            // Still actionable: names the status class.
664            assert!(
665                msg.contains(&status.to_string())
666                    || msg.contains("Rate limited")
667                    || msg.contains("Access denied")
668                    || msg.contains("not found"),
669                "status {status}: message not actionable: {msg}"
670            );
671        }
672    }
673
674    #[test]
675    fn hostile_429_with_reset_hint_keeps_timing_but_not_body() {
676        let msg = humanize_api_error_with_reset(429, &hostile_json_body(), Some("47s"));
677        assert_no_request_content(&msg, "429+reset");
678        assert!(msg.contains("47s"), "reset timing must survive: {msg}");
679    }
680
681    #[test]
682    fn hostile_non_json_body_never_leaks() {
683        // Non-JSON hostile content (e.g. text/plain echo of the request).
684        let body = format!(
685            "raw echo: {} {{\"messages\":[...],\"system\":[...]}} {}",
686            HOSTILE_SENTINEL,
687            "x".repeat(300)
688        );
689        for status in [400u16, 403, 404, 429, 500, 418] {
690            let msg = humanize_api_error(status, &body);
691            assert_no_request_content(&msg, &format!("non-json status {status}"));
692        }
693    }
694
695    #[test]
696    fn hostile_error_type_field_is_not_echoed_verbatim() {
697        // `error.type` is attacker-controlled too — only vetted static
698        // labels may be reproduced.
699        let body = format!(
700            r#"{{"error":{{"type":"{} injected-type","message":"m"}}}}"#,
701            HOSTILE_SENTINEL
702        );
703        for status in [400u16, 403, 404, 429, 500, 418] {
704            let msg = humanize_api_error(status, &body);
705            assert_no_request_content(&msg, &format!("type-field status {status}"));
706        }
707    }
708
709    #[test]
710    fn test_runtime_error_display() {
711        assert_eq!(
712            format!("{}", RuntimeError::Auth("bad token".into())),
713            "Auth error: bad token"
714        );
715
716        assert_eq!(
717            format!("{}", RuntimeError::Config("missing".into())),
718            "Config error: missing"
719        );
720
721        assert_eq!(
722            format!("{}", RuntimeError::Tool("failed".into())),
723            "Tool execution failed: failed"
724        );
725
726        assert_eq!(
727            format!("{}", RuntimeError::Session("not found".into())),
728            "Session error: not found"
729        );
730
731        assert_eq!(format!("{}", RuntimeError::Timeout), "Request timed out");
732
733        assert_eq!(format!("{}", RuntimeError::Canceled), "Operation canceled");
734    }
735
736    #[test]
737    fn test_runtime_error_to_string() {
738        assert_eq!(
739            RuntimeError::Auth("bad token".into()).to_string(),
740            "Auth error: bad token"
741        );
742
743        assert_eq!(
744            RuntimeError::Config("missing".into()).to_string(),
745            "Config error: missing"
746        );
747
748        assert_eq!(
749            RuntimeError::Tool("failed".into()).to_string(),
750            "Tool execution failed: failed"
751        );
752
753        assert_eq!(
754            RuntimeError::Session("not found".into()).to_string(),
755            "Session error: not found"
756        );
757
758        assert_eq!(RuntimeError::Timeout.to_string(), "Request timed out");
759
760        assert_eq!(RuntimeError::Canceled.to_string(), "Operation canceled");
761    }
762}