Synced from monorepo

Changes:
- Gate session-lifecycle heap steady state with a dhat soak
- Unbreak merge lifecycle e2e after default model → grok-4.5
- Scan home-scope rules dirs at <root>/rules
- Complete text-input paste and terminal parity
- Gate project roles and personas
- Use canonical editing in dialogs
- Use canonical editing in search bars
- Reject ambiguous MCP tool IDs
- Harden Git operands for plugins
- Simplify queue drain API
- Pass RFC 9207 iss through MCP OAuth token exchange
- Show leader roster when local agents map is empty
- Use canonical editing in Persona views
- Remove marketplace default-skills auto-install and purge old installs
- Use canonical editing in extension forms
- Add canonical dashboard text editing
- Use canonical editing in settings
- Add /summarize as a /recap alias
- Restore previous agent when exiting dashboard
- Use tool_choice auto for compaction
- Settings toggle for snap-prompt-to-top on send
- Update default models to grok-4.5
- Source login shell once for local bash (env + alias/function snapshot)
- Template hardcoded param names in server-native tool descriptions
- Fix System-Reminder XML tag injection in CLAUDE.md via agents_md
- Fix remote workspace-server hardcoding LSP trust (repo code execution risk)
- Clear orphaned tool-call updates at turn end
- Suppress task wake after cancel
- Send x-grok-client-identifier on direct API tool calls
- Harden dashboard peek lease transitions
- Host /btw side panel in live region (minimal mode)
- Bound scroll presentation latency
- Highlight multi-line constructs correctly in diffs and the file viewer
- Block web_fetch non-public IPs; local opt-in is explicit-host only
- Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness
- Follow up clipboard delivery feedback
- Use canonical editing in pickers
- Route TextArea through canonical editor
- Persistent "watching" status row; quieter turn markers
- Gate sensitive edit targets
- Expose agent registry counts and gate session churn on them
- Default coding data sharing to opt-out until server preference applies
- Wire chat attachment ids through gateway prompts
- On auth refresh failure, issue retry
- Forward preview provenance and computer lifecycle state
- Document independent privacy controls and scope /privacy output
- Strip SamplingError Display prefix on rate-limit UI copy
- Stop dumping Cloudflare HTML into Retry failed
- Disable in-place prompt edit (scroll jank on enter)
- Strip forced ANSI color from gh pr view JSON
- Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

View file

@ -20,7 +20,7 @@ use reqwest::header::{
};
use serde::Serialize;
use xai_grok_sampling_types::error::{parse_error_bytes, try_parse_stream_error};
use xai_grok_sampling_types::error::{try_parse_stream_error, user_facing_api_error_message};
use xai_grok_sampling_types::{
ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, ConversationRequest,
ConversationResponse, CreateResponseWrapper, DOOM_LOOP_CHECK_HEADER, MessagesRequestWrapper,
@ -678,48 +678,9 @@ impl SamplingClient {
|| lower.contains("secret")
}
/// Format a single header for error messages, redacting sensitive values.
fn format_header(name: &str, value: &str) -> String {
let display_value = if Self::is_sensitive_header(name) {
"[REDACTED]"
} else {
value
};
format!(" {}: {}", name, display_value)
}
/// Build request headers string for error messages (redacting sensitive values).
fn format_request_headers(
&self,
x_grok_conv_id: &str,
x_grok_req_id: &str,
model_id: &str,
include_accept: bool,
) -> Vec<String> {
let mut req_headers: Vec<String> = self
.default_headers
.iter()
.map(|(name, value)| {
Self::format_header(name.as_str(), value.to_str().unwrap_or("[non-utf8]"))
})
.collect();
req_headers.push(Self::format_header("x-grok-conv-id", x_grok_conv_id));
req_headers.push(Self::format_header("x-grok-req-id", x_grok_req_id));
req_headers.push(Self::format_header("x-grok-model-override", model_id));
if include_accept {
req_headers.push(Self::format_header("accept", "text/event-stream"));
}
req_headers
}
/// Build response headers string for error messages.
fn format_response_headers(response: &reqwest::Response) -> Vec<String> {
response
.headers()
.iter()
.map(|(name, value)| Self::format_header(name.as_str(), &format!("{:?}", value)))
.collect()
/// Short lossy body snippet for error logs (never user-facing).
fn body_preview(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).chars().take(500).collect()
}
/// Log all headers from a request at debug level (redacting sensitive values).
@ -739,36 +700,6 @@ impl SamplingClient {
}
}
/// Build error context message based on error type and status code.
/// Includes relevant request/response details depending on what the error is about.
fn build_api_error_message(
&self,
status: reqwest::StatusCode,
server_message: &str,
endpoint: &str,
req_headers: &[String],
resp_headers: Option<&[String]>,
) -> String {
let server_message_lower = server_message.to_lowercase();
let mut context_parts = vec![server_message.to_string()];
context_parts.push(format!("\nRequest URL: {}", endpoint));
// Show headers if error mentions headers
if server_message_lower.contains("header") {
context_parts.push(format!("Request headers:\n{}", req_headers.join("\n")));
}
// Always show response headers for server errors
if status.is_server_error()
&& let Some(resp_hdrs) = resp_headers
{
context_parts.push(format!("Response headers:\n{}", resp_hdrs.join("\n")));
}
context_parts.join("\n")
}
fn endpoint(&self, path: &str) -> String {
let base = self.base_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
@ -805,12 +736,12 @@ impl SamplingClient {
if !status.is_success() {
if status == reqwest::StatusCode::UNAUTHORIZED {
self.record_401_attribution(crate::attribution::SamplingConsumer::ChatCompletions);
let server_message = parse_error_bytes(bytes.as_ref());
let server_message = user_facing_api_error_message(status, bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401): {server_message}"
)));
}
let message = parse_error_bytes(bytes.as_ref());
let message = user_facing_api_error_message(status, bytes.as_ref());
return Err(SamplingError::Api {
status,
message,
@ -956,30 +887,20 @@ impl SamplingClient {
crate::attribution::SamplingConsumer::ChatCompletionsStream,
);
let endpoint = self.endpoint("chat/completions");
let server_message = response.text().await.unwrap_or_default();
let body = response.bytes().await.unwrap_or_default();
let server_message = user_facing_api_error_message(status, body.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
)));
}
let req_headers =
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true);
let resp_headers = Self::format_response_headers(&response);
let bytes = response.bytes().await?;
let server_message = parse_error_bytes(bytes.as_ref());
let message = self.build_api_error_message(
status,
&server_message,
&self.endpoint("chat/completions"),
&req_headers,
Some(&resp_headers),
);
let message = user_facing_api_error_message(status, bytes.as_ref());
span.record("error", message.as_str());
tracing::error!(
status = %status,
error_message = %message,
body_preview = %Self::body_preview(bytes.as_ref()),
model_id = %model_id,
"chat/completions API error"
);
@ -1161,26 +1082,17 @@ impl SamplingClient {
if status == reqwest::StatusCode::UNAUTHORIZED {
self.record_401_attribution(crate::attribution::SamplingConsumer::Responses);
let endpoint = self.endpoint("responses");
let server_message = parse_error_bytes(bytes.as_ref());
let server_message = user_facing_api_error_message(status, bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
)));
}
let req_headers =
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, false);
let server_message = parse_error_bytes(bytes.as_ref());
let message = self.build_api_error_message(
status,
&server_message,
&self.endpoint("responses"),
&req_headers,
None,
);
let message = user_facing_api_error_message(status, bytes.as_ref());
tracing::warn!(
status = %status,
error_message = %message,
body_preview = %Self::body_preview(bytes.as_ref()),
model_id = %model_id,
"responses API error"
);
@ -1329,7 +1241,8 @@ impl SamplingClient {
span.record("error", "unauthorized (401)");
self.record_401_attribution(crate::attribution::SamplingConsumer::ResponsesStream);
let endpoint = self.endpoint("responses");
let server_message = response.text().await.unwrap_or_default();
let body = response.bytes().await.unwrap_or_default();
let server_message = user_facing_api_error_message(status, body.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
)));
@ -1337,24 +1250,13 @@ impl SamplingClient {
let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers());
let req_headers =
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true);
let resp_headers = Self::format_response_headers(&response);
let bytes = response.bytes().await?;
let server_message = parse_error_bytes(bytes.as_ref());
let message = self.build_api_error_message(
status,
&server_message,
&self.endpoint("responses"),
&req_headers,
Some(&resp_headers),
);
let message = user_facing_api_error_message(status, bytes.as_ref());
span.record("error", message.as_str());
tracing::error!(
status = %status,
error_message = %message,
body_preview = %Self::body_preview(bytes.as_ref()),
model_id = %model_id,
"responses API error"
);
@ -1519,26 +1421,17 @@ impl SamplingClient {
if status == reqwest::StatusCode::UNAUTHORIZED {
self.record_401_attribution(crate::attribution::SamplingConsumer::Messages);
let endpoint = self.endpoint("messages");
let server_message = parse_error_bytes(bytes.as_ref());
let server_message = user_facing_api_error_message(status, bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
)));
}
let req_headers =
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, false);
let server_message = parse_error_bytes(bytes.as_ref());
let message = self.build_api_error_message(
status,
&server_message,
&self.endpoint("messages"),
&req_headers,
None,
);
let message = user_facing_api_error_message(status, bytes.as_ref());
tracing::warn!(
status = %status,
error_message = %message,
body_preview = %Self::body_preview(bytes.as_ref()),
model_id = %model_id,
"messages API error"
);
@ -1648,7 +1541,8 @@ impl SamplingClient {
span.record("error", "unauthorized (401)");
self.record_401_attribution(crate::attribution::SamplingConsumer::MessagesStream);
let endpoint = self.endpoint("messages");
let server_message = response.text().await.unwrap_or_default();
let body = response.bytes().await.unwrap_or_default();
let server_message = user_facing_api_error_message(status, body.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
)));
@ -1656,24 +1550,13 @@ impl SamplingClient {
let model_metadata = extract_model_metadata(response.headers());
let retry_after_secs = extract_retry_after(response.headers());
let should_retry = extract_should_retry(response.headers());
let req_headers =
self.format_request_headers(x_grok_conv_id, x_grok_req_id, &model_id, true);
let resp_headers = Self::format_response_headers(&response);
let bytes = response.bytes().await?;
let server_message = parse_error_bytes(bytes.as_ref());
let message = self.build_api_error_message(
status,
&server_message,
&self.endpoint("messages"),
&req_headers,
Some(&resp_headers),
);
let message = user_facing_api_error_message(status, bytes.as_ref());
span.record("error", message.as_str());
tracing::error!(
status = %status,
error_message = %message,
body_preview = %Self::body_preview(bytes.as_ref()),
model_id = %model_id,
"messages API error"
);

View file

@ -0,0 +1,120 @@
//! Wire e2e + pure tests for user-facing API error sanitization.
//!
//! Edge proxies return non-JSON bodies (HTML). Those must never reach TUI
//! scrollback; only structured JSON error envelopes and status-based copy.
use std::sync::Arc;
use xai_grok_sampler::{SamplerConfig, SamplingClient};
use xai_grok_sampling_types::{
ContentPart, ConversationItem, ConversationRequest, UserItem, status_user_message,
user_facing_api_error_message,
};
use xai_grok_test_support::{MockInferenceServer, ScriptedResponse};
const CF_524_HTML: &str = r#"<!DOCTYPE html>
<html lang="en-US">
<head><title>grok.com | 524: A timeout occurred</title></head>
<body>
<h1>A timeout occurred <span>Error code 524</span></h1>
<div>Visit cloudflare.com for more information.</div>
</body>
</html>"#;
fn test_config(base_url: &str, api_key: &str) -> SamplerConfig {
SamplerConfig {
api_key: Some(api_key.to_string()),
base_url: base_url.to_string(),
model: "test-model".to_string(),
..SamplerConfig::default()
}
}
fn user_request(text: &str) -> ConversationRequest {
ConversationRequest {
items: vec![ConversationItem::User(UserItem {
content: vec![ContentPart::Text {
text: Arc::<str>::from(text),
}],
..Default::default()
})],
..Default::default()
}
}
async fn stream_err(status: u16, body: &str) -> xai_grok_sampling_types::SamplingError {
let server = MockInferenceServer::start().await.expect("start mock");
server.enqueue_response("/v1/chat/completions", ScriptedResponse::text(status, body));
let mut cfg = test_config(&server.url(), "test-key");
cfg.max_retries = Some(0);
let client = SamplingClient::new(cfg).expect("client");
match client.conversation_stream(user_request("hi")).await {
Ok(_) => panic!("expected API error"),
Err(e) => e,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stream_524_html_uses_status_copy() {
let err = stream_err(524, CF_524_HTML).await;
let s = err.to_string();
assert!(!s.contains("<!DOCTYPE") && !s.contains("<html"));
assert!(s.contains(&status_user_message(
reqwest::StatusCode::from_u16(524).unwrap()
)));
assert!(s.contains("524"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stream_503_html_uses_unavailable_copy() {
let err = stream_err(503, "<html><body>Service Unavailable</body></html>").await;
let s = err.to_string();
assert!(!s.contains("<html"));
assert!(s.contains("temporarily unavailable"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stream_json_error_envelope_is_preserved() {
let body = r#"{"error":{"message":"rate limit exceeded","type":"rate_limit_error"}}"#;
let err = stream_err(429, body).await;
let s = err.to_string();
assert!(s.contains("rate limit exceeded"));
assert!(!s.contains("temporarily unavailable"));
}
#[test]
fn status_user_message_matrix() {
let cases: &[(u16, &str)] = &[
(502, "temporarily unavailable"),
(503, "temporarily unavailable"),
(504, "temporarily unavailable"),
(520, "timed out"),
(524, "timed out"),
(500, "Something went wrong on the server"),
(400, "Request failed"),
];
for &(code, needle) in cases {
let msg = status_user_message(reqwest::StatusCode::from_u16(code).unwrap());
assert!(
msg.contains(needle),
"status {code}: expected {needle:?} in {msg:?}"
);
assert!(
msg.contains(&format!("HTTP {code}")),
"status {code}: expected HTTP code in {msg:?}"
);
}
}
#[test]
fn non_json_empty_body_falls_back_to_status() {
let msg = user_facing_api_error_message(reqwest::StatusCode::BAD_GATEWAY, b"");
assert_eq!(msg, status_user_message(reqwest::StatusCode::BAD_GATEWAY));
}
#[test]
fn structured_json_is_not_replaced_by_status_copy() {
let bytes = br#"{"error":{"message":"credits exhausted","type":"server_error"}}"#;
let msg = user_facing_api_error_message(reqwest::StatusCode::PAYMENT_REQUIRED, bytes);
assert_eq!(msg, "credits exhausted");
}