feat(logging): persist diagnostics across restarts and redact secrets

Backend half of the logging overhaul.

Retention:
- Keep the last 4 rotated files at 20MB each and stop deleting logs on
  startup, so a crash's prior-run logs survive a restart.
- Apply the persisted log level right after plugin registration instead
  of at the end of setup.
- Bound crash.log with size-based rotation (5MB x 2).

Secret redaction on every log path:
- Proxy: strip userinfo/query from upstream URLs (keep path for
  diagnostics), exact-match redact known auth.api_key/access_token,
  classify request/response bodies instead of logging them, header
  allowlist.
- Redact the Gemini `?key=` in cache-trace endpoints.
- Omit MCP custom-field values (headers may carry tokens).
- Redact deeplink and model-fetch URLs before logging.

Docs:
- FAQ points users to the persistent crash.log for support workflows.
This commit is contained in:
Jason
2026-07-16 10:20:44 +08:00
parent 7f028632b5
commit 22d2872c33
14 changed files with 655 additions and 224 deletions
+44 -6
View File
@@ -84,7 +84,9 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
// 核心字段(需要手动处理的字段)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "http_headers"],
// DB 中的统一规范使用 headersCodex TOML 使用 http_headers。
// 两者都必须视为核心字段,避免鉴权值落入通用日志路径。
"http" | "sse" => vec!["type", "url", "headers", "http_headers"],
_ => vec!["type"],
};
@@ -204,7 +206,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
if let Some(val) = json_val {
spec.insert(key.clone(), val);
log::debug!("导入扩展字段 '{key}' = {toml_val:?}");
log::debug!("导入扩展字段 '{key}'(值已省略)");
}
}
@@ -567,7 +569,7 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
// 定义核心字段(已在下方处理,跳过通用转换)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "http_headers"],
"http" | "sse" => vec!["type", "url", "headers", "http_headers"],
_ => vec!["type"],
};
@@ -664,11 +666,11 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
if let Some(toml_item) = json_value_to_toml_item(value, key) {
t[&key[..]] = toml_item;
// 记录扩展字段的处理
// 记录字段名:未知字段同样可能携带 token / secret。
if extended_fields.contains(&key.as_str()) {
log::debug!("已转换扩展字段 '{key}' = {value:?}");
log::debug!("已转换扩展字段 '{key}'(值已省略)");
} else {
log::info!("已转换自定义字段 '{key}' = {value:?}");
log::debug!("已转换自定义字段 '{key}'(值已省略)");
}
}
}
@@ -676,3 +678,39 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
Ok(t)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_headers_are_only_written_to_codex_http_headers() {
let table = json_server_to_toml_table(&json!({
"type": "http",
"url": "https://mcp.example.com",
"headers": {
"Authorization": "Bearer top-secret",
"X-Api-Key": "also-secret"
},
"timeout": 30
}))
.unwrap();
let headers = table
.get("http_headers")
.and_then(|item| item.as_table())
.expect("Codex http_headers table should be written");
assert_eq!(
headers.get("Authorization").and_then(|item| item.as_str()),
Some("Bearer top-secret")
);
assert!(
table.get("headers").is_none(),
"legacy headers must not be emitted a second time"
);
assert_eq!(
table.get("timeout").and_then(|item| item.as_integer()),
Some(30)
);
}
}