修复陶泥儿错误诊断脱敏与资源参数提示

分离普通 Prompt 与错误诊断脱敏,保留安全 HTTP 字段并隐藏敏感值
补充 assetName 必填说明与 schema 回归测试
增加前端错误标记过滤及 HTTP 诊断回归测试
同步更新 AI 游戏创作智能体实施方案
This commit is contained in:
2026-08-25 22:42:28 +08:00
parent 05e11b11ca
commit 990b71965c
7 changed files with 721 additions and 14 deletions
@@ -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"
@@ -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::{
@@ -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<usize> {
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-secret>",
"<redacted-url>",
"<absolute-path>",
"[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, "<redacted-secret>".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 <token>`). 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),
@@ -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 <angle-bearer-value-567>;",
"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 = [
"<redacted-url>",
"$PROJECT_ROOT",
@@ -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-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, '[已隐藏链接]')
.replace(/\$PROJECT_ROOT|<absolute-path>/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(/<redacted-url>/gi, '[已隐藏链接]')
.replace(/\$PROJECT_ROOT|<absolute-path>/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, ' ')
@@ -718,6 +718,63 @@ describe('Agent Runtime Provider 状态投影', () => {
).toBe(
'陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接] [已隐藏路径] [已隐藏凭据]。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)',
);
expect(
projectRuntimeVisibleError(
'direct-codex-failure:v1 stage=art-preparation retryable=true summary=请求失败 authorization=<redacted-url> [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=<absolute-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 失败保留安全原因并隐藏链接与路径', () => {
@@ -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。