继续收敛遗留错误正文
外部 MCP HTTP 错误返回状态、错误码和中文正文 认证、素材上传和发行发布保留上游 401/403/5xx 详情 精确脱敏 Direct 错误并标记旧历史缺失正文 补充回归测试、诊断文档和真实客户端启动验证
This commit is contained in:
@@ -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(
|
||||
|
||||
+2
-2
@@ -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, };
|
||||
|
||||
+8
-1
@@ -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;
|
||||
}
|
||||
|
||||
/** 条目投影的脱敏:只改自由文本字段,身份与时间原样保留。 */
|
||||
|
||||
+10
-10
@@ -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:
|
||||
|
||||
+3
-1
@@ -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 相关失败,不能据此宣称全绿。
|
||||
|
||||
Reference in New Issue
Block a user