diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1bf8d12cf..196c36011 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 派生 Debug 会让完整配置经应用状态递归进入日志 + +- 现象:配置和状态当前没有直接日志调用,但新增一行 `debug!(?state, ...)` 或 `format!("{config:?}")` 就能把 JWT、后台口令、支付私钥、OSS / provider key 与 SpacetimeDB token 一次性写入日志及 OTel 留存面。 +- 原因:`AppConfig`、`AppState` 与 `AppStateInner` 曾使用派生 `Debug`;状态继续递归格式化多个含配置的 client。即使顶层状态停止下钻,`SpacetimeClientConfig` 及 `SpacetimeClient` 的独立手写路径仍会绕过顶层防线。 +- 处理:配置和聚合状态只实现封闭的手写安全摘要,不格式化任一自由字符串或含凭据的嵌套 client;`SpacetimeClientConfig` 独立脱敏,`SpacetimeClient` 只复用该安全摘要。不要以默认 `info` 级别或当前零调用点代替代码约束。 +- 验证:同一唯一哨兵同时填入全部凭据字段、可能带凭据的 SpacetimeDB URL 和数据库名,逐一格式化 `AppConfig`、`AppStateInner`、`AppState`、`SpacetimeClientConfig`、`SpacetimeClient`,断言哨兵零出现且安全运行摘要仍存在。 +- 关联:`server-rs/crates/api-server/src/config.rs`、`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/spacetime-client/src/active.rs`、Issue #148。 + ## Chat 生成预算字段不能按模型名猜测或失败后自动重放 - 现象:同一个 OpenAI-compatible Chat endpoint 调用 reasoning 模型时返回 `Unsupported parameter: max_tokens`;直接把全局请求字段改成 `max_completion_tokens` 后,旧兼容网关又可能拒绝新字段。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index aa8fe5978..3c1046fe8 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -47,6 +47,7 @@ - 新增 Markdown 文档时,文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;只在任务需要时重命名历史文档,避免无关大 diff。 - 涉及中文文本时注意 UTF-8 编码和乱码排查。 - 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。 +- 运行时日志禁止对完整配置、应用状态或 provider client 做递归 `Debug` 输出;当前 `AppConfig`、`AppState`、`AppStateInner`、`SpacetimeClientConfig` 与 `SpacetimeClient` 必须保持封闭的手写安全摘要,并用唯一哨兵测试锁定顶层与可独立格式化路径。其它仍使用派生 `Debug` 的历史 provider 类型不得新增整对象日志调用,后续按类型独立脱敏。新增字段默认不进入摘要,确需排障时只增加枚举、数值、布尔值或是否配置等非敏感字段。 - `packages/shared` 用于前后端 DTO、公开契约及跨页面复用的无业务真相 UI 组件和纯工具;不得把领域规则、后端副作用或正式状态放入其中。 - 修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义时,必须同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 和对应契约测试;Rust 实现与 OpenAPI 未对齐时不得完成、提交或发布。 - `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run dev:api-server` 与 `/healthz`。 diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 77eb6a15c..03ae99869 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1,4 +1,4 @@ -use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration}; +use std::{env, fmt, fs, net::SocketAddr, path::PathBuf, time::Duration}; use platform_llm::{ DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS, @@ -27,7 +27,7 @@ const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct AppConfig { pub bind_host: String, pub bind_port: u16, @@ -212,6 +212,41 @@ pub struct AppConfig { pub slow_request_threshold_ms: u64, } +impl fmt::Debug for AppConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // 配置由环境变量和私密文件聚合而来。这里只输出排障所需的封闭运行摘要, + // 不递归格式化任一字符串配置,避免 URL 凭据、Token、私钥或未来新增字段进入日志。 + f.debug_struct("AppConfig") + .field("bind_port", &self.bind_port) + .field("listen_backlog", &self.listen_backlog) + .field("worker_threads", &self.worker_threads) + .field("process_role", &self.process_role) + .field("external_generation_mode", &self.external_generation_mode) + .field( + "external_generation_worker_concurrency", + &self.external_generation_worker_concurrency, + ) + .field("max_concurrent_requests", &self.max_concurrent_requests) + .field( + "admin_max_concurrent_requests", + &self.admin_max_concurrent_requests, + ) + .field("spacetime_pool_size", &self.spacetime_pool_size) + .field("sms_auth_enabled", &self.sms_auth_enabled) + .field("wechat_auth_enabled", &self.wechat_auth_enabled) + .field("wechat_pay_enabled", &self.wechat_pay_enabled) + .field("aliyun_matting_enabled", &self.aliyun_matting_enabled) + .field("tracking_outbox_enabled", &self.tracking_outbox_enabled) + .field( + "wallet_refund_outbox_enabled", + &self.wallet_refund_outbox_enabled, + ) + .field("otel_enabled", &self.otel_enabled) + .field("credentials", &"") + .finish_non_exhaustive() + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ProcessRole { Api, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 2b8721595..7b19f6ed1 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -122,9 +122,15 @@ impl BackpressureState { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct AppState(Arc); +impl fmt::Debug for AppState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("AppState").field(self.0.as_ref()).finish() + } +} + impl std::ops::Deref for AppState { type Target = AppStateInner; @@ -225,7 +231,6 @@ impl FromRef for PuzzleApiState { } // Axum/Hyper 会在路由树和连接 service 上频繁 clone state;AppState 外层必须保持浅拷贝。 -#[derive(Debug)] pub struct AppStateInner { // 配置会在后续中间件、路由和平台适配接入时逐步消费。 #[allow(dead_code)] @@ -285,6 +290,35 @@ pub struct AppStateInner { test_runtime_snapshot_store: Arc>>, } +impl fmt::Debug for AppStateInner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // AppState 聚合多个仍可独立 Debug 的平台 client。这里使用封闭摘要,禁止 Debug + // 递归下钻到 JWT、管理员口令、OSS、微信、LLM 等运行时凭据。 + f.debug_struct("AppStateInner") + .field("config", &self.config) + .field("ready", &self.ready.load(Ordering::Relaxed)) + .field( + "bgfilter_worker_reached", + &self.bgfilter_worker_reached.load(Ordering::Relaxed), + ) + .field("admin_runtime_enabled", &self.admin_runtime.is_some()) + .field("oss_client_enabled", &self.oss_client.is_some()) + .field("spacetime_client", &self.spacetime_client) + .field("tracking_outbox_enabled", &self.tracking_outbox.is_some()) + .field( + "wallet_refund_outbox_enabled", + &self.wallet_refund_outbox.is_some(), + ) + .field("llm_client_enabled", &self.llm_client.is_some()) + .field( + "editor_agent_llm_client_enabled", + &self.editor_agent_llm_client.is_some(), + ) + .field("matting_client_enabled", &self.matting_client.is_some()) + .finish_non_exhaustive() + } +} + #[derive(Clone, Debug)] #[cfg(any())] struct CreativeAgentSessionRuntimeRecord { @@ -2253,6 +2287,105 @@ mod tests { use super::*; + #[test] + fn debug_summaries_redact_all_runtime_credentials() { + const SENSITIVE_KEY_LURE: &str = "ISSUE_148_DEBUG_SECRET_LURE"; + + let secret = || Some(SENSITIVE_KEY_LURE.to_string()); + let config = AppConfig { + bgfilter_internal_token: secret(), + editor_bgfilter_token: secret(), + aliyun_matting_access_key_id: secret(), + aliyun_matting_access_key_secret: secret(), + admin_username: Some("debug-admin".to_string()), + admin_password: secret(), + internal_api_secret: secret(), + jwt_secret: SENSITIVE_KEY_LURE.to_string(), + sms_access_key_id: secret(), + sms_access_key_secret: secret(), + sms_mock_verify_code: SENSITIVE_KEY_LURE.to_string(), + wechat_app_secret: secret(), + wechat_mini_program_app_secret: secret(), + wechat_pay_private_key_pem: secret(), + wechat_pay_private_key_path: Some(std::path::PathBuf::from(SENSITIVE_KEY_LURE)), + wechat_pay_platform_public_key_pem: secret(), + wechat_pay_platform_public_key_path: Some(std::path::PathBuf::from(SENSITIVE_KEY_LURE)), + wechat_pay_api_v3_key: secret(), + wechat_mini_program_virtual_payment_offer_id: secret(), + wechat_mini_program_virtual_payment_app_key: secret(), + wechat_mini_program_virtual_payment_sandbox_app_key: secret(), + wechat_mini_program_message_token: secret(), + wechat_mini_program_message_encoding_aes_key: secret(), + oss_bucket: Some("debug-bucket".to_string()), + oss_endpoint: Some("oss.example.invalid".to_string()), + oss_access_key_id: secret(), + oss_access_key_secret: secret(), + spacetime_server_url: format!("https://spacetime.invalid/?token={SENSITIVE_KEY_LURE}"), + spacetime_database: format!("debug-{SENSITIVE_KEY_LURE}"), + spacetime_token: secret(), + spacetime_runtime_service_bootstrap_secret: secret(), + llm_base_url: "https://llm.example.invalid".to_string(), + llm_api_key: secret(), + llm_model: "debug-model".to_string(), + dashscope_api_key: secret(), + vector_engine_base_url: "https://vector.example.invalid".to_string(), + vector_engine_api_key: secret(), + hyper3d_api_key: secret(), + volcengine_speech_api_key: secret(), + volcengine_speech_app_id: secret(), + volcengine_speech_access_key: secret(), + ark_character_video_api_key: secret(), + ..AppConfig::default() + }; + + let spacetime_config = spacetime_client_config_for_process(&config); + let spacetime_client = SpacetimeClient::new(spacetime_config.clone()); + let state = AppState::new(config.clone()).expect("state should build"); + let config_debug = format!("{config:?}"); + let expected_config_debug = format!( + "AppConfig {{ bind_port: {:?}, listen_backlog: {:?}, worker_threads: {:?}, process_role: {:?}, external_generation_mode: {:?}, external_generation_worker_concurrency: {:?}, max_concurrent_requests: {:?}, admin_max_concurrent_requests: {:?}, spacetime_pool_size: {:?}, sms_auth_enabled: {:?}, wechat_auth_enabled: {:?}, wechat_pay_enabled: {:?}, aliyun_matting_enabled: {:?}, tracking_outbox_enabled: {:?}, wallet_refund_outbox_enabled: {:?}, otel_enabled: {:?}, credentials: \"\", .. }}", + config.bind_port, + config.listen_backlog, + config.worker_threads, + config.process_role, + config.external_generation_mode, + config.external_generation_worker_concurrency, + config.max_concurrent_requests, + config.admin_max_concurrent_requests, + config.spacetime_pool_size, + config.sms_auth_enabled, + config.wechat_auth_enabled, + config.wechat_pay_enabled, + config.aliyun_matting_enabled, + config.tracking_outbox_enabled, + config.wallet_refund_outbox_enabled, + config.otel_enabled, + ); + assert_eq!( + config_debug, expected_config_debug, + "AppConfig Debug 只能输出显式允许的枚举、数值、布尔值和脱敏占位;新增自由字符串必须默认缺席" + ); + + let outputs = [ + ("AppConfig", config_debug.clone()), + ("SpacetimeClientConfig", format!("{spacetime_config:?}")), + ("SpacetimeClient", format!("{spacetime_client:?}")), + ("AppStateInner", format!("{:?}", state.0.as_ref())), + ("AppState", format!("{state:?}")), + ]; + + for (type_name, output) in outputs { + assert!( + !output.contains(SENSITIVE_KEY_LURE), + "{type_name} Debug leaked the credential lure: {output}" + ); + } + assert!(config_debug.contains("process_role")); + assert!(config_debug.contains("")); + assert!(format!("{spacetime_config:?}").contains("pool_size")); + assert!(format!("{state:?}").contains("ready: true")); + } + #[test] fn app_state_reuses_character_animation_oss_client_and_eight_permits() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 91859574b..dc9096de5 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -106,7 +106,7 @@ use tracing::warn; use crate::module_bindings::*; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct SpacetimeClientConfig { pub server_url: String, pub database: String, @@ -116,6 +116,23 @@ pub struct SpacetimeClientConfig { pub subscribe_cached_read_models: bool, } +impl fmt::Debug for SpacetimeClientConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // server_url 也可能包含 userinfo 或查询凭据,因此与 token 一并只输出安全摘要。 + f.debug_struct("SpacetimeClientConfig") + .field("server_url_configured", &!self.server_url.trim().is_empty()) + .field("database_configured", &!self.database.trim().is_empty()) + .field("token", &"") + .field("pool_size", &self.pool_size) + .field("procedure_timeout", &self.procedure_timeout) + .field( + "subscribe_cached_read_models", + &self.subscribe_cached_read_models, + ) + .finish() + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SpacetimeClientStage { Ready,