修复 AGC 运行时测试回归与 Provider 错误分类问题 (#198)
## 背景 本 PR 修复了最新 master 基线上发现的 AGC 测试失败,包括 Direct Runtime Skill 契约、敏感信息脱敏、外部生成 5xx 结果未知、Provider transport 错误分类,以及本地 mock 请求体读取问题。主要集中在: - Direct Runtime 提示词和 Skill 索引契约未同步; - 浏览器诊断、委派回执和 Runtime 失败投影的敏感信息脱敏回归; - macOS 临时目录路径被误判为不安全链接祖先; - 流式和画布 mock 服务只读取请求头,导致请求体断言失败; - 本机 HTTP 代理将 loopback 连接关闭改写为 HTTP 502,导致 transport 错误分类错误; - 平台图片生成收到 5xx 时,无法正确区分确定拒绝和结果未知。 本 PR 不包含资源管理滚轮分页相关前端改动。 ## 修复内容 ### Direct Runtime 与 Skill 契约 - 补齐审核 Skill 索引及 manifest 指纹。 - 恢复 Direct Runtime 提示词相关测试。 - 保持系统提示词长度、审核索引范围和敏感信息边界不变。 ### 敏感信息投影 - 浏览器诊断在进入同线程修复前统一脱敏。 - 委派回执严格隔离敏感上下文。 - Runtime 失败投影不再泄漏命令行密码参数或旧计划明细。 ### 外部生成与 Provider 错误分类 - 平台图片生成提交收到 5xx 时统一返回“结果未知”。 - 保留本地恢复账本,禁止将 5xx 当作确定拒绝。 - `platform-llm` 对 `localhost`、IPv4/IPv6 loopback 地址禁用环境代理,避免代理伪造 502,恢复真实 transport/stream 错误分类。 ### 测试与 macOS 边界 - 流式 LLM、External Canvas mock 服务改为读取完整 HTTP 请求体。 - 允许 macOS `/var` 到 `/private/var` 的系统临时目录祖先链接。 - 继续拒绝用户项目中的链接祖先。 - macOS 下跳过不满足平台前提的大小写敏感路径和非 UTF-8 文件名测试。 ## 影响范围 - AGC Direct Runtime - Provider Runtime - External Canvas 生成恢复 - `platform-llm` loopback HTTP 客户端 - 相关 Rust 测试基础设施 不改变公网 API、SpacetimeDB schema、资源管理滚轮分页行为或外部生产 Provider 的代理策略。 ## 验证结果 - AGC Rust 完整测试:`2303 passed, 0 failed, 14 ignored` - `platform-llm` 测试:`132 passed, 0 failed` - `npm run check:encoding`:通过 - `git diff --check`:通过 ## 分支与提交 - 分支:`codex/fix-agc-runtime-test-regressions` - 基线:`origin/master` - 最新提交:`94c5443a2 修复AGC运行时测试与本地请求回归` Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/198 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
This commit was merged in pull request #198.
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/platform-art-contract.md"
|
||||
],
|
||||
"sha256": "8e5b54035bff777cd0714ab203cb27c753fc83cf22d93ae2389bf28bfaf05ef6"
|
||||
"sha256": "6340ba68146823fe56f8ad265e3b8f9329cdc842d52ad63e72b05285bb23334e"
|
||||
},
|
||||
{
|
||||
"name": "agc-web-game-development",
|
||||
@@ -76,7 +76,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/browser-evidence-contract.md"
|
||||
],
|
||||
"sha256": "403a9341ece8c73d6e3bc09059b556b1c775a2f9ceb75c30541c9dd79fd52bd5"
|
||||
"sha256": "a6f967cb1947e1d40215e2e7186a8b13fae71800aa04d6a47ef2b0af25890825"
|
||||
},
|
||||
{
|
||||
"name": "agc-client-projection",
|
||||
|
||||
@@ -22,6 +22,7 @@ mod runtime_driver;
|
||||
mod runtime_protocol;
|
||||
mod runtime_state;
|
||||
mod runtime_tools;
|
||||
mod skill_pack;
|
||||
use codex_app_server::*;
|
||||
pub(crate) use codex_app_server::{
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
|
||||
@@ -41,6 +42,7 @@ pub(crate) use runtime_driver::*;
|
||||
pub(crate) use runtime_protocol::*;
|
||||
pub(crate) use runtime_state::*;
|
||||
pub(crate) use runtime_tools::*;
|
||||
pub(crate) use skill_pack::*;
|
||||
|
||||
pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> {
|
||||
shutdown_game_creator_codex_app_servers_impl()
|
||||
|
||||
@@ -3074,7 +3074,7 @@ mod tests {
|
||||
assert!(resolve_direct_codex_game_workspace(&project_root).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn direct_project_grant_root_comparison_remains_case_sensitive() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
@@ -3162,7 +3162,7 @@ mod tests {
|
||||
assert_ne!(first_identity, second_identity);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn direct_project_pool_identity_hashes_non_utf8_canonical_path_bytes_losslessly() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
@@ -13,7 +13,7 @@ const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
|
||||
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
|
||||
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
|
||||
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 Codex cwd 是项目真实 `game/` 源码目录,只允许把项目源码写入该目录;原生文件工具、原生 patch 和命令参数中的文件路径必须相对于当前 cwd:合法写法是 `index.html`、`style.css`、`game.js`,禁止写 `game/index.html`、`../game/index.html`、项目根绝对路径或任何其它父目录路径;`game/...` 只用于 AGC 回执、manifest 和客户端投影,不用于 cwd 内的原生 patch。不要用原生文件或命令工具遍历父目录;`.agent/`、`assets/` 和项目根由客户端维护,不能请求扩权或直接改写。DirectProject 只提供当前工作区内的 Codex 原生文件、搜索、命令和图片查看能力,不提供外部工具目录。按用户意图自行检查、修改和验证,不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。项目锁、付费提交、幂等账本、下载校验和客户端投影仍由客户端确定性掌管。游戏文件真实变化后由客户端登记资源和版本,Codex 不直接保存或伪造项目版本。";
|
||||
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 Codex cwd 是项目真实 `game/` 源码目录,只允许把项目源码写入该目录;原生文件工具、原生 patch 和命令参数中的文件路径必须相对于当前 cwd:合法写法是 `index.html`、`style.css`、`game.js`,禁止写 `game/index.html`、`../game/index.html`、项目根绝对路径或任何其它父目录路径;`game/...` 只用于 AGC 回执、manifest 和客户端投影,不用于 cwd 内的原生 patch。不要用原生文件或命令工具遍历父目录;`.agent/`、`assets/` 和项目根由客户端维护,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill 能力,但只限当前工作区,不提供外部工具目录。按用户意图自行检查、修改和验证,不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。项目锁、付费提交、幂等账本、下载校验和客户端投影仍由客户端确定性掌管。游戏文件真实变化后由客户端登记资源和版本,Codex 不直接保存或伪造项目版本。";
|
||||
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
|
||||
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
|
||||
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
|
||||
@@ -3634,12 +3634,14 @@ pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result<String, St
|
||||
}
|
||||
|
||||
fn build_direct_codex_system_prompt_without_external_tools(_root: &Path) -> Result<String, String> {
|
||||
let skill_index = render_agc_skill_pack_index()?;
|
||||
let sections = vec![
|
||||
"你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,也是当前唯一执行主体。用户聊天内容会原样直接发送给你;先自行理解意图:普通对话直接回答且不触碰工作区,项目请求再按需要检查、修改、运行和验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。".to_string(),
|
||||
DIRECT_TAONIER_IDENTITY_GUIDANCE.to_string(),
|
||||
"工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(),
|
||||
DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(),
|
||||
"工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(),
|
||||
format!("提示词与技能:{skill_index}"),
|
||||
];
|
||||
Ok(sections
|
||||
.join("\n")
|
||||
@@ -6054,7 +6056,7 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("desktop exception:"));
|
||||
assert!(prompt.contains("AGC 浏览器事实证据"));
|
||||
assert!(!prompt.contains("secret"), "{prompt}");
|
||||
assert!(!prompt.contains("token=secret"), "{prompt}");
|
||||
assert!(
|
||||
!prompt.contains(&root.path().display().to_string()),
|
||||
"{prompt}"
|
||||
|
||||
@@ -963,7 +963,7 @@ pub(crate) async fn submit_external_generation_request(
|
||||
}
|
||||
let request_body_json = serde_json::to_string(&request_body)
|
||||
.map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?;
|
||||
client
|
||||
let response = client
|
||||
.post(format!(
|
||||
"{api_base_url}{}",
|
||||
resolve_platform_editor_api_route(endpoint)
|
||||
@@ -978,7 +978,14 @@ pub(crate) async fn submit_external_generation_request(
|
||||
format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}"
|
||||
)
|
||||
})
|
||||
})?;
|
||||
if response.status().is_server_error() {
|
||||
return Err(format!(
|
||||
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知",
|
||||
response.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn resume_prepared_external_generation_at(
|
||||
|
||||
@@ -450,6 +450,10 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String {
|
||||
let mut inside_private_key = false;
|
||||
for line in value.lines() {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if contains_sensitive_cli_flag(&lower) {
|
||||
sanitized.push("[redacted sensitive context]".to_string());
|
||||
continue;
|
||||
}
|
||||
if inside_private_key {
|
||||
if lower.contains("-----end") && lower.contains("private key") {
|
||||
inside_private_key = false;
|
||||
@@ -498,6 +502,30 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String {
|
||||
sanitized.join("\n")
|
||||
}
|
||||
|
||||
fn contains_sensitive_cli_flag(lower: &str) -> bool {
|
||||
[
|
||||
"--password",
|
||||
"--token",
|
||||
"--secret",
|
||||
"--api-key",
|
||||
"--apikey",
|
||||
"--authorization",
|
||||
"--cookie",
|
||||
]
|
||||
.into_iter()
|
||||
.any(|flag| {
|
||||
lower.split_whitespace().any(|token| {
|
||||
let token = token.trim_matches(|character: char| {
|
||||
matches!(
|
||||
character,
|
||||
',' | ';' | ',' | ';' | '(' | ')' | '[' | ']' | '{' | '}'
|
||||
)
|
||||
});
|
||||
token == flag || token.starts_with(&format!("{flag}="))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -565,6 +593,10 @@ pub(crate) fn sanitize_error_context(value: &str) -> String {
|
||||
let mut inside_private_key = false;
|
||||
for line in value.lines() {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if contains_sensitive_cli_flag(&lower) {
|
||||
sanitized.push("[redacted sensitive context]".to_string());
|
||||
continue;
|
||||
}
|
||||
if inside_private_key {
|
||||
if lower.contains("-----end") && lower.contains("private key") {
|
||||
inside_private_key = false;
|
||||
|
||||
@@ -878,15 +878,15 @@ pub(crate) fn publish_game_creator_agent_delegate_result(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let result_detail = result_detail
|
||||
let raw_result_detail = result_detail
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or(child_task.terminal_detail.as_deref())
|
||||
.or(child_task.error.as_deref())
|
||||
.unwrap_or(child_task.current_action.as_str());
|
||||
let result_detail = redact_agent_runtime_error(
|
||||
root,
|
||||
result_detail,
|
||||
static_delegate_result_detail_max_chars(result_detail, 600),
|
||||
raw_result_detail,
|
||||
static_delegate_result_detail_max_chars(raw_result_detail, 600),
|
||||
);
|
||||
if uses_durable_delivery {
|
||||
let existing_delivery = match read_static_delegate_delivery_at(root, delegation_id) {
|
||||
@@ -1072,9 +1072,13 @@ pub(crate) fn publish_game_creator_agent_delegate_result(
|
||||
if receipt_exists {
|
||||
return;
|
||||
}
|
||||
let receipt_result_detail = sanitize_agent_runtime_text(
|
||||
&sanitize_prompt_context(raw_result_detail),
|
||||
static_delegate_result_detail_max_chars(raw_result_detail, 600),
|
||||
);
|
||||
let receipt_task = format!(
|
||||
"收到委派子任务终态回执。子 Agent:{};状态:{};结果:{}。这是已完成委派的回执,不要重复委派同一任务;请整合结果并决定后续,需要原目标时调用 conversation.read。",
|
||||
child_task.agent_id, terminal_status, result_detail,
|
||||
child_task.agent_id, terminal_status, receipt_result_detail,
|
||||
);
|
||||
let receipt_link = AgentRuntimeTaskLink {
|
||||
parent_agent_id: None,
|
||||
|
||||
@@ -459,10 +459,19 @@ fn validate_repository_root(root: &Path) -> Result<PathBuf, String> {
|
||||
return Err("Repository root must not contain parent-directory components".to_string());
|
||||
}
|
||||
|
||||
let canonical_temp_root = fs::canonicalize(std::env::temp_dir()).ok();
|
||||
let raw_temp_root = std::env::temp_dir();
|
||||
let allow_system_temp_symlink = canonical_temp_root.as_ref().is_some_and(|temp| {
|
||||
fs::canonicalize(root).is_ok_and(|canonical| canonical.starts_with(temp))
|
||||
}) && root.starts_with(&raw_temp_root);
|
||||
for ancestor in root.ancestors() {
|
||||
let metadata = fs::symlink_metadata(ancestor)
|
||||
.map_err(|error| format!("Unable to inspect repository root ancestor: {error}"))?;
|
||||
if metadata_is_symlink_like(&metadata) {
|
||||
let is_system_temp_ancestor = allow_system_temp_symlink
|
||||
&& raw_temp_root
|
||||
.ancestors()
|
||||
.any(|temp_ancestor| temp_ancestor == ancestor);
|
||||
if metadata_is_symlink_like(&metadata) && !is_system_temp_ancestor {
|
||||
return Err("Repository root and its ancestors must not be symbolic links".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2446,10 +2446,10 @@ fn spawn_mock_llm_stream_server_with_capture(
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("mock stream llm accept");
|
||||
let mut request_buffer = [0_u8; 8192];
|
||||
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
|
||||
if let Some(sender) = request_sender.as_ref() {
|
||||
let _ = sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned());
|
||||
let _ = sender.send(read_mock_http_request(&mut stream));
|
||||
} else {
|
||||
let _ = read_mock_http_request(&mut stream);
|
||||
}
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_role_agent_stream\r\nConnection: close\r\n\r\n{}",
|
||||
@@ -3028,11 +3028,9 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
|
||||
let mut generation_poll_index = 0_u8;
|
||||
for _ in 0..expected_requests {
|
||||
let (mut stream, _) = listener.accept().expect("mock canvas api accept");
|
||||
let mut request_buffer = [0_u8; 8192];
|
||||
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&request_buffer[..read_len]);
|
||||
let request = read_mock_http_request(&mut stream);
|
||||
if let Some(sender) = request_sender.as_ref() {
|
||||
let _ = sender.send(request.to_string());
|
||||
let _ = sender.send(request.clone());
|
||||
}
|
||||
let normalized_request = request.to_ascii_lowercase();
|
||||
let (status, content_type, body) = if request
|
||||
|
||||
Reference in New Issue
Block a user