From 990b71965ccb3c6016d4c5e0ec6b5ec8b3c12675 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 25 Aug 2026 22:42:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=99=B6=E6=B3=A5=E5=84=BF?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=AF=8A=E6=96=AD=E8=84=B1=E6=95=8F=E4=B8=8E?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8F=82=E6=95=B0=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分离普通 Prompt 与错误诊断脱敏,保留安全 HTTP 字段并隐藏敏感值 补充 assetName 必填说明与 schema 回归测试 增加前端错误标记过滤及 HTTP 诊断回归测试 同步更新 AI 游戏创作智能体实施方案 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 16 +- .../src-tauri/src/agent/generation.rs | 4 +- .../src/agent/generation/prompt_context.rs | 491 ++++++++++++++++++ .../src-tauri/src/agent/runtime_state.rs | 84 ++- .../src/features/agent-runtime/model.ts | 79 ++- .../tests/agentRuntimeModel.test.ts | 57 ++ ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 7 files changed, 721 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 73d7603d4..9d38b108f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -133,7 +133,8 @@ fn direct_tools_mcp_specs() -> Value { "assetName": { "type": "string", "minLength": 1, - "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS + "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, + "description": "必填的资源显示名称,用于项目清单和恢复匹配;只填写人类可读名称,不能传项目路径、URL、objectKey、Token 或其它凭据" } }, "required": ["kind", "mode", "prompt", "assetName"], @@ -707,6 +708,19 @@ mod tests { assert!(art_tool["description"].as_str().is_some_and( |description| description.contains("模型参数和 MCP 自动批准本身不构成替换授权") )); + let resource_tool = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .find(|tool| tool["name"] == "agc_create_or_derive_resource") + .expect("resource tool"); + assert_eq!( + resource_tool["inputSchema"]["properties"]["assetName"]["description"], + "必填的资源显示名称,用于项目清单和恢复匹配;只填写人类可读名称,不能传项目路径、URL、objectKey、Token 或其它凭据" + ); + assert!(resource_tool["inputSchema"]["required"] + .as_array() + .is_some_and(|required| required.iter().any(|field| field == "assetName"))); assert_eq!( tool_art_preparation_mode(&json!({})).expect("safe default"), "reuse-or-create" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 6127502fb..9abd01bd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -102,8 +102,8 @@ pub(crate) use prompt_context::{ game_creator_planner_system_prompt, game_creator_planner_user_prompt, game_creator_system_prompt, read_optional_text, redact_secret_tokens, render_local_asset_prompt_context, render_local_conversation_prompt_context, - render_local_conversation_prompt_context_for_session, sanitize_prompt_context, - truncate_prompt_context, truncate_prompt_context_preserving_tail, + render_local_conversation_prompt_context_for_session, sanitize_error_context, + sanitize_prompt_context, truncate_prompt_context, truncate_prompt_context_preserving_tail, }; #[allow(unused_imports)] pub(crate) use role_briefs::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 4249255ed..5cc5bf4cd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -411,6 +411,497 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String { sanitized.join("\n") } +// Prompt context is deliberately fail-closed: a line which looks like a +// credential assignment is dropped in full before it can reach a model. An +// error, however, is an operator-facing diagnostic. Dropping the whole line +// there also drops the HTTP status and the provider's actionable validation +// fields, which turns a recoverable 401/403 into an opaque error. Keep this +// separate sanitizer narrow and only replace sensitive values. +const ERROR_SENSITIVE_ASSIGNMENT_KEYS: &[&str] = &[ + "proxy-authorization", + "authorization", + "set-cookie", + "cookie", + "access-token", + "access_token", + "access token", + "accesstoken", + "refresh-token", + "refresh_token", + "refresh token", + "refreshtoken", + "oauth-token", + "oauth_token", + "oauth token", + "id-token", + "id_token", + "id token", + "auth-token", + "auth_token", + "auth token", + "authtoken", + "client-secret", + "client_secret", + "client secret", + "clientsecret", + "private-key", + "private_key", + "private key", + "privatekey", + "secret-key", + "secret_key", + "secret key", + "secretkey", + "x-api-key", + "x_api_key", + "api-key", + "api_key", + "api key", + "apikey", + "credentials", + "credential", + "password", + "token", + "secret", + "bearer", +]; + +const ERROR_REDACTED_VALUE: &str = "[redacted-secret]"; +const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; + +/// Redact an error for display in Runtime state, diagnostics, and tool +/// 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. +pub(crate) fn sanitize_error_context(value: &str) -> String { + let mut sanitized = Vec::new(); + let mut inside_private_key = false; + for line in value.lines() { + let lower = line.to_ascii_lowercase(); + 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()); + inside_private_key = !(lower.contains("-----end") && lower.contains("private key")); + continue; + } + + 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") +} + +fn error_key_boundary(lower: &str, start: usize, end: usize) -> bool { + let left_is_boundary = start == 0 + || lower[..start] + .chars() + .next_back() + .is_some_and(|character| !character.is_ascii_alphanumeric()); + let right_is_boundary = end == lower.len() + || lower[end..] + .chars() + .next() + .is_some_and(|character| !character.is_ascii_alphanumeric()); + left_is_boundary && right_is_boundary +} + +fn error_assignment_value_start(lower: &str, key_end: usize) -> Option { + let mut index = key_end; + if lower.as_bytes().get(index) == Some(&b'\\') + && lower + .as_bytes() + .get(index + 1) + .is_some_and(|byte| matches!(byte, b'\'' | b'"')) + { + index += 2; + } else if matches!(lower.as_bytes().get(index), Some(b'\'' | b'"' | b'`')) { + index += 1; + } + while lower[index..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + index += lower[index..].chars().next()?.len_utf8(); + } + let delimiter = lower[index..].chars().next()?; + if !matches!(delimiter, ':' | '=') { + return None; + } + index += delimiter.len_utf8(); + if delimiter == '=' && lower.as_bytes().get(index) == Some(&b'>') { + index += 1; + } + while lower[index..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + index += lower[index..].chars().next()?.len_utf8(); + } + Some(index) +} + +fn find_error_sensitive_assignment( + line: &str, + search_from: usize, +) -> Option<(usize, usize, usize)> { + let lower = line.to_ascii_lowercase(); + let mut best: Option<(usize, usize, usize)> = None; + for key in ERROR_SENSITIVE_ASSIGNMENT_KEYS { + let mut search = search_from; + while let Some(relative) = lower[search..].find(key) { + let start = search + relative; + let end = start + key.len(); + if error_key_boundary(&lower, start, end) { + if let Some(value_start) = error_assignment_value_start(&lower, end) { + let replace = best.is_none_or(|(best_start, best_len, _)| { + start < best_start || (start == best_start && key.len() > best_len) + }); + if replace { + best = Some((start, key.len(), value_start)); + } + break; + } + } + search = end; + } + } + best +} + +fn error_sensitive_unquoted_value_end(line: &str, mut index: usize) -> usize { + while index < line.len() { + let character = line[index..].chars().next().unwrap_or_default(); + if matches!( + character, + ',' | ',' | ';' | ';' | '&' | ']' | '}' | ')' | '<' | '>' + ) { + break; + } + index += character.len_utf8(); + } + index +} + +fn error_redaction_marker_at(line: &str, value_start: usize) -> Option<(usize, String)> { + [ + "[redacted-secret]", + "", + "", + "", + "[redacted-config]", + "[redacted sensitive context]", + ] + .into_iter() + .find_map(|marker| { + line[value_start..] + .starts_with(marker) + .then(|| (value_start + marker.len(), marker.to_string())) + }) +} + +fn error_normal_quoted_value_end(line: &str, content_start: usize, quote: char) -> usize { + let mut escaped = false; + for (offset, character) in line[content_start..].char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if character == quote { + return content_start + offset + quote.len_utf8(); + } + } + line.len() +} + +fn error_escaped_quoted_value_end(line: &str, content_start: usize, quote: char) -> usize { + let mut index = content_start; + while index < line.len() { + let character = line[index..].chars().next().unwrap_or_default(); + if character == '\\' { + let next_index = index + character.len_utf8(); + if let Some(next) = line[next_index..].chars().next() { + if next == quote { + let after_quote = next_index + next.len_utf8(); + let follows_json_boundary = + line[after_quote..].chars().next().is_none_or(|character| { + character.is_whitespace() + || matches!( + character, + ',' | ',' | ';' | ';' | ':' | ':' | '}' | ']' | ')' | '&' + ) + }); + if follows_json_boundary { + return after_quote; + } + } + index = next_index + next.len_utf8(); + continue; + } + } + index += character.len_utf8(); + } + line.len() +} + +fn error_sensitive_value_replacement(line: &str, value_start: usize) -> Option<(usize, String)> { + if value_start >= line.len() { + return Some((value_start, String::new())); + } + if let Some(marker) = error_redaction_marker_at(line, value_start) { + return Some(marker); + } + let first = line[value_start..].chars().next().unwrap_or_default(); + + // JSON escaped strings are common in serialized provider errors, e.g. + // {\"token\":\"raw-secret\"}. Treat the escaped quote pair as the + // wrapper so the value after it cannot remain in the diagnostic. + if first == '\\' + && line[value_start + first.len_utf8()..] + .chars() + .next() + .is_some_and(|character| matches!(character, '\'' | '"')) + { + let quote = line[value_start + first.len_utf8()..] + .chars() + .next() + .unwrap_or('"'); + let content_start = value_start + first.len_utf8() + quote.len_utf8(); + let end = error_escaped_quoted_value_end(line, content_start, quote); + return Some((end, format!("\\{quote}{ERROR_REDACTED_VALUE}\\{quote}"))); + } + + if matches!(first, '\'' | '"') { + let quote = first; + let content_start = value_start + quote.len_utf8(); + let end = error_normal_quoted_value_end(line, content_start, quote); + let replacement = if end > content_start && line[..end].ends_with(quote) { + format!("{quote}{ERROR_REDACTED_VALUE}{quote}") + } else { + format!("{quote}{ERROR_REDACTED_VALUE}") + }; + return Some((end, replacement)); + } + + if first == '<' { + let end = line[value_start + first.len_utf8()..] + .find('>') + .map(|offset| value_start + first.len_utf8() + offset + 1) + .unwrap_or_else(|| line.len()); + return Some((end, "".to_string())); + } + + if first == '[' { + let end = line[value_start + first.len_utf8()..] + .find(']') + .map(|offset| value_start + first.len_utf8() + offset + 1) + .unwrap_or_else(|| line.len()); + return Some((end, ERROR_REDACTED_VALUE.to_string())); + } + + let lower = line[value_start..].to_ascii_lowercase(); + if lower.starts_with("bearer") + && lower["bearer".len()..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + let mut token_start = value_start + "bearer".len(); + while line[token_start..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + token_start += line[token_start..].chars().next().unwrap().len_utf8(); + } + let token_end = error_sensitive_unquoted_value_end(line, token_start); + if token_end > token_start { + let bearer = &line[value_start..value_start + "bearer".len()]; + return Some((token_end, format!("{bearer} {ERROR_REDACTED_VALUE}"))); + } + } + + // Assignment values may contain spaces (for example + // `Authorization=Basic `). Consume the whole delimited value; + // stopping at the first space would leak the remainder of a credential. + let end = error_sensitive_unquoted_value_end(line, value_start); + if end == value_start { + Some((end, String::new())) + } else { + Some((end, ERROR_REDACTED_VALUE.to_string())) + } +} + +fn error_assignment_value_replacement(line: &str, value_start: usize) -> (usize, String) { + if value_start >= line.len() { + return (value_start, String::new()); + } + + let lower = line[value_start..].to_ascii_lowercase(); + if lower.starts_with("bearer") + && lower["bearer".len()..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + let mut token_start = value_start + "bearer".len(); + while line[token_start..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + token_start += line[token_start..].chars().next().unwrap().len_utf8(); + } + if let Some((token_end, _)) = error_sensitive_value_replacement(line, token_start) { + if token_end > token_start { + let bearer = &line[value_start..value_start + "bearer".len()]; + return (token_end, format!("{bearer} {ERROR_REDACTED_VALUE}")); + } + } + } + + error_sensitive_value_replacement(line, value_start) + .unwrap_or_else(|| (value_start, String::new())) +} + +fn redact_error_sensitive_assignments(line: &str) -> String { + let mut output = String::with_capacity(line.len()); + let mut cursor = 0usize; + while let Some((key_start, key_len, value_start)) = + find_error_sensitive_assignment(line, cursor) + { + if value_start < cursor { + break; + } + // Do not retain the sensitive field name itself. A warning may be + // serialized to the Agent/UI, and names such as `api_key` or + // `Authorization` are sensitive context even after their values have + // been replaced. Preserve surrounding quotes, separators, and + // whitespace so JSON-ish diagnostics remain readable. + let key_end = key_start + key_len; + output.push_str(&line[cursor..key_start]); + output.push_str(ERROR_REDACTED_KEY); + output.push_str(&line[key_end..value_start]); + let (value_end, replacement) = error_assignment_value_replacement(line, value_start); + if value_end <= value_start { + cursor = value_start; + } else { + output.push_str(&replacement); + cursor = value_end; + } + } + output.push_str(&line[cursor..]); + output +} + +fn redact_error_bearer_values(line: &str) -> String { + let lower = line.to_ascii_lowercase(); + let mut output = String::with_capacity(line.len()); + let mut cursor = 0usize; + while let Some(relative) = lower[cursor..].find("bearer") { + let start = cursor + relative; + let end = start + "bearer".len(); + let boundary_before = start == 0 + || lower[..start] + .chars() + .next_back() + .is_some_and(|character| !character.is_ascii_alphanumeric()); + let boundary_after = end == lower.len() + || lower[end..] + .chars() + .next() + .is_some_and(|character| !character.is_ascii_alphanumeric()); + if !boundary_before || !boundary_after { + cursor = end; + continue; + } + let mut token_start = end; + while line[token_start..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + token_start += line[token_start..].chars().next().unwrap().len_utf8(); + } + let Some((token_end, replacement)) = error_sensitive_value_replacement(line, token_start) + else { + cursor = end; + continue; + }; + if token_end <= token_start { + cursor = end; + continue; + } + output.push_str(&line[cursor..token_start]); + output.push_str(&replacement); + cursor = token_end; + } + output.push_str(&line[cursor..]); + output +} + +fn redact_error_named_token(value: &str, needle: &str) -> String { + let lower = value.to_ascii_lowercase(); + let needle_lower = needle.to_ascii_lowercase(); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while let Some(relative) = lower[cursor..].find(&needle_lower) { + let start = cursor + relative; + let mut end = start + needle.len(); + while end < value.len() { + let character = value[end..].chars().next().unwrap_or_default(); + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ',' + | ';' + | ';' + | ':' + | ':' + | '&' + | ']' + | '}' + | ')' + | '<' + | '>' + ) + { + break; + } + end += character.len_utf8(); + } + output.push_str(&value[cursor..start]); + output.push_str("[redacted-config]"); + cursor = end; + } + output.push_str(&value[cursor..]); + output +} + +fn redact_error_config_names(line: &str) -> String { + [".env", "game-creator.config"] + .into_iter() + .fold(line.to_string(), |value, needle| { + redact_error_named_token(&value, needle) + }) +} + pub(crate) fn redact_secret_tokens(line: &str) -> String { let mut spans = [ ("tnr_sk_", 8usize), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 2f1829a55..6e29595eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1727,6 +1727,88 @@ mod planning_state_tests { } } +#[cfg(test)] +mod runtime_error_redaction_tests { + use super::*; + + #[test] + fn runtime_error_redaction_keeps_http_diagnostics_while_redacting_values() { + let value = concat!( + "陶泥儿请求失败:HTTP 401;code=invalid-token;field=authorization;", + "message=token=token-value-123;authorization: Bearer bearer-value-456;", + "payload={\"token\":\"json-token-value-789\",\"api_key\":\"api-value-012\"}" + ); + let redacted = redact_agent_runtime_error(Path::new("."), value, 1_000); + + assert!(redacted.contains("HTTP 401"), "{redacted}"); + assert!(redacted.contains("code=invalid-token"), "{redacted}"); + assert!(redacted.contains("field=authorization"), "{redacted}"); + assert!(redacted.contains("message="), "{redacted}"); + for secret in [ + "token-value-123", + "bearer-value-456", + "json-token-value-789", + "api-value-012", + ] { + assert!(!redacted.contains(secret), "{secret} leaked in {redacted}"); + } + assert!( + !redacted.contains("[redacted sensitive context]"), + "{redacted}" + ); + } + + #[test] + fn runtime_error_redaction_hides_private_key_and_config_names_without_losing_status() { + let value = concat!( + "平台返回 HTTP 403;reason=forbidden;detail=读取 .env.production 失败\n", + "-----BEGIN PRIVATE KEY-----\n", + "PRIVATE-KEY-VALUE-123\n", + "-----END PRIVATE KEY-----\n", + "message=拒绝访问" + ); + let redacted = redact_agent_runtime_error(Path::new("."), value, 1_000); + + assert!(redacted.contains("HTTP 403"), "{redacted}"); + assert!(redacted.contains("reason=forbidden"), "{redacted}"); + assert!(redacted.contains("message=拒绝访问"), "{redacted}"); + assert!(!redacted.contains(".env.production"), "{redacted}"); + assert!(!redacted.contains("PRIVATE-KEY-VALUE-123"), "{redacted}"); + } + + #[test] + fn runtime_error_redaction_handles_escaped_json_wrapped_bearer_and_camel_case_keys() { + let value = concat!( + r#"HTTP 401;code=invalid-token;payload={\"token\":\"json-token-value-789\",\"clientsecret\":\"client-secret-value-123\"}"#, + r#";escaped={\"token\":\"raw\"tail-secret-value\"}"#, + r#";authorization: Bearer \"quoted-bearer-value-456\";"#, + "authorization: Bearer ;", + "privatekey=private-key-value-890;secretkey=secret-key-value-901;", + "authtoken=auth-token-value-012;bearer=bare-bearer-value-345;", + "authorization => arrow-authorization-value-678" + ); + let redacted = redact_agent_runtime_error(Path::new("."), value, 2_000); + + assert!(redacted.contains("HTTP 401"), "{redacted}"); + assert!(redacted.contains("code=invalid-token"), "{redacted}"); + for secret in [ + "json-token-value-789", + "client-secret-value-123", + "tail-secret-value", + "quoted-bearer-value-456", + "angle-bearer-value-567", + "private-key-value-890", + "secret-key-value-901", + "auth-token-value-012", + "bare-bearer-value-345", + "arrow-authorization-value-678", + ] { + assert!(!redacted.contains(secret), "{secret} leaked in {redacted}"); + } + assert!(redacted.contains("[redacted-secret]"), "{redacted}"); + } +} + pub(super) fn normalize_game_creator_agent_runtime_state( state: &mut AgentRuntimeState, agent_id: &str, @@ -4210,7 +4292,7 @@ pub(crate) fn redact_agent_runtime_error(root: &Path, value: &str, max_chars: us let redacted = redact_agent_runtime_project_paths_raw(root, &redacted); let redacted = redact_absolute_path_tokens(&redacted); let redacted = redact_secret_tokens(&redacted); - let mut sanitized = sanitize_prompt_context(&redacted); + let mut sanitized = sanitize_error_context(&redacted); let preserved_markers = [ "", "$PROJECT_ROOT", diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 7bce10686..d1c3be699 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -1724,6 +1724,68 @@ 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-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(//gi, '[已隐藏链接]') + .replace(/\$PROJECT_ROOT|/g, '[已隐藏路径]') + .replace(/\[redacted-secret\]/gi, '[已隐藏凭据]') + .replace(/\[redacted-sensitive-field\]/gi, '[已隐藏敏感字段]') + .replace(/\[redacted-config\]/gi, '[已隐藏配置]') + .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]'); +} + function directCodexDiagnosticFailureDetail(message: string) { const trimmed = message.trim(); const match = @@ -1737,22 +1799,18 @@ function directCodexDiagnosticFailureDetail(message: string) { if (!stage || !retryable || !rawSummary || !rawHint) { return null; } - const redactMarkerForDisplay = (value: string) => - value - .replace(//gi, '[已隐藏链接]') - .replace(/\$PROJECT_ROOT|/g, '[已隐藏路径]') - .replace(/\[redacted-secret\]/gi, '[已隐藏凭据]'); - const summary = redactMarkerForDisplay(rawSummary) + const summary = redactDirectFailureMarkers(rawSummary) .replace(/\s+/g, ' ') .trim() .slice(0, 320); - const hint = redactMarkerForDisplay(rawHint) + const hint = redactDirectFailureMarkers(rawHint) .replace(/\s+/g, ' ') .trim() .slice(0, 180); const combined = `${summary} ${hint}`; if ( - /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test( + containsUnredactedDirectFailureSecret(combined) || + /https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( combined, ) ) { @@ -1794,13 +1852,14 @@ function directPlatformFailureDetail(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( + containsUnredactedDirectFailureSecret(trimmed) || + /https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( trimmed, ) ) { return null; } - const safe = trimmed + const safe = redactDirectFailureMarkers(trimmed) .replace(/(?:;|;|\s)operationId\s*=\s*[^;;\s]+/gi, '') .replace(/(?:;|;)\s*externalGenerationJobId\s*=\s*[^;;\s]+/gi, '') .replace(/\s+/g, ' ') diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index e31fd2d6c..07c84d184 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -718,6 +718,63 @@ describe('Agent Runtime Provider 状态投影', () => { ).toBe( '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接] [已隐藏路径] [已隐藏凭据]。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)', ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=true summary=请求失败 authorization= [redacted-config] token=[redacted-sensitive-field];建议:请检查已隐藏配置并稍后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:请求失败 authorization=[已隐藏链接] [已隐藏配置] token=[已隐藏敏感字段]。请检查已隐藏配置并稍后重试(可直接重试)', + ); + }); + + test('直连平台错误保留 HTTP 诊断字段但只显示已脱敏的敏感值', () => { + const safe = projectRuntimeVisibleError( + '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=[redacted-secret];detail=登录态已失效', + '陶泥儿智能创作', + true, + ); + expect(safe).toContain('HTTP 401'); + expect(safe).toContain('code=invalid-token'); + expect(safe).toContain('field=authorization'); + expect(safe).toContain('登录态已失效'); + expect(safe).toContain('[已隐藏凭据]'); + expect(safe).not.toContain('[redacted sensitive context]'); + + const unsafe = projectRuntimeVisibleError( + '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=raw-secret-value', + '陶泥儿智能创作', + true, + ); + expect(unsafe).not.toContain('raw-secret-value'); + expect(unsafe).toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置'); + const unsafeBearer = projectRuntimeVisibleError( + '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;bearer=raw-bearer-value', + '陶泥儿智能创作', + true, + ); + expect(unsafeBearer).not.toContain('raw-bearer-value'); + expect(unsafeBearer).toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置'); + const unsafeEscapedJson = projectRuntimeVisibleError( + String.raw`陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;payload={\"token\":\"raw-escaped-json-value\"}`, + '陶泥儿智能创作', + true, + ); + expect(unsafeEscapedJson).not.toContain('raw-escaped-json-value'); + expect(unsafeEscapedJson).toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置'); + + const markerSafe = projectRuntimeVisibleError( + '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 403;detail=authorization=[redacted-config];field=[redacted-sensitive-field];path=', + '陶泥儿智能创作', + true, + ); + expect(markerSafe).toContain('HTTP 403'); + expect(markerSafe).toContain('authorization=[已隐藏配置]'); + expect(markerSafe).toContain('field=[已隐藏敏感字段]'); + expect(markerSafe).toContain('path=[已隐藏路径]'); + expect(markerSafe).not.toContain('[redacted-sensitive-field]'); + expect(markerSafe).not.toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置'); }); test('直连 Codex 失败保留安全原因并隐藏链接与路径', () => { diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a4791cdd1..719d4c165 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,9 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-08-25 账户 / 项目画布 / 本地素材导入 + +- 普通 Prompt 上下文与错误诊断必须使用分离的脱敏边界:Prompt 继续对疑似凭据行整体隐藏;错误诊断保留 HTTP 状态以及 `code / field / message / reason / detail` 等安全字段,仅替换 Token、Cookie、私钥、配置名、URL 和宿主路径等敏感值。`agc_create_or_derive_resource.assetName` 是必填的人类可读资源显示名称,不接受项目路径、URL、objectKey、Token 或其它凭据。 + ## 2026-08-24 Direct Codex 已登记资源查询与媒体生成语义工具 - `agc_tools` 新增 `agc_list_registered_assets` 与 `agc_create_or_derive_resource`。前者按 `kind / assetId / offset / limit` 有界查询客户端权威 manifest,并可显式返回角色动画正式序列帧的稳定 objectKey、assetObjectId 和尺寸;结果不包含完整 manifest、prompt、model、provider route、签名 URL、宿主路径或凭据。后者只接受 `kind / mode / sourceLocalAssetId / prompt / assetName`,`create` 仅允许无源视频、音效和背景音乐,`derive` 必须引用当前项目已登记的 localAssetId,角色动画固定为 derive。