继续收敛遗留错误正文

外部 MCP HTTP 错误返回状态、错误码和中文正文

认证、素材上传和发行发布保留上游 401/403/5xx 详情

精确脱敏 Direct 错误并标记旧历史缺失正文

补充回归测试、诊断文档和真实客户端启动验证
This commit is contained in:
kdletters
2026-10-05 20:03:04 +08:00
parent 04cc60eb04
commit 6f7ccc2832
19 changed files with 355 additions and 178 deletions
@@ -121,6 +121,9 @@ fn safe_error_body_detail(body: &str) -> Option<String> {
.and_then(|value| value.get("error").or(Some(value)))
.and_then(|value| serde_json::to_string(value).ok())
.or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?;
if matches!(detail.trim(), "{}" | "null" | "\"\"") {
return None;
}
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
@@ -131,21 +134,24 @@ fn safe_error_body_detail(body: &str) -> Option<String> {
fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
let server_detail = error_message(body)
.or_else(|| error_code(body))
.or_else(|| safe_error_body_detail(body));
if status == StatusCode::UNAUTHORIZED {
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
return match server_detail {
Some(detail) => {
format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}")
}
None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(),
};
}
if status == StatusCode::FORBIDDEN {
return format!(
"permission-denied: {}",
error_message(body)
.or_else(|| safe_error_body_detail(body))
.unwrap_or_else(|| "当前账号无权执行此操作".to_string())
server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string())
);
}
let detail = error_message(body)
.or_else(|| error_code(body))
.or_else(|| safe_error_body_detail(body))
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
format!("{fallback}:{detail}")
}
@@ -371,6 +377,14 @@ mod tests {
map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"),
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
);
assert_eq!(
map_http_error(
StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"access token expired"}}"#,
"读取失败",
),
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired"
);
assert_eq!(
map_http_error(
StatusCode::FORBIDDEN,
@@ -1697,7 +1697,7 @@ where
#[cfg(not(test))]
async fn run_direct_tools_mcp_stdio() -> Result<(), String> {
let root = validate_direct_tools_project_root(
&std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?,
&std::env::current_dir().map_err(|error| format!("读取 MCP 工作目录失败:{error}"))?,
)?;
let (sender, receiver) = tokio::sync::mpsc::channel(DIRECT_TOOLS_MCP_MAX_IN_FLIGHT);
std::thread::Builder::new()
@@ -1723,27 +1723,60 @@ fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool {
.is_some_and(|value| value == token)
}
fn external_mcp_http_error(
status: StatusCode,
code: &'static str,
message: &'static str,
) -> axum::response::Response {
(
status,
Json(json!({
"error": {
"code": code,
"message": message,
"status": status.as_u16(),
}
})),
)
.into_response()
}
async fn handle_external_mcp_http_request(
AxumState(state): AxumState<ExternalMcpHttpState>,
headers: HeaderMap,
Json(request): Json<Value>,
) -> Result<axum::response::Response, StatusCode> {
) -> Result<axum::response::Response, axum::response::Response> {
if !external_mcp_authorized(&headers, &state.token) {
return Err(StatusCode::UNAUTHORIZED);
return Err(external_mcp_http_error(
StatusCode::UNAUTHORIZED,
"mcp-unauthorized",
"客户端 MCP 鉴权失败:Bearer token 缺失、无效或已过期",
));
}
let Some(session) = current_platform_session() else {
return Err(StatusCode::UNAUTHORIZED);
return Err(external_mcp_http_error(
StatusCode::UNAUTHORIZED,
"mcp-session-unavailable",
"客户端 MCP 鉴权失败:当前账号会话不存在或已失效",
));
};
if session.user_id != state.session_user_id
|| session.identity_generation != state.session_identity_generation
{
return Err(StatusCode::UNAUTHORIZED);
return Err(external_mcp_http_error(
StatusCode::UNAUTHORIZED,
"mcp-session-changed",
"客户端 MCP 鉴权失败:账号会话已变化,请重新建立 MCP 连接",
));
}
// HTTP 有独立入口容量;饱和时在任何工具副作用前拒绝,不与 STDIO 重复取许可。
let _permit = state
.in_flight
.try_acquire()
.map_err(|_| StatusCode::TOO_MANY_REQUESTS)?;
let _permit = state.in_flight.try_acquire().map_err(|_| {
external_mcp_http_error(
StatusCode::TOO_MANY_REQUESTS,
"mcp-capacity-exhausted",
"客户端受控工具桥并发已满(上限 8),请稍后重试",
)
})?;
// JSON-RPC 通知(没有 `id`)没有响应体:MCP 客户端在 `initialize` 之后一定会发
// `notifications/initialized`,按错误回 400 会被判成握手失败,整个服务器就被标记
// 成 `failed`、一个工具都不广告(真实案例:2026-10-02 的 cc DirectProject 因此
@@ -1757,7 +1790,13 @@ async fn handle_external_mcp_http_request(
handle_direct_tools_mcp_request(&state.root, request),
)
.await
.ok_or(StatusCode::BAD_REQUEST)?;
.ok_or_else(|| {
external_mcp_http_error(
StatusCode::BAD_REQUEST,
"mcp-bridge-context-missing",
"客户端受控工具桥未绑定:当前请求不在受控工具执行上下文中",
)
})?;
Ok(Json(response).into_response())
}
@@ -1912,7 +1951,17 @@ mod tests {
Json(json!({"id":1,"method":"ping"})),
)
.await;
assert_eq!(rejected.unwrap_err(), StatusCode::TOO_MANY_REQUESTS);
let rejected = rejected.unwrap_err();
assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS);
let rejected_body = axum::body::to_bytes(rejected.into_body(), usize::MAX)
.await
.unwrap();
let rejected_body: Value = serde_json::from_slice(&rejected_body).unwrap();
assert_eq!(rejected_body["error"]["code"], "mcp-capacity-exhausted");
assert_eq!(rejected_body["error"]["status"], 429);
assert!(rejected_body["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("并发已满")));
drop(occupied);
let response = handle_external_mcp_http_request(
AxumState(state.clone()),
@@ -1932,6 +1981,44 @@ mod tests {
);
}
#[tokio::test]
async fn external_http_mcp_auth_failure_keeps_status_and_actionable_body() {
let _session = crate::platform_session::install_test_platform_session(
"mcp-auth-user",
"mcp-auth-key",
"http://127.0.0.1:9",
);
let session = current_platform_session().unwrap();
let temporary = tempfile::tempdir().unwrap();
let state = ExternalMcpHttpState {
bridge_url: "http://127.0.0.1:9".to_string(),
root: temporary.path().to_path_buf(),
token: "expected-token".to_string(),
session_user_id: session.user_id,
session_identity_generation: session.identity_generation,
in_flight: std::sync::Arc::new(tokio::sync::Semaphore::new(
DIRECT_TOOLS_MCP_MAX_IN_FLIGHT,
)),
};
let rejected = handle_external_mcp_http_request(
AxumState(state),
HeaderMap::new(),
Json(json!({"id":1,"method":"ping"})),
)
.await
.unwrap_err();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(rejected.into_body(), usize::MAX)
.await
.unwrap();
let body: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["error"]["code"], "mcp-unauthorized");
assert_eq!(body["error"]["status"], 401);
assert!(body["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("Bearer token")));
}
#[tokio::test]
async fn mcp_dispatch_runs_mixed_tools_concurrently_with_bounded_out_of_order_responses() {
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -161,12 +161,14 @@ pub(crate) struct AuthNetworkFailure {
pub(crate) reason: AuthNetworkReason,
}
/// 登录服务 5xx 的状态码。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
/// 登录服务 5xx 的状态码与服务端原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthServiceUnavailable {
pub(crate) status: u16,
/// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
pub(crate) server_message: Option<String>,
}
/// 其它未识别拒绝的状态码与服务端原文。
@@ -268,7 +270,10 @@ mod tests {
"authNetworkFailure",
),
(
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }),
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable {
status: 503,
server_message: None,
}),
"authServiceUnavailable",
),
(
@@ -342,7 +347,10 @@ mod tests {
#[test]
fn payload_variants_keep_machine_facts_and_server_text() {
let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable(
AuthServiceUnavailable { status: 503 },
AuthServiceUnavailable {
status: 503,
server_message: None,
},
))
.expect("serialize auth error");
assert_eq!(unavailable["type"], "authServiceUnavailable");
@@ -565,6 +565,7 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA
if status.is_server_error() {
return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable {
status: status_code,
server_message,
});
}
ClientAuthError::UnexpectedRejection(UnexpectedRejection {
@@ -1317,7 +1318,10 @@ mod tests {
assert!(!transient.is_authority_failure());
assert_eq!(
transient,
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 })
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable {
status: 500,
server_message: None,
})
);
}
@@ -1424,7 +1428,10 @@ mod tests {
fn unreadable_body_keeps_the_known_status() {
assert_eq!(
classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin),
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 })
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable {
status: 503,
server_message: None,
})
);
assert_eq!(
classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session),
@@ -321,7 +321,8 @@ fn parse_error_payload(body: &str) -> (Option<String>, Option<String>) {
body.trim(),
600,
);
(!detail.trim().is_empty()).then_some(detail)
(!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\""))
.then_some(detail)
});
(code, message)
}
@@ -342,7 +343,12 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
let (code, message) = parse_error_payload(body);
if status == StatusCode::UNAUTHORIZED {
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
return match message.or(code) {
Some(detail) => {
format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}")
}
None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(),
};
}
if status == StatusCode::FORBIDDEN {
return format!(
@@ -1616,6 +1622,14 @@ mod tests {
map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"),
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
);
assert_eq!(
map_http_error(
StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"access token expired"}}"#,
"读取失败",
),
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired"
);
assert_eq!(
map_http_error(
StatusCode::BAD_REQUEST,
@@ -163,42 +163,80 @@ fn response_data(payload: Value) -> Value {
fn response_error(status: StatusCode, body: &str, action: &str) -> String {
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
if status == StatusCode::UNAUTHORIZED {
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
}
if status == StatusCode::FORBIDDEN {
return format!("permission-denied: {action}权限不足");
}
let parsed = serde_json::from_str::<Value>(body).ok();
let error = parsed.as_ref().and_then(|value| value.get("error"));
let detail = error
.and_then(|value| value.get("message"))
.or_else(|| parsed.as_ref().and_then(|value| value.get("message")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.or_else(|| {
error
.and_then(|value| value.get("code"))
.or_else(|| parsed.as_ref().and_then(|value| value.get("code")))
let detail = ["message", "code", "detail", "additionalDetails"]
.into_iter()
.filter_map(|field| {
let value = error
.and_then(|value| value.get(field))
.or_else(|| parsed.as_ref().and_then(|value| value.get(field)))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.map(ToString::to_string)?;
// 先把常见的 `token=... provider rejected` 形状切开,再交给统一脱敏器;
// 否则它会把整行当成长凭据,连后面的真实原因一起吞掉。
Some(redact_upload_inline_secret_tail(value))
})
.collect::<Vec<_>>()
.join(";");
let detail = (!detail.is_empty())
.then_some(detail)
.or_else(|| {
let safe = crate::agent::redact_agent_runtime_error(
std::path::Path::new("__agc_no_project_root__"),
body.trim(),
600,
);
(!safe.is_empty()).then_some(safe)
(!safe.is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe)
})
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
.map(|detail| {
crate::agent::redact_agent_runtime_error(
std::path::Path::new("__agc_no_project_root__"),
&detail,
600,
)
});
if status == StatusCode::UNAUTHORIZED {
return match detail {
Some(detail) => {
format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}")
}
None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(),
};
}
if status == StatusCode::FORBIDDEN {
return format!(
"permission-denied: {action}权限不足{}",
detail
.map(|detail| format!(":{detail}"))
.unwrap_or_default()
);
}
let detail = detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
format!("{action}失败:{detail}")
}
fn redact_upload_inline_secret_tail(mut value: String) -> String {
for key in ["token=", "api_key=", "api-key=", "authorization="] {
let Some(start) = value.to_ascii_lowercase().find(key) else {
continue;
};
let value_start = start + key.len();
let value_end = value[value_start..]
.find(|character: char| {
character.is_whitespace() || matches!(character, ';' | ';' | ',' | ',')
})
.map(|offset| value_start + offset)
.unwrap_or(value.len());
if value_end > value_start {
value.replace_range(value_start..value_end, "[redacted-secret]");
}
}
value
}
fn upload_route() -> &'static str {
"/api/assets/direct-upload-tickets"
}
@@ -477,4 +515,17 @@ mod tests {
assert!(!detail.contains("token=secret"), "{detail}");
assert!(detail.contains("provider rejected"), "{detail}");
}
#[test]
fn response_error_keeps_auth_status_and_server_message() {
let detail = response_error(
StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"asset token expired"}}"#,
"上传素材",
);
assert_eq!(
detail,
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试:asset token expired"
);
}
}
@@ -385,7 +385,9 @@ export function AuthenticatedClient({
}
case 'authServiceUnavailable': {
const payload = failure as AuthServiceUnavailable;
const text = `登录服务暂时不可用(HTTP ${payload.status})`;
const text = payload.serverMessage
? `登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}`
: `登录服务暂时不可用(HTTP ${payload.status})`;
setAuthCheckError(text);
setLoginStatus(text);
throw error;
@@ -660,7 +662,9 @@ export function AuthenticatedClient({
case 'authServiceUnavailable': {
const payload = failure as AuthServiceUnavailable;
setLoginStatus(
`发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`,
payload.serverMessage
? `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}`
: `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`,
);
throw error;
}
@@ -898,7 +902,9 @@ export function AuthenticatedClient({
case 'authServiceUnavailable': {
const payload = failure as AuthServiceUnavailable;
setLoginStatus(
`登录失败:登录服务暂时不可用(HTTP ${payload.status})`,
payload.serverMessage
? `登录失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}`
: `登录失败:登录服务暂时不可用(HTTP ${payload.status})`,
);
throw error;
}
@@ -87,58 +87,6 @@ export function isMudPointInsufficientRuntimeError(message: string) {
);
}
const DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN =
/(?:^|[^\w])(?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|accesstoken|refresh[_ -]?token|refreshtoken|oauth[_ -]?token|id[_ -]?token|auth[_ -]?token|authtoken|client[_ -]?secret|clientsecret|private[_ -]?key|privatekey|secret[_ -]?key|secretkey|x-api-key|x_api_key|api[_ -]?key|apikey|credentials?|password|token|secret|bearer)\s*["']?\s*[:=]>?\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?:Bearer\s+(?:\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)|\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+))/gi;
const DIRECT_FAILURE_BEARER_PATTERN =
/(?:^|[^\w])Bearer\s+(\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)/gi;
function isRedactedDirectFailureValue(value: string) {
const normalized = value.trim();
return (
normalized.length === 0 ||
/^(?:Bearer\s+)?(?:\[redacted-secret\]|\[redacted-sensitive-field\]|\[已隐藏凭据\]|\[已隐藏敏感字段\]|\[已隐藏链接\]|\[已隐藏路径\]|\[已隐藏配置\]|\[已隐藏敏感信息\]|<redacted-secret>|<redacted-url>|<absolute-path>|\[redacted-config\]|\[redacted sensitive context\])$/i.test(
normalized,
)
);
}
function containsUnredactedDirectFailureSecret(value: string) {
// Provider payloads are sometimes embedded as escaped JSON in a single
// diagnostic line (`{\"token\":\"...\"}`). Normalize only the quote
// escapes for the detector; the displayed value still goes through the
// backend's redaction and marker conversion unchanged.
const normalizedValue = value.replace(/\\(["'])/g, '$1');
DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while (
(match =
DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.exec(normalizedValue)) !==
null
) {
const rawCandidate = (match[1] ?? '').trim();
const candidate =
rawCandidate.length >= 2 &&
((rawCandidate.startsWith('"') && rawCandidate.endsWith('"')) ||
(rawCandidate.startsWith("'") && rawCandidate.endsWith("'")))
? rawCandidate.slice(1, -1).trim()
: rawCandidate;
if (!isRedactedDirectFailureValue(candidate)) {
return true;
}
}
DIRECT_FAILURE_BEARER_PATTERN.lastIndex = 0;
while (
(match = DIRECT_FAILURE_BEARER_PATTERN.exec(normalizedValue)) !== null
) {
if (!isRedactedDirectFailureValue(match[1] ?? '')) {
return true;
}
}
return false;
}
function redactDirectFailureMarkers(value: string) {
return value
.replace(/<redacted-url>/gi, '[已隐藏链接]')
@@ -149,6 +97,15 @@ function redactDirectFailureMarkers(value: string) {
.replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]');
}
/**
* 跨 IPC 的宿主正文统一走同一套精确脱敏:遇到凭据、链接或路径时只替换敏感值,
* 不再因为一条正文里同时出现敏感字段就把 HTTP 状态、错误码和可行动原因整段丢弃。
*/
function visibleRuntimeDetail(value: string, maxChars: number) {
const safe = runtimeFailureDetail(value)?.replace(/^:/u, '').trim() ?? '';
return safe ? safe.slice(0, maxChars) : '';
}
/**
* 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。
*
@@ -185,23 +142,19 @@ function directCodexDiagnosticFailureParts(
if (!stage || !retryable || !rawSummary || !rawHint) {
return null;
}
const summary = redactDirectFailureMarkers(rawSummary)
const summary = (
visibleRuntimeDetail(rawSummary, 320) ||
redactDirectFailureMarkers(rawSummary)
)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 320);
const hint = redactDirectFailureMarkers(rawHint)
const hint = (
visibleRuntimeDetail(rawHint, 180) || redactDirectFailureMarkers(rawHint)
)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
const combined = `${summary} ${hint}`;
if (
containsUnredactedDirectFailureSecret(combined) ||
/https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test(
combined,
)
) {
return null;
}
const stageLabel = {
request: '智能创作请求失败',
'art-preparation': '平台资源准备失败',
@@ -262,15 +215,9 @@ function directPlatformFailureDetail(message: string) {
)) {
return null;
}
if (
containsUnredactedDirectFailureSecret(trimmed) ||
/https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test(
trimmed,
)
) {
return null;
}
const safe = redactDirectFailureMarkers(trimmed)
const safe = (
visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed)
)
.replace(/(?:;|;|\s)operationId\s*=\s*[^;;\s]+/gi, '')
.replace(/(?:;|;)\s*externalGenerationJobId\s*=\s*[^;;\s]+/gi, '')
.replace(/\s+/g, ' ')
@@ -290,19 +237,9 @@ function directCodexFailureDetail(message: string) {
if (!detail) {
return null;
}
if (
/(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie)/i.test(
detail,
)
) {
return null;
}
const safe = detail
.replace(/https?:\/\/[^\s]+/gi, '[已隐藏链接]')
.replace(
/(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s]+/gi,
'[已隐藏路径]',
)
const safe = (
visibleRuntimeDetail(detail, 420) || redactDirectFailureMarkers(detail)
)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 280);
@@ -319,14 +256,12 @@ function directRuntimeFailureDetail(message: string) {
)) {
return null;
}
if (
/(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test(
trimmed,
)
) {
return null;
}
const safe = trimmed.replace(/\s+/g, ' ').trim().slice(0, 320);
const safe = (
visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed)
)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 320);
return safe || null;
}
@@ -370,7 +305,7 @@ export function projectRuntimeVisibleError(
other: '智能创作执行失败,请查看运行详情后重试',
}[codexAppServerKind];
if (detail) {
return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`;
return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`;
}
}
const directCodexAppServerKind = visibleMessage.match(
@@ -389,11 +324,11 @@ export function projectRuntimeVisibleError(
other: '未完成本次执行,请查看项目文件是否已修改后再重试',
}[directCodexAppServerKind];
if (detail) {
return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`;
return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`;
}
}
if (visibleMessage.includes('codex-app-server-terminal-unknown:')) {
return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ''}`;
return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`;
}
const directCodexDetail = directCodexFailureDetail(visibleMessage);
if (directCodexDetail) {
@@ -415,7 +350,7 @@ export function projectRuntimeVisibleError(
!retryAttemptText ||
!maxRetriesText
) {
return `${subject} 执行失败${runtimeFailureDetail(message) ?? ',请稍后重试'}`;
return `${subject} 执行失败${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`;
}
const httpStatus = Number.parseInt(httpStatusText, 10);
const retryAttempt = Number.parseInt(retryAttemptText, 10);
@@ -554,7 +489,7 @@ export function projectRuntimeVisibleError(
const safeDetail = runtimeFailureDetail(message);
return safeDetail
? `${subject} 执行失败${safeDetail}`
: `${subject} 执行失败,请稍后重试`;
: `${subject} 执行失败:宿主未提供具体错误正文`;
}
/** 精确脱敏普通 Tauri/IPC/网络错误:保留错误码与正文,只替换敏感值。 */
@@ -583,12 +518,12 @@ function runtimeFailureDetail(value: string) {
.replace(/\s+/gu, ' ')
.trim()
.slice(0, 420);
if (!safe || safe === trimmed) return safe ? `:${safe}` : '';
if (!safe || safe === trimmed) return safe ? `:${safe}` : null;
return `:${safe}`;
}
function runtimeFailureDetailSuffix(value: string) {
return runtimeFailureDetail(value);
return runtimeFailureDetail(value) ?? ':宿主未提供具体错误正文';
}
export function taskRowsFromManifest(
@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 登录服务 5xx 的状态码。
* 登录服务 5xx 的状态码与服务端原文。
*/
export type AuthServiceUnavailable = { status: number, };
export type AuthServiceUnavailable = { status: number, serverMessage: string | null, };
@@ -512,7 +512,14 @@ export function sanitizeDirectThreadText(
);
const withoutAbsolutePaths = redactAbsolutePaths(withoutProjectRoot);
const withoutSecrets = redactSecretTokens(withoutAbsolutePaths);
return sanitizeSensitiveLines(withoutSecrets);
const sanitized = sanitizeSensitiveLines(withoutSecrets);
// 旧版已经落盘的失败条目无法凭空恢复被删掉的原因;把它们明确标成“宿主未提供正文”,
// 避免历史记录继续伪装成当前仍在吞错的完整兜底句。新事件带 detail 时不命中这里。
const legacyGenericFailure =
'陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断';
return sanitized.trim() === legacyGenericFailure
? `${sanitized}:宿主未提供具体错误正文`
: sanitized;
}
/** 条目投影的脱敏:只改自由文本字段,身份与时间原样保留。 */
@@ -77,10 +77,14 @@ export function enqueueFailureNotice(failure: EnqueueError): string | null {
return `待发消息已达上限(最多 ${failure.limit} 条),请等前面几条发完再发送`;
case 'projectRootUnusable':
return '当前项目目录不存在或不是绝对路径';
case 'permissionRejected':
return failure.policyDetail?.trim() || null;
case 'inputRejected':
return failure.detail?.trim() || null;
case 'permissionRejected': {
const detail = visibleDirectFailureDetail(failure.policyDetail);
return detail ? detail : '项目权限策略拒绝执行:宿主未提供具体错误正文';
}
case 'inputRejected': {
const detail = visibleDirectFailureDetail(failure.detail);
return detail ? detail : '输入被宿主拒绝:宿主未提供具体错误正文';
}
case 'contentEmpty':
return '聊天内容不能为空';
// 宿主 / 环境事实:必须走上报通道,文案由 `enqueueSystemFailureNoticeText` 给固定句。
@@ -109,13 +113,9 @@ export function enqueueSystemFailureNoticeText(
): string | null {
switch (failure.type) {
case 'environmentNotReady':
return visibleDirectFailureDetail(failure.detail)
? `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail)}`
: `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`;
return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail) ?? '宿主未提供具体错误正文'}`;
case 'hostStateUnavailable':
return visibleDirectFailureDetail(failure.detail)
? `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail)}`
: `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`;
return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail) ?? '宿主未提供具体错误正文'}`;
// 业务变体由 `enqueueFailureNotice` 认领,这里不重复给句;认不出形状时同理。
// 穷尽性由那个函数的 `default: expectNever` 保证——新增变体时那边会先编译失败。
default:
@@ -100,7 +100,9 @@ function withVisibleFailureDetail(
detail: string | null | undefined,
) {
const safeDetail = visibleDirectFailureDetail(detail);
return safeDetail ? `${message}:${safeDetail}` : message;
return safeDetail
? `${message}:${safeDetail}`
: `${message}:宿主未提供具体错误正文`;
}
function withVisibleFailureDetailOrMissing(
@@ -24,4 +24,28 @@ describe('Agent Runtime 错误正文', () => {
expect(text).toContain('status=401');
expect(text).toContain('detail=invalid token');
});
it('正文带敏感字段时只替换敏感值,不丢掉上游状态和原因', () => {
const text = projectRuntimeVisibleError(
'direct-codex-error: HTTP 502 upstream overloaded;authorization=Bearer provider-secret;https://provider.example/private;C:\\Users\\demo\\game',
'陶泥儿智能创作',
);
expect(text).toContain('HTTP 502');
expect(text).toContain('upstream overloaded');
expect(text).toContain('[已隐藏凭据]');
expect(text).not.toContain('provider-secret');
expect(text).not.toContain('provider.example');
expect(text).not.toContain('C:\\Users\\demo');
});
it('已知分类但宿主没有正文时明确标记正文缺失', () => {
const text = projectRuntimeVisibleError(
'kind=codex-app-server-unauthorized',
'陶泥儿智能创作',
);
expect(text).toContain('鉴权失败');
expect(text).toContain('宿主未提供具体错误正文');
});
});
@@ -148,8 +148,7 @@ function submitComposerForm(composer: HTMLElement) {
function directTurnInputText(args?: Record<string, unknown>) {
const content = (
args?.userItem as
| { content?: Array<{ type: string; text?: string }> }
| undefined
{ content?: Array<{ type: string; text?: string }> } | undefined
)?.content;
return (content ?? [])
.map((part) => (part.type === 'input_text' ? (part.text ?? '') : ''))
@@ -546,13 +545,13 @@ export function registerChatComposerControlTests() {
await waitFor(() => {
expect(
within(conversation).getByText(
'陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试',
'陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:Codex app-server 启动失败:找不到可执行文件',
),
).not.toBeNull();
});
// 宿主原文(`detail` 里的可执行文件路径、机器字段)只留在诊断里,不进聊天。
expect(conversation.textContent ?? '').not.toContain(
'Codex app-server 启动失败',
// 宿主原文经过精确脱敏后保留在聊天里,便于用户直接定位启动失败原因。
expect(conversation.textContent ?? '').toContain(
'Codex app-server 启动失败:找不到可执行文件',
);
expect(conversation.textContent ?? '').not.toContain(
'direct-codex-failure',
@@ -789,7 +788,7 @@ export function registerChatComposerControlTests() {
await waitFor(() => {
expect(
within(surface).getAllByText(
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试:codex-app-server-error:context-window-exceeded',
).length,
).toBeGreaterThan(0);
});
@@ -853,7 +852,7 @@ export function registerChatComposerControlTests() {
await waitFor(() => {
expect(
within(conversation).getAllByText(
'陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试',
'陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试:写入本项目对话历史失败:项目对话历史追加写失败',
),
).toHaveLength(1);
});
@@ -55,12 +55,12 @@ export function registerProjectConversationTests() {
detail: ' 用户条目不合法 ',
}),
).toBe('用户条目不合法');
// 空原文按"没有提示"处理:要么给一条能显示的话,要么给 null。
// 空原文也不能静默:明确标出宿主未提供正文,避免用户只看到统一兜底句。
expect(
enqueueFailureNotice({ type: 'permissionRejected', policyDetail: ' ' }),
).toBeNull();
).toBe('项目权限策略拒绝执行:宿主未提供具体错误正文');
// 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里只给固定句,不回落 `detail`。
// 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里保留精确脱敏 detail。
expect(
enqueueFailureNotice({
type: 'environmentNotReady',
@@ -79,14 +79,16 @@ export function registerProjectConversationTests() {
detail: 'Codex app-server 启动失败:找不到可执行文件',
}),
).toBe(
'陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试',
'陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:Codex app-server 启动失败:找不到可执行文件',
);
expect(
enqueueSystemFailureNoticeText({
type: 'hostStateUnavailable',
detail: '账本损坏:exitStatus=signal: 9 (SIGKILL)',
}),
).toBe('陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试');
).toBe(
'陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试:账本损坏:exitStatus=signal: 9 (SIGKILL)',
);
// JS 侧载体:结构化载荷装进 Error,`error` 与 `message`(序列化的机器事实)都带出去。
const wrapper = new EnqueueFailureWrapper({
@@ -196,7 +196,11 @@ describe('变体分流', () => {
variantMessage({ type: 'passwordLoginRejected', serverMessage: null }),
).toBe('登录输入被拒绝');
expect(
variantMessage({ type: 'authServiceUnavailable', status: 503 }),
variantMessage({
type: 'authServiceUnavailable',
status: 503,
serverMessage: null,
}),
).toBe('HTTP 503');
});
@@ -92,6 +92,17 @@ describe('directThreadSanitize', () => {
);
});
it('旧版通用中断条目明确标记宿主没有记录正文', () => {
expect(
sanitizeDirectThreadText(
ROOT,
'陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断',
),
).toBe(
'陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断:宿主未提供具体错误正文',
);
});
it('多行文本保留换行', () => {
const value = '第一段\nkey=sk-abcdefghijklmnop\n第三段\n';
expect(sanitizeDirectThreadText(ROOT, value)).toBe(
@@ -128,6 +128,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
- AGC 项目资源编辑、上传、预览、恢复队列和生成结果回读的面板错误统一经过 `normalizeDiagnosticText`,保留状态码与安全正文,替换凭据、URL、路径和长标识;资源业务提示不再直接展示 `Error.message`。
- 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的普通客户端错误统一经过 `visibleClientErrorMessage`;结构化认证拒绝仍按 typed reason 分流,非结构化错误保留安全正文后再进入状态提示或错误上报。
- 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的用户可见错误也复用该出口,避免业务面板把原始 `Error.message` 重新带回界面。
- 外部 MCP loopback 的 401/429/桥上下文缺失必须返回带正文的 JSON 错误体(同时保留 HTTP 状态);不能让 Claude Code 侧只看到空 body 的状态码。
- 认证 5xx、账户/素材上传/发行发布的 401/403 保留服务端 `message`/`code` 后再做精确脱敏;空 `{}`、`null` 等无信息响应不算错误正文。
- 旧版已落盘的通用“执行通道中断”文本无法恢复历史原因,读取时明确追加“宿主未提供具体错误正文”;新事件只在确实没有 detail 时使用同样标记。
## 后果与边界
@@ -49,3 +49,6 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时,
- 真实 AGC dev smoke:客户端使用 `3080`、后端 `8084`、数据库 `3101`、后台 `3103` 启动;真实 CC 回合复现 `Reached maximum number of turns (8)` 被旧代码错误记为 `transport-closed`,修复后 Tauri 已热重编译重启。修复后的真实 Provider 回放未再次发送,避免无必要的付费请求。
- 真实 AGC 项目诊断发现真实请求 HTTP 400 曾被摘要为 `codex-app-server-error:other detail=fields=codexErrorInfo`,另有 401 仅显示 `codex-app-server-error:unauthorized`;该漏损已在 app-server 投影边界修复。真实 Provider、真实 IPC 断链和真实内存压力仍未在本轮主动制造。
- 继续扫描发现素材/参考图/资源编辑上传、模型目录初始化和 Direct MCP stdio 仍有固定 `map_err(|_| ...)` 丢正文,已改为保留底层网络、serde、写入和 OS 错误;平台素材上传定向 3 项、模型目录定向 4 项、Direct MCP 断输出定向 1 项通过。资源编辑全套并行测试本轮 54 项通过、11 项因共享 fixture/本地端口或账号环境超时失败,不能把该套结果记为全绿。
- 本轮继续扫出三类未收敛口:外部 MCP HTTP 的 401/429/桥上下文缺失原先只有空状态码响应,现改为保留 HTTP 状态、稳定错误码和中文正文;认证 5xx 结构化载荷、账户/素材上传/发行发布的 401/403 现保留服务端 message/code(仍精确脱敏)。
- 前端历史错误文本此前会把含有敏感字段的整条 Direct 诊断直接丢弃,现改为只替换凭据、链接和路径;旧版已经落盘且无法恢复正文的“执行通道中断”条目会明确追加“宿主未提供具体错误正文”,不再伪装成当前仍在吞错。
- 新增/通过:外部 MCP HTTP 401/429 定向 2 项、前端 Direct/Runtime 错误正文 25 项、旧历史通用中断脱敏回归 1 项;认证 Rust 定向 19 项、素材上传响应错误定向 1 项通过。`appSurface` 全套仍有环境/fixture 相关失败,不能据此宣称全绿。