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