修复 DirectProject 流式正文逐 delta 脱敏吃掉段尾换行导致 Markdown 结构损坏
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m58s
Project CI / Backend tests (pull_request) Successful in 4m7s
Project CI / Frontend tests (pull_request) Successful in 2m13s
Project CI / Native shell tests (pull_request) Successful in 6m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
Project CI / Repository checks (pull_request) Successful in 2m15s

- prompt_context.rs:sanitize_error_context 改为 split_inclusive('\n') 逐段处理并原样保留行终止符,「逐段脱敏」与「整段脱敏」同形(CRLF 仍归一成 LF,私钥块整行吞掉的旧口径不变)
- codex_app_server/mod.rs:新增 direct_thread_delta_sanitization_preserves_line_breaks,钉住逐段脱敏 == 整段脱敏,去掉 split_inclusive 即红
- pitfalls.md:记入本次排障(含 chunk 起点路径误判这一同源未修缺陷)
This commit is contained in:
2026-09-29 21:30:44 +08:00
parent a5664cb1c9
commit 2cc7df8074
3 changed files with 63 additions and 17 deletions
@@ -6006,6 +6006,35 @@ mod tests {
}
}
/// 流式脱敏必须保留增量自带的换行:前端把每个 `item.delta` 的脱敏结果**直接拼接**成一条
/// 消息再交给 Markdown 渲染,所以「逐段脱敏」必须与「整段脱敏」同形,不能吃掉段尾换行。
///
/// 回归背景:`sanitize_error_context` 曾用 `lines()` + `join("\n")` 往返,把以换行结尾的段
/// 的末尾换行吃掉。真实会话里段落、列表项和表格行会因此并进同一行(表头、`|---|---|` 与
/// 数据行挤成一段正文),整条汇报的 Markdown 结构——尤其表格——直接失效。
#[test]
fn direct_thread_delta_sanitization_preserves_line_breaks() {
let root = std::path::Path::new("/workspace/direct-project");
let chunks = [
"共 4 项验收要求。\n",
"\n",
"| 素材 | 用途 |\n",
"|---|---|\n",
"| 飞鸟角色 | 玩家角色 |\n",
];
let streamed: String = chunks
.iter()
.map(|chunk| direct_thread_delta_text(root, chunk))
.collect();
let whole = chunks.concat();
assert_eq!(streamed, whole, "逐段脱敏不得吃掉段尾换行");
assert_eq!(
direct_thread_delta_text(root, &whole),
streamed,
"逐段脱敏与整段脱敏必须同形"
);
}
/// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。
///
/// 参数:method / params / 正文候选 / 安全活动类别 / turnId。
@@ -623,34 +623,42 @@ const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]";
/// results. This intentionally does not call `sanitize_prompt_context`: the
/// latter is stricter by design and would erase useful HTTP status/code/field
/// information whenever a safe error line mentions a credential field.
///
/// 逐行脱敏,但**保留每个 chunk 末尾的换行**:本函数会被流式增量逐段调用
/// (`direct_thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用
/// `lines()` + `join("\n")` 会把「以换行结尾的段」的末尾换行吃掉,拼接后段落、列表项和表格行
/// 会并进同一行,整条消息的 Markdown 结构(尤其表格)就作废了。
pub(crate) fn sanitize_error_context(value: &str) -> String {
let mut sanitized = Vec::new();
let mut sanitized = String::with_capacity(value.len());
let mut inside_private_key = false;
for line in value.lines() {
for chunk in value.split_inclusive('\n') {
let terminated = chunk.ends_with('\n');
let line = chunk.strip_suffix('\n').unwrap_or(chunk);
let line = line.strip_suffix('\r').unwrap_or(line);
let lower = line.to_ascii_lowercase();
if contains_sensitive_cli_flag(&lower) {
sanitized.push("[redacted sensitive context]".to_string());
continue;
}
if inside_private_key {
sanitized.push_str("[redacted sensitive context]");
} else if inside_private_key {
if lower.contains("-----end") && lower.contains("private key") {
inside_private_key = false;
}
// 私钥块整行吞掉(连带它的换行),与旧口径一致,不补空行。
continue;
}
if lower.contains("-----begin") && lower.contains("private key") {
sanitized.push("[redacted sensitive context]".to_string());
} else if lower.contains("-----begin") && lower.contains("private key") {
sanitized.push_str("[redacted sensitive context]");
inside_private_key = !(lower.contains("-----end") && lower.contains("private key"));
continue;
} else {
let line = redact_secret_tokens(line);
let line = redact_error_sensitive_assignments(&line);
let line = redact_error_bearer_values(&line);
let line = redact_error_config_names(&line);
sanitized.push_str(&redact_secret_tokens(&line));
}
if terminated {
sanitized.push('\n');
}
let line = redact_secret_tokens(line);
let line = redact_error_sensitive_assignments(&line);
let line = redact_error_bearer_values(&line);
let line = redact_error_config_names(&line);
sanitized.push(redact_secret_tokens(&line));
}
sanitized.join("\n")
sanitized
}
fn error_key_boundary(lower: &str, start: usize, end: usize) -> bool {