From 0951bb2013f0a631d3c7cd77f03ffda5bb7b4aa9 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 31 Aug 2026 10:21:04 +0000 Subject: [PATCH] =?UTF-8?q?=E8=AE=A9=20Direct=20GUI=20=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E8=90=BD=E4=B8=8B=E6=9C=89=E7=95=8C=E8=A1=8C=E4=B8=BA=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E8=B4=A6=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DirectCodexTurnAudit,把 item/completed 抽成项目内 jsonl 与 agent.db 摘要 - GUI Direct 回合记录 sidecar 路径、native 读/MCP/写文件、offeredRead 与 firstDesign - 不落 stdout、patch、MCP result,不进聊天 jsonl,失败不阻断做游戏 - 补充技术方案、decision-log 与文档索引 --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/codex_app_server.rs | 34 +- .../src/agent/direct_codex_attachments.rs | 31 +- .../src-tauri/src/agent/direct_codex_audit.rs | 1458 +++++++++++++++++ .../src-tauri/src/agent/direct_runtime.rs | 68 +- docs/README.md | 1 + .../shared-memory/decision-log.md | 8 + .../shared-memory/document-map.md | 7 +- ...】DirectProject本轮附件路径映射-2026-08-31.md | 2 +- ...术方案】Direct回合行为审计账本-2026-08-31.md | 389 +++++ ...irectProject未消费用户上传权威文档-2026-08-30.md | 1 + 11 files changed, 1955 insertions(+), 46 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs create mode 100644 docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 27531bafd..bc2bedb87 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,6 +13,7 @@ mod codex_app_server; mod codex_cli; mod codex_provider_proxy; mod direct_codex_attachments; +mod direct_codex_audit; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -36,6 +37,7 @@ pub(crate) use codex_cli::{ }; pub(crate) use codex_provider_proxy::*; pub(crate) use direct_codex_attachments::*; +pub(crate) use direct_codex_audit::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 5a1d30e9f..6d3aa6237 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1905,8 +1905,15 @@ impl CodexAppServerConnection { request: LlmRunRequest, on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, ) -> Result { - self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None) - .await + self.run_turn_with_direct_observer( + snapshot, + llm, + request, + on_agent_message_delta, + None, + None, + ) + .await } async fn run_turn_with_direct_observer( @@ -1916,6 +1923,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + mut audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; @@ -2085,6 +2093,11 @@ impl CodexAppServerConnection { completed, ¶ms, ); + if completed { + if let Some(audit) = audit.as_mut() { + audit.observe_item(¶ms); + } + } } if item_type == "agentMessage" { if let Some(text) = item @@ -2796,8 +2809,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at( system_prompt: String, user_prompt: String, ) -> Result { - direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None) - .await + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + user_prompt, + None, + None, + ) + .await } pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( @@ -2811,6 +2830,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( system_prompt, user_prompt, Some(observer), + None, ) .await } @@ -2861,11 +2881,12 @@ fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) format!("{:x}", digest.finalize()) } -async fn direct_game_creator_codex_chat_at_with_optional_observer( +pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -2917,7 +2938,7 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer( .await .map_err(|error| error.to_string())?; connection - .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer) + .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) .await .map(|value| value.text) .map_err(|error| error.to_string()) @@ -4245,6 +4266,7 @@ while IFS= read -r line; do :; done tool_request(), Some(&mut on_delta), Some(&mut observer), + None, ) .await .expect("run fake app-server turn"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs index f788b42f2..8cf49e017 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs @@ -1,7 +1,7 @@ //! Direct Codex 本轮附件 sidecar:Home 与 Project 共用同一 DTO 和渲染函数。 //! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。 -const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8; const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; @@ -14,17 +14,17 @@ const PROJECT_ATTACHMENT_HEADER: &str = #[derive(Clone, Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectCodexTurnAttachment { - name: String, - media_type: String, + pub(crate) name: String, + pub(crate) media_type: String, #[serde(default)] - size: u64, + pub(crate) size: u64, #[serde(default)] - local_path: Option, + pub(crate) local_path: Option, #[serde(default)] - status: Option, + pub(crate) status: Option, } -fn sanitize_attachment_name(value: &str) -> String { +pub(crate) fn sanitize_attachment_name(value: &str) -> String { let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); let sanitized = basename .chars() @@ -38,7 +38,7 @@ fn sanitize_attachment_name(value: &str) -> String { } } -fn sanitize_attachment_media_type(value: &str) -> String { +pub(crate) fn sanitize_attachment_media_type(value: &str) -> String { let value = value.trim(); if value.is_empty() || value.chars().any(|character| { @@ -54,7 +54,7 @@ fn sanitize_attachment_media_type(value: &str) -> String { } } -fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> { +pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> { match value.map(str::trim) { Some("imported") => Some("imported"), Some("failed") => Some("failed"), @@ -62,7 +62,7 @@ fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> { } } -fn sanitize_attachment_local_path(value: &str) -> Option { +pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() || trimmed.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS @@ -104,7 +104,7 @@ fn sanitize_attachment_local_path(value: &str) -> Option { Some(path) } -fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool { +pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool { attachments.iter().any(|attachment| { attachment .local_path @@ -421,13 +421,8 @@ mod tests { #[test] fn unknown_status_keeps_home_attachment_metadata_shape() { - let attachment = project_attachment( - "pending.md", - "text/markdown", - 1, - None, - Some("pending"), - ); + let attachment = + project_attachment("pending.md", "text/markdown", 1, None, Some("pending")); let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render"); assert!(prompt.contains(HOME_ATTACHMENT_HEADER)); assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER)); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs new file mode 100644 index 000000000..bc34a6b4c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -0,0 +1,1458 @@ +//! Direct Codex GUI 回合行为账本:把 item/completed 抽成项目内有界时间线。 +//! 不灌附件正文、不落 stdout / patch / MCP result,不进入前端观察者。 + +use super::*; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DIRECT_CODEX_AUDIT_HASH_MAX_BYTES: u64 = 2 * 1024 * 1024; +const DIRECT_CODEX_AUDIT_MAX_ITEMS: usize = 256; +const DIRECT_CODEX_AUDIT_COMMAND_CHARS: usize = 240; +const DIRECT_CODEX_AUDIT_BRIEF_CHARS: usize = 4000; +const DIRECT_CODEX_AUDIT_PREVIEW_CHARS: usize = 240; +const DIRECT_CODEX_AUDIT_QUERY_CHARS: usize = 400; +const DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS: usize = 120; +const DIRECT_CODEX_AUDIT_ID_LIST_MAX: usize = 8; +const DIRECT_CODEX_AUDIT_TURN_LOG_DIR: &str = ".agent/runtime/direct-codex/turns"; + +const SKIPPED_ITEM_TYPES: &[&str] = &[ + "agentMessage", + "userMessage", + "plan", + "reasoning", + "contextCompaction", + "hookPrompt", +]; + +const DESIGN_MCP_TOOLS: &[&str] = &[ + "taonier_prepare_game_art", + "agc_generate_image", + "agc_edit_image", + "agc_create_or_derive_resource", +]; + +struct OfferedAttachment { + local_path: String, + content_sha256: Option, + read: bool, + content_sha256_match: Option, +} + +pub(crate) struct DirectCodexTurnAudit { + root: PathBuf, + client_turn_id: String, + log_path: PathBuf, + turn_log_relative: String, + sidecar_present: bool, + offered: Vec, + item_count: usize, + items_truncated: bool, + truncated_written: bool, + first_design: Option, + audit_write_failed: bool, + finished: bool, +} + +impl DirectCodexTurnAudit { + pub(crate) fn start( + root: &Path, + client_turn_id: &str, + original_prompt: &str, + attachments: &[DirectCodexTurnAttachment], + ) -> Self { + let turn_log_relative = format!("{DIRECT_CODEX_AUDIT_TURN_LOG_DIR}/{client_turn_id}.jsonl"); + let log_path = root.join(&turn_log_relative); + let sidecar_present = attachments_use_project_mapping(attachments); + let (attachment_values, offered) = project_audit_attachments(root, attachments); + let mut audit = Self { + root: root.to_path_buf(), + client_turn_id: client_turn_id.to_string(), + log_path, + turn_log_relative, + sidecar_present, + offered, + item_count: 0, + items_truncated: false, + truncated_written: false, + first_design: None, + audit_write_failed: false, + finished: false, + }; + let omitted = attachments + .len() + .saturating_sub(MAX_DIRECT_CODEX_ATTACHMENTS); + let mut record = json!({ + "recordType": "direct.codex.turn_start", + "clientTurnId": client_turn_id, + "sidecarPresent": sidecar_present, + "promptSha256": sha256_hex(original_prompt.as_bytes()), + "promptChars": original_prompt.chars().count(), + "attachments": attachment_values, + }); + if omitted > 0 { + record["attachmentsOmitted"] = json!(omitted); + } + audit.append_record(record); + audit + } + + pub(crate) fn observe_item(&mut self, params: &Value) { + if self.finished { + return; + } + let Some(item) = params.get("item") else { + return; + }; + let item_type = item + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if SKIPPED_ITEM_TYPES.contains(&item_type) { + return; + } + if self.item_count >= DIRECT_CODEX_AUDIT_MAX_ITEMS { + self.items_truncated = true; + if !self.truncated_written { + self.truncated_written = true; + self.append_record(json!({ + "recordType": "direct.codex.items_truncated", + "clientTurnId": self.client_turn_id, + "droppedAfter": DIRECT_CODEX_AUDIT_MAX_ITEMS, + })); + } + return; + } + + self.item_count = self.item_count.saturating_add(1); + let seq = self.item_count; + let mut record = json!({ + "recordType": "direct.codex.item", + "clientTurnId": self.client_turn_id, + "seq": seq, + "itemType": item_type, + }); + if let Some(item_id) = item + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + { + record["itemId"] = json!(item_id); + } + if let Some(status) = item.get("status").and_then(Value::as_str) { + record["status"] = json!(status); + } else { + record["status"] = json!("completed"); + } + + match item_type { + "commandExecution" => self.fill_command_execution(&mut record, item), + "mcpToolCall" => self.fill_mcp_tool_call(&mut record, item, seq), + "fileChange" => self.fill_file_change(&mut record, item, seq), + "imageView" => self.fill_image_view(&mut record, item), + "functionCallOutput" => fill_function_call_output(&mut record, item), + "webSearch" => fill_web_search(&mut record, item), + _ => {} + } + + self.append_record(record); + } + + pub(crate) fn finish(&mut self, completed: bool) { + if self.finished { + return; + } + self.finished = true; + let offered_read = self.offered_read_values(); + let record = json!({ + "recordType": "direct.codex.turn_end", + "clientTurnId": self.client_turn_id, + "completed": completed, + "itemCount": self.item_count, + "itemsTruncated": self.items_truncated, + "offeredRead": offered_read, + "firstDesign": self.first_design.clone(), + }); + self.append_record(record); + let summary = json!({ + "recordType": "direct.codex.turn", + "clientTurnId": self.client_turn_id, + "turnLog": self.turn_log_relative, + "sidecarPresent": self.sidecar_present, + "offeredCount": self.offered.len(), + "offeredRead": offered_read, + "firstDesign": self.first_design.clone(), + "itemCount": self.item_count, + "itemsTruncated": self.items_truncated, + "completed": completed, + "auditWriteFailed": self.audit_write_failed, + }); + if append_agent_db_record(&self.root, summary).is_err() { + self.audit_write_failed = true; + } + } + + fn fill_command_execution(&mut self, record: &mut Value, item: &Value) { + let mut path_rejected = false; + let mut actions = Vec::new(); + if let Some(raw_actions) = item.get("commandActions").and_then(Value::as_array) { + for action in raw_actions { + let action_type = action + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + match action_type { + "read" => { + let (entry, rejected) = self.read_action_entry(action); + path_rejected |= rejected; + actions.push(entry); + } + "listFiles" => { + let mut entry = json!({ "type": "listFiles" }); + match optional_action_path(&self.root, action) { + ActionPath::Missing => {} + ActionPath::Rejected => { + path_rejected = true; + entry["pathRejected"] = json!(true); + } + ActionPath::Ok(path) => entry["path"] = json!(path), + } + actions.push(entry); + } + "search" => { + let mut entry = json!({ "type": "search" }); + if let Some(query) = action.get("query").and_then(Value::as_str) { + entry["query"] = + json!(truncate_chars(query, DIRECT_CODEX_AUDIT_QUERY_CHARS)); + } + match optional_action_path(&self.root, action) { + ActionPath::Missing => {} + ActionPath::Rejected => { + path_rejected = true; + entry["pathRejected"] = json!(true); + } + ActionPath::Ok(path) => entry["path"] = json!(path), + } + actions.push(entry); + } + _ => actions.push(json!({ "type": "unknown" })), + } + } + } + record["actions"] = json!(actions); + if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { + record["exitCode"] = json!(exit_code); + } + if let Some(duration_ms) = item.get("durationMs").and_then(Value::as_i64) { + record["durationMs"] = json!(duration_ms); + } + let command = item.get("command").and_then(Value::as_str).unwrap_or(""); + if path_rejected || command_contains_host_absolute_path(command) { + record["commandRedacted"] = json!(true); + } else if !command.is_empty() { + record["command"] = json!(truncate_chars(command, DIRECT_CODEX_AUDIT_COMMAND_CHARS)); + } + } + + fn read_action_entry(&mut self, action: &Value) -> (Value, bool) { + let Some(raw_path) = action.get("path").and_then(Value::as_str) else { + return (json!({ "type": "read", "pathRejected": true }), true); + }; + match relativize_project_path(&self.root, raw_path) { + Some(path) => { + let (content_sha256, hash_skipped) = hash_project_file(&self.root, &path); + self.mark_offered_read(&path, content_sha256.as_deref()); + let mut entry = json!({ "type": "read", "path": path }); + insert_hash_fields(&mut entry, content_sha256, hash_skipped); + (entry, false) + } + None => (json!({ "type": "read", "pathRejected": true }), true), + } + } + + fn fill_mcp_tool_call(&mut self, record: &mut Value, item: &Value, seq: usize) { + let tool = item.get("tool").and_then(Value::as_str).unwrap_or(""); + record["tool"] = json!(tool); + if let Some(server) = item + .get("server") + .and_then(Value::as_str) + .filter(|server| !server.is_empty() && *server != "agc_tools") + { + record["server"] = json!(server); + } + if let Some(duration_ms) = item.get("durationMs").and_then(Value::as_i64) { + record["durationMs"] = json!(duration_ms); + } + if item.get("error").is_some() { + record["errorKind"] = json!(item + .pointer("/error/code") + .and_then(Value::as_str) + .or_else(|| item.pointer("/error/type").and_then(Value::as_str)) + .unwrap_or("error")); + } + let arguments = item.get("arguments").cloned().unwrap_or(Value::Null); + let extracted = extract_mcp_arguments(&self.root, tool, &arguments); + if let Some(path) = extracted + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + { + self.mark_offered_read(&path, None); + } + if let Some(local_paths) = extracted.get("localPaths").and_then(Value::as_array) { + for path in local_paths { + if let Some(path) = path.as_str() { + self.mark_offered_read(path, None); + } + } + } + if extracted + .as_object() + .is_some_and(|object| !object.is_empty()) + { + record["arguments"] = extracted.clone(); + } + if self.first_design.is_none() { + if DESIGN_MCP_TOOLS.contains(&tool) { + let mut design = json!({ + "kind": format!("mcp:{tool}"), + "seq": seq, + "tool": tool, + }); + let preview = extracted + .get("brief") + .or_else(|| extracted.get("prompt")) + .and_then(Value::as_str) + .map(|text| truncate_chars(text, DIRECT_CODEX_AUDIT_PREVIEW_CHARS)); + if let Some(preview) = preview { + design["briefPreview"] = json!(preview); + } + self.first_design = Some(design); + } else if tool == "agc_write_file" { + if let Some(path) = extracted.get("path").and_then(Value::as_str) { + if is_design_write_path(path) { + self.first_design = Some(json!({ + "kind": format!("write:{path}"), + "seq": seq, + "path": path, + })); + } + } + } + } + } + + fn fill_file_change(&mut self, record: &mut Value, item: &Value, seq: usize) { + let mut changes = Vec::new(); + if let Some(raw_changes) = item.get("changes").and_then(Value::as_array) { + for change in raw_changes { + let kind = file_change_kind(change); + let mut entry = json!({ "kind": kind }); + match change.get("path").and_then(Value::as_str) { + Some(raw) => match relativize_project_path(&self.root, raw) { + Some(path) => { + if self.first_design.is_none() && is_design_write_path(&path) { + self.first_design = Some(json!({ + "kind": format!("fileChange:{path}"), + "seq": seq, + "path": path, + })); + } + entry["path"] = json!(path); + } + None => entry["pathRejected"] = json!(true), + }, + None => entry["pathRejected"] = json!(true), + } + changes.push(entry); + } + } + record["changes"] = json!(changes); + } + + fn fill_image_view(&mut self, record: &mut Value, item: &Value) { + match item.get("path").and_then(Value::as_str) { + Some(raw) => match relativize_project_path(&self.root, raw) { + Some(path) => { + let (content_sha256, hash_skipped) = hash_project_file(&self.root, &path); + self.mark_offered_read(&path, content_sha256.as_deref()); + record["path"] = json!(path); + insert_hash_fields(record, content_sha256, hash_skipped); + } + None => record["pathRejected"] = json!(true), + }, + None => record["pathRejected"] = json!(true), + } + } + + fn mark_offered_read(&mut self, path: &str, content_sha256: Option<&str>) { + for offered in &mut self.offered { + if offered.local_path != path { + continue; + } + offered.read = true; + match (offered.content_sha256.as_deref(), content_sha256) { + (Some(expected), Some(actual)) => { + let matches = expected == actual; + offered.content_sha256_match = + Some(offered.content_sha256_match.unwrap_or(true) && matches); + } + _ => {} + } + } + } + + fn offered_read_values(&self) -> Vec { + self.offered + .iter() + .map(|offered| { + let mut value = json!({ + "localPath": offered.local_path, + "read": offered.read, + }); + if let Some(matches) = offered.content_sha256_match { + value["contentSha256Match"] = json!(matches); + } + value + }) + .collect() + } + + fn append_record(&mut self, mut record: Value) { + #[cfg(test)] + if test_fail_audit_write(&self.root) { + self.audit_write_failed = true; + return; + } + if let Some(object) = record.as_object_mut() { + object.insert( + "recordedAtMs".to_string(), + json!(u64::try_from(unix_millis()).unwrap_or(u64::MAX)), + ); + } + let Ok(line) = serde_json::to_string(&record) else { + self.audit_write_failed = true; + return; + }; + if append_jsonl_line(&self.log_path, &line, "Direct 回合审计记录").is_err() { + self.audit_write_failed = true; + } + } +} + +impl Drop for DirectCodexTurnAudit { + fn drop(&mut self) { + if !self.finished { + self.finish(false); + } + } +} + +enum ActionPath { + Missing, + Rejected, + Ok(String), +} + +fn optional_action_path(root: &Path, action: &Value) -> ActionPath { + let Some(raw) = action.get("path").and_then(Value::as_str) else { + return ActionPath::Missing; + }; + if raw.trim().is_empty() { + return ActionPath::Missing; + } + match relativize_project_path(root, raw) { + Some(path) => ActionPath::Ok(path), + None => ActionPath::Rejected, + } +} + +fn project_audit_attachments( + root: &Path, + attachments: &[DirectCodexTurnAttachment], +) -> (Vec, Vec) { + let mut values = Vec::new(); + let mut offered = Vec::new(); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + let name = sanitize_attachment_name(&attachment.name); + let media_type = sanitize_attachment_media_type(&attachment.media_type); + let raw_path = attachment + .local_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let sanitized_path = raw_path.and_then(sanitize_attachment_local_path); + let path_rejected = raw_path.is_some() && sanitized_path.is_none(); + let status = if path_rejected { + Some("failed") + } else { + sanitize_attachment_status(attachment.status.as_deref()) + }; + let mut value = json!({ + "name": name, + "mediaType": media_type, + "size": attachment.size, + }); + if let Some(path) = sanitized_path { + value["localPath"] = json!(path.clone()); + let (content_sha256, hash_skipped) = hash_project_file(root, &path); + insert_hash_fields(&mut value, content_sha256.clone(), hash_skipped); + offered.push(OfferedAttachment { + local_path: path, + content_sha256, + read: false, + content_sha256_match: None, + }); + } + if let Some(status) = status { + value["status"] = json!(status); + } + values.push(value); + } + (values, offered) +} + +fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value { + let Some(object) = arguments.as_object() else { + return json!({}); + }; + let mut out = Map::new(); + match tool { + "agc_list_project_files" => { + copy_sanitized_path(root, object, "path", &mut out); + copy_truncated_string( + object, + "query", + DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS, + &mut out, + ); + copy_string(object, "kind", &mut out); + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_write_file" => { + copy_sanitized_path(root, object, "path", &mut out); + if let Some(content) = object.get("content").and_then(Value::as_str) { + out.insert("contentChars".to_string(), json!(content.chars().count())); + } + } + "taonier_prepare_game_art" => { + copy_string(object, "mode", &mut out); + copy_text_with_hash( + object, + "brief", + "brief", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_generate_image" => { + copy_string(object, "kind", &mut out); + copy_string(object, "aspectRatio", &mut out); + copy_string(object, "imageSize", &mut out); + copy_string(object, "assetName", &mut out); + copy_sanitized_path(root, object, "outputPath", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_edit_image" => { + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_create_or_derive_resource" => { + copy_string(object, "kind", &mut out); + copy_string(object, "mode", &mut out); + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + copy_text_with_hash( + object, + "prompt", + "prompt", + DIRECT_CODEX_AUDIT_BRIEF_CHARS, + &mut out, + ); + } + "agc_list_registered_assets" => { + copy_string(object, "kind", &mut out); + copy_string(object, "assetId", &mut out); + if let Some(flag) = object.get("includeSequenceFrames").and_then(Value::as_bool) { + out.insert("includeSequenceFrames".to_string(), json!(flag)); + } + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_list_account_assets" => { + copy_string(object, "folderId", &mut out); + copy_truncated_string( + object, + "query", + DIRECT_CODEX_AUDIT_LIST_QUERY_CHARS, + &mut out, + ); + copy_number(object, "offset", &mut out); + copy_number(object, "limit", &mut out); + } + "agc_import_account_assets" => { + if let Some(ids) = object.get("assetIds").and_then(Value::as_array) { + let kept: Vec = ids + .iter() + .filter_map(Value::as_str) + .take(DIRECT_CODEX_AUDIT_ID_LIST_MAX) + .map(Value::from) + .collect(); + let omitted = ids.len().saturating_sub(kept.len()); + out.insert("assetIds".to_string(), json!(kept)); + if omitted > 0 { + out.insert("assetIdsOmitted".to_string(), json!(omitted)); + } + } + if let Some(paths) = object.get("localPaths").and_then(Value::as_array) { + let kept: Vec = paths + .iter() + .filter_map(Value::as_str) + .filter_map(|path| relativize_project_path(root, path)) + .take(DIRECT_CODEX_AUDIT_ID_LIST_MAX) + .map(Value::from) + .collect(); + out.insert("localPaths".to_string(), json!(kept)); + } + } + "agc_remove_background" => { + copy_string(object, "sourceLocalAssetId", &mut out); + copy_string(object, "assetName", &mut out); + } + "agc_browser_playtest" => copy_number(object, "attempt", &mut out), + "agc_web_search" => { + copy_truncated_string(object, "query", DIRECT_CODEX_AUDIT_QUERY_CHARS, &mut out); + copy_number(object, "maxResults", &mut out); + } + "agc_read_skill_resource" => { + copy_string(object, "skillName", &mut out); + copy_string(object, "relativePath", &mut out); + } + _ => {} + } + Value::Object(out) +} + +fn fill_function_call_output(record: &mut Value, item: &Value) { + if let Some(name) = item.get("name").and_then(Value::as_str) { + record["name"] = json!(name); + } + if let Some(namespace) = item.get("namespace").and_then(Value::as_str) { + record["namespace"] = json!(namespace); + } +} + +fn fill_web_search(record: &mut Value, item: &Value) { + if let Some(query) = item.get("query").and_then(Value::as_str) { + record["query"] = json!(truncate_chars(query, DIRECT_CODEX_AUDIT_QUERY_CHARS)); + } +} + +fn copy_string(source: &Map, key: &str, out: &mut Map) { + if let Some(value) = source + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + out.insert(key.to_string(), json!(value)); + } +} + +fn copy_truncated_string( + source: &Map, + key: &str, + max_chars: usize, + out: &mut Map, +) { + if let Some(value) = source.get(key).and_then(Value::as_str) { + out.insert(key.to_string(), json!(truncate_chars(value, max_chars))); + } +} + +fn copy_number(source: &Map, key: &str, out: &mut Map) { + if let Some(value) = source.get(key).and_then(Value::as_i64) { + out.insert(key.to_string(), json!(value)); + } +} + +fn copy_sanitized_path( + root: &Path, + source: &Map, + key: &str, + out: &mut Map, +) { + let Some(raw) = source.get(key).and_then(Value::as_str) else { + return; + }; + match relativize_project_path(root, raw) { + Some(path) => { + out.insert(key.to_string(), json!(path)); + } + None => { + out.insert(format!("{key}Rejected"), json!(true)); + } + } +} + +fn copy_text_with_hash( + source: &Map, + source_key: &str, + dest_key: &str, + max_chars: usize, + out: &mut Map, +) { + let Some(text) = source.get(source_key).and_then(Value::as_str) else { + return; + }; + out.insert(format!("{dest_key}Chars"), json!(text.chars().count())); + out.insert( + format!("{dest_key}Sha256"), + json!(sha256_hex(text.as_bytes())), + ); + out.insert(dest_key.to_string(), json!(truncate_chars(text, max_chars))); +} + +fn file_change_kind(change: &Value) -> &'static str { + let kind = change.get("kind"); + let label = kind + .and_then(Value::as_str) + .or_else(|| { + kind.and_then(|value| value.get("type")) + .and_then(Value::as_str) + }) + .unwrap_or("update"); + match label { + "add" => "add", + "delete" => "delete", + _ => "update", + } +} + +fn is_design_write_path(path: &str) -> bool { + path == "index.html" + || path.starts_with("game/") + || path.rsplit('/').next() == Some("index.html") +} + +fn insert_hash_fields( + target: &mut Value, + content_sha256: Option, + hash_skipped: Option<&str>, +) { + if let Some(content_sha256) = content_sha256 { + target["contentSha256"] = json!(content_sha256); + } + if let Some(hash_skipped) = hash_skipped { + target["hashSkipped"] = json!(hash_skipped); + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() +} + +fn hash_project_file(root: &Path, relative: &str) -> (Option, Option<&'static str>) { + if reject_agent_runtime_private_control_path(relative).is_err() + || reject_sensitive_project_file_read(relative).is_err() + { + return (None, Some("missing")); + } + let path = match resolve_local_project_path(root, relative) { + Ok(path) => path, + Err(_) => return (None, Some("missing")), + }; + let metadata = match fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => metadata, + _ => return (None, Some("missing")), + }; + if metadata.len() > DIRECT_CODEX_AUDIT_HASH_MAX_BYTES { + return (None, Some("too-large")); + } + match fs::read(&path) { + Ok(bytes) => (Some(sha256_hex(&bytes)), None), + Err(_) => (None, Some("missing")), + } +} + +fn posix_path_text(path: &Path) -> String { + let text = path.to_string_lossy(); + let text = text + .strip_prefix(r"\\?\") + .or_else(|| text.strip_prefix("//?/")) + .unwrap_or(&text); + text.replace('\\', "/").trim_end_matches('/').to_string() +} + +fn relativize_project_path(root: &Path, raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + if let Some(relative) = sanitize_attachment_local_path(trimmed) { + return accept_relative_path(&relative); + } + if let Some(relative) = strip_absolute_root_prefix(root, trimmed) { + return sanitize_attachment_local_path(&relative) + .and_then(|path| accept_relative_path(&path)); + } + None +} + +fn strip_absolute_root_prefix(root: &Path, raw: &str) -> Option { + if let (Ok(root_canon), Ok(raw_canon)) = (root.canonicalize(), Path::new(raw).canonicalize()) { + if let Ok(stripped) = raw_canon.strip_prefix(&root_canon) { + let relative = posix_path_text(stripped); + if !relative.is_empty() { + return Some(relative); + } + } + } + let root_text = posix_path_text(root); + let raw_text = posix_path_text(Path::new(raw)); + let rest = if cfg!(windows) { + let root_lower = root_text.to_ascii_lowercase(); + let raw_lower = raw_text.to_ascii_lowercase(); + let suffix = raw_lower.strip_prefix(&root_lower)?; + raw_text + .get(raw_text.len().saturating_sub(suffix.len())..) + .unwrap_or(suffix) + .to_string() + } else { + raw_text.strip_prefix(&root_text)?.to_string() + }; + let rest = rest.trim_start_matches('/').to_string(); + (!rest.is_empty()).then_some(rest) +} + +fn accept_relative_path(relative: &str) -> Option { + if reject_agent_runtime_private_control_path(relative).is_err() + || reject_sensitive_project_file_read(relative).is_err() + { + return None; + } + Some(relative.to_string()) +} + +fn command_contains_host_absolute_path(command: &str) -> bool { + if command.contains("\\\\") || command.contains("/Users/") || command.contains("/home/") { + return true; + } + let bytes = command.as_bytes(); + let mut index = 0; + while index + 2 < bytes.len() { + if bytes[index].is_ascii_alphabetic() + && bytes[index + 1] == b':' + && matches!(bytes[index + 2], b'\\' | b'/') + { + return true; + } + index += 1; + } + false +} + +#[cfg(test)] +fn test_fail_audit_write(root: &Path) -> bool { + root.join(".agent/runtime/test-fail-direct-codex-audit") + .is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_project(name: &str) -> tempfile::TempDir { + let directory = tempfile::tempdir().expect("temp project"); + init_local_game_project_at(directory.path(), name, "审计测试项目").expect("init project"); + directory + } + + fn attachment_json( + name: &str, + media_type: &str, + size: u64, + local_path: Option<&str>, + status: Option<&str>, + ) -> DirectCodexTurnAttachment { + let mut value = json!({ + "name": name, + "mediaType": media_type, + "size": size, + }); + if let Some(local_path) = local_path { + value["localPath"] = json!(local_path); + } + if let Some(status) = status { + value["status"] = json!(status); + } + serde_json::from_value(value).expect("attachment") + } + + fn read_turn_log(root: &Path, client_turn_id: &str) -> Vec { + let path = root + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join(format!("{client_turn_id}.jsonl")); + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("audit jsonl")) + .collect() + } + + fn read_agent_db(root: &Path) -> Vec { + fs::read_to_string(root.join(".agent/agent.db")) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect() + } + + fn start_audit( + root: &Path, + prompt: &str, + attachments: &[DirectCodexTurnAttachment], + ) -> DirectCodexTurnAudit { + DirectCodexTurnAudit::start(root, "turn-01", prompt, attachments) + } + + #[test] + fn turn_start_hashes_original_prompt_and_sanitizes_attachment_paths() { + let project = fixture_project("audit-start"); + let root = project.path(); + let upload = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads dir"); + fs::write(root.join(upload), "脉冲余烬").expect("write gdd"); + let attachments = vec![ + attachment_json( + "fast_gdd.md", + "text/markdown", + 12, + Some(upload), + Some("imported"), + ), + attachment_json( + "secret.md", + "text/markdown", + 1, + Some("../secret.md"), + Some("imported"), + ), + ]; + let mut audit = start_audit(root, "请根据附件做游戏", &attachments); + audit.finish(true); + let records = read_turn_log(root, "turn-01"); + let start = records + .iter() + .find(|record| record["recordType"] == "direct.codex.turn_start") + .expect("turn_start"); + assert_eq!( + start["promptSha256"], + json!(sha256_hex("请根据附件做游戏".as_bytes())) + ); + assert_eq!(start["sidecarPresent"], json!(true)); + let listed = start["attachments"].as_array().expect("attachments"); + assert_eq!(listed[0]["localPath"], json!(upload)); + assert_eq!( + listed[0]["contentSha256"], + json!(sha256_hex("脉冲余烬".as_bytes())) + ); + assert!(listed[1].get("localPath").is_none()); + assert_eq!(listed[1]["status"], json!("failed")); + assert!(!serde_json::to_string(start).expect("json").contains("..")); + } + + #[test] + fn turn_start_without_attachments_sets_sidecar_absent() { + let project = fixture_project("audit-empty"); + let mut audit = start_audit(project.path(), "继续改游戏", &[]); + audit.finish(true); + let start = &read_turn_log(project.path(), "turn-01")[0]; + assert_eq!(start["sidecarPresent"], json!(false)); + assert_eq!(start["attachments"], json!([])); + } + + #[test] + fn attachment_hash_skips_missing_and_too_large_files() { + let project = fixture_project("audit-hash"); + let root = project.path(); + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + let large_path = "assets/uploads/upload-1-big.bin"; + let missing_path = "assets/uploads/upload-1-missing.md"; + fs::write( + root.join(large_path), + vec![0_u8; (DIRECT_CODEX_AUDIT_HASH_MAX_BYTES as usize) + 1], + ) + .expect("large file"); + let attachments = vec![ + attachment_json( + "big.bin", + "application/octet-stream", + 3, + Some(large_path), + Some("imported"), + ), + attachment_json( + "missing.md", + "text/markdown", + 1, + Some(missing_path), + Some("imported"), + ), + ]; + let mut audit = start_audit(root, "x", &attachments); + audit.finish(true); + let start = &read_turn_log(root, "turn-01")[0]; + let listed = start["attachments"].as_array().expect("attachments"); + assert_eq!(listed[0]["hashSkipped"], json!("too-large")); + assert!(listed[0].get("contentSha256").is_none()); + assert_eq!(listed[1]["hashSkipped"], json!("missing")); + } + + #[test] + fn command_read_relativizes_absolute_path_and_drops_stdout() { + let project = fixture_project("audit-read"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "裂脉炮").expect("gdd"); + let absolute = root.join(relative); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 9, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "id": "item-read", + "type": "commandExecution", + "command": "type assets/uploads/upload-1-fast_gdd.md", + "status": "completed", + "exitCode": 0, + "aggregatedOutput": "裂脉炮 SECRET", + "commandActions": [{ + "type": "read", + "name": "fast_gdd.md", + "path": absolute.to_string_lossy(), + "command": format!("type {}", absolute.display()) + }] + } + })); + audit.finish(true); + let item = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + let dumped = serde_json::to_string(&item).expect("item json"); + assert!(!dumped.contains("aggregatedOutput")); + assert!(!dumped.contains("裂脉炮 SECRET")); + assert_eq!(item["actions"][0]["path"], json!(relative)); + assert_eq!( + item["actions"][0]["contentSha256"], + json!(sha256_hex("裂脉炮".as_bytes())) + ); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["offeredRead"][0]["read"], json!(true)); + assert_eq!(end["offeredRead"][0]["contentSha256Match"], json!(true)); + } + + #[test] + fn rejected_read_path_does_not_persist_host_absolute_path() { + let project = fixture_project("audit-reject"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": r"type C:\Users\secret\fast_gdd.md", + "aggregatedOutput": "nope", + "commandActions": [{ + "type": "read", + "path": r"C:\Users\secret\fast_gdd.md" + }] + } + })); + audit.finish(true); + let dumped = fs::read_to_string( + project + .path() + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join("turn-01.jsonl"), + ) + .expect("log"); + assert!(!dumped.contains(r"C:\Users")); + assert!(!dumped.contains("Users")); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["actions"][0]["pathRejected"], json!(true)); + assert_eq!(item["commandRedacted"], json!(true)); + assert!(item.get("command").is_none()); + assert!(item.get("aggregatedOutput").is_none()); + } + + #[test] + fn mcp_art_brief_is_kept_and_result_is_dropped() { + let project = fixture_project("audit-art"); + let mut audit = start_audit(project.path(), "做游戏", &[]); + audit.observe_item(&json!({ + "item": { + "id": "art-1", + "type": "mcpToolCall", + "server": "agc_tools", + "tool": "taonier_prepare_game_art", + "status": "completed", + "arguments": { "brief": "俯视角收集冒险小游戏", "mode": "reuse-or-create" }, + "result": { "secret": "do-not-store" }, + "error": null + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["arguments"]["brief"], json!("俯视角收集冒险小游戏")); + assert!(item.get("result").is_none()); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("do-not-store")); + let end = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!( + end["firstDesign"]["kind"], + json!("mcp:taonier_prepare_game_art") + ); + assert_eq!( + end["firstDesign"]["briefPreview"], + json!("俯视角收集冒险小游戏") + ); + } + + #[test] + fn agc_write_file_keeps_path_and_content_chars_not_body() { + let project = fixture_project("audit-write"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { + "path": "game/index.html", + "content": "秘密正文" + } + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["arguments"]["path"], json!("game/index.html")); + assert_eq!( + item["arguments"]["contentChars"], + json!("秘密正文".chars().count()) + ); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("秘密正文")); + assert!(item["arguments"].get("content").is_none()); + } + + #[test] + fn generate_image_prompt_is_truncated_with_hash() { + let project = fixture_project("audit-image"); + let prompt = "收".repeat(5000); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "agc_generate_image", + "arguments": { "prompt": prompt, "kind": "icon-spec" } + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + let stored = item["arguments"]["prompt"].as_str().expect("prompt"); + assert_eq!(stored.chars().count(), DIRECT_CODEX_AUDIT_BRIEF_CHARS); + assert_eq!(item["arguments"]["promptChars"], json!(5000)); + assert_eq!( + item["arguments"]["promptSha256"], + json!(sha256_hex("收".repeat(5000).as_bytes())) + ); + } + + #[test] + fn file_change_keeps_path_and_kind_without_diff() { + let project = fixture_project("audit-patch"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "type": "fileChange", + "status": "completed", + "changes": [{ + "path": "game/index.html", + "kind": { "type": "add" }, + "diff": "*** SECRET PATCH" + }] + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["changes"][0]["path"], json!("game/index.html")); + assert_eq!(item["changes"][0]["kind"], json!("add")); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("SECRET PATCH")); + assert!(!dumped.contains("diff")); + } + + #[test] + fn list_or_search_does_not_count_as_reading_offered_attachment() { + let project = fixture_project("audit-list"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "x").expect("gdd"); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 1, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "rg fast_gdd assets", + "commandActions": [{ + "type": "search", + "query": "fast_gdd", + "path": "assets" + }] + } + })); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "ls assets/uploads", + "commandActions": [{ "type": "listFiles", "path": "assets/uploads" }] + } + })); + audit.finish(true); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["offeredRead"][0]["read"], json!(false)); + assert!(end["offeredRead"][0].get("contentSha256Match").is_none()); + assert!(end["firstDesign"].is_null()); + } + + #[test] + fn first_design_skips_reads_and_uses_later_art_item_seq() { + let project = fixture_project("audit-order"); + let root = project.path(); + let relative = "assets/uploads/upload-1-fast_gdd.md"; + fs::create_dir_all(root.join("assets/uploads")).expect("uploads"); + fs::write(root.join(relative), "x").expect("gdd"); + let mut audit = start_audit( + root, + "做游戏", + &[attachment_json( + "fast_gdd.md", + "text/markdown", + 1, + Some(relative), + Some("imported"), + )], + ); + audit.observe_item(&json!({ + "item": { + "type": "commandExecution", + "command": "type assets/uploads/upload-1-fast_gdd.md", + "commandActions": [{ "type": "read", "path": relative }] + } + })); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "taonier_prepare_game_art", + "arguments": { "brief": "收集冒险" } + } + })); + audit.finish(true); + let end = read_turn_log(root, "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!( + end["firstDesign"]["kind"], + json!("mcp:taonier_prepare_game_art") + ); + assert_eq!(end["firstDesign"]["seq"], json!(2)); + assert_eq!(end["offeredRead"][0]["read"], json!(true)); + } + + #[test] + fn item_cap_writes_truncated_marker() { + let project = fixture_project("audit-cap"); + let mut audit = start_audit(project.path(), "x", &[]); + for index in 0..(DIRECT_CODEX_AUDIT_MAX_ITEMS + 2) { + audit.observe_item(&json!({ + "item": { + "id": format!("item-{index}"), + "type": "commandExecution", + "command": "ls", + "commandActions": [{ "type": "unknown" }] + } + })); + } + audit.finish(true); + let records = read_turn_log(project.path(), "turn-01"); + let items = records + .iter() + .filter(|record| record["recordType"] == "direct.codex.item") + .count(); + assert_eq!(items, DIRECT_CODEX_AUDIT_MAX_ITEMS); + assert!(records + .iter() + .any(|record| record["recordType"] == "direct.codex.items_truncated")); + let end = records + .iter() + .find(|record| record["recordType"] == "direct.codex.turn_end") + .expect("end"); + assert_eq!(end["itemsTruncated"], json!(true)); + assert_eq!(end["itemCount"], json!(DIRECT_CODEX_AUDIT_MAX_ITEMS)); + } + + #[test] + fn agent_db_summary_points_at_relative_turn_log() { + let project = fixture_project("audit-db"); + let mut audit = start_audit(project.path(), "做游戏", &[]); + audit.observe_item(&json!({ + "item": { + "type": "mcpToolCall", + "tool": "taonier_prepare_game_art", + "arguments": { "brief": "俯视角收集冒险小游戏" } + } + })); + audit.finish(true); + let summary = read_agent_db(project.path()) + .into_iter() + .rev() + .find(|record| record["recordType"] == "direct.codex.turn") + .expect("summary"); + assert_eq!( + summary["turnLog"], + json!(format!("{DIRECT_CODEX_AUDIT_TURN_LOG_DIR}/turn-01.jsonl")) + ); + assert_eq!( + summary["firstDesign"]["briefPreview"], + json!("俯视角收集冒险小游戏") + ); + assert_eq!(summary["completed"], json!(true)); + assert!(!summary["turnLog"].as_str().expect("path").contains('\\')); + } + + #[test] + fn write_failure_does_not_panic_or_surface_through_finish() { + let project = fixture_project("audit-fail"); + fs::create_dir_all(project.path().join(".agent/runtime")).expect("runtime"); + fs::write( + project + .path() + .join(".agent/runtime/test-fail-direct-codex-audit"), + "1", + ) + .expect("marker"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { "type": "unknownTool" } + })); + audit.finish(true); + assert!(!project + .path() + .join(DIRECT_CODEX_AUDIT_TURN_LOG_DIR) + .join("turn-01.jsonl") + .is_file()); + } + + #[test] + fn unknown_item_type_keeps_only_public_fields() { + let project = fixture_project("audit-unknown"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { + "id": "mystery", + "type": "secretNewItem", + "payload": { "token": "leak-me" }, + "aggregatedOutput": "nope" + } + })); + audit.finish(true); + let item = read_turn_log(project.path(), "turn-01") + .into_iter() + .find(|record| record["recordType"] == "direct.codex.item") + .expect("item"); + assert_eq!(item["itemType"], json!("secretNewItem")); + assert_eq!(item["itemId"], json!("mystery")); + let dumped = serde_json::to_string(&item).expect("json"); + assert!(!dumped.contains("leak-me")); + assert!(!dumped.contains("payload")); + assert!(!dumped.contains("aggregatedOutput")); + } + + #[test] + fn agent_messages_are_skipped() { + let project = fixture_project("audit-skip"); + let mut audit = start_audit(project.path(), "x", &[]); + audit.observe_item(&json!({ + "item": { "type": "agentMessage", "text": "已按 GDD 完成" } + })); + audit.finish(true); + let items = read_turn_log(project.path(), "turn-01") + .into_iter() + .filter(|record| record["recordType"] == "direct.codex.item") + .count(); + assert_eq!(items, 0); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 188838ccb..798bac52e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2174,9 +2174,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { let sources = direct_codex_game_outputs(root) .into_iter() - .filter_map(|(relative_path, _, _)| { - std::fs::read_to_string(root.join(relative_path)).ok() - }) + .filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); let mut available_paths = Vec::new(); if direct_taonier_art_base_is_valid(root) { @@ -2264,9 +2262,7 @@ fn direct_browser_evidence_needs_art_repair( fn direct_game_output_completion_error(root: &Path) -> Option { let entry = agent_runtime_game_entry_relative_path(root); if !root.join(entry).is_file() { - return Some(format!( - "Codex 返回后未找到 {entry},项目未进入可运行状态" - )); + return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } if !direct_game_sources_reference_taonier_art_package(root) { return Some( @@ -3781,6 +3777,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( prompt, creation_type, None, + None, ) .await } @@ -3790,6 +3787,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( prompt: &str, creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -3805,7 +3803,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None); } - match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await { + match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await + { Ok(reply) => Ok(reply), Err(failure) => { let error = record_direct_codex_turn_failure(root, failure); @@ -3822,6 +3821,7 @@ async fn run_direct_game_creator_turn_inner( prompt: &str, creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, + audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { @@ -3850,15 +3850,23 @@ async fn run_direct_game_creator_turn_inner( ); } }; - direct_game_creator_codex_chat_at_with_observer( + direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, prompt.to_string(), - &mut observer, + Some(&mut observer), + audit, ) .await } else { - direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + prompt.to_string(), + None, + audit, + ) + .await } .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; if let Some(emitter) = turn_emitter { @@ -4194,23 +4202,47 @@ pub(crate) async fn chat_with_game_creator_direct_codex( redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) })?; let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let user_prompt = - render_direct_codex_user_prompt(&prompt, attachments.as_deref().unwrap_or_default())?; - let reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + let mut audit = DirectCodexTurnAudit::start( + root, + &turn_id, + &prompt, + attachments.as_deref().unwrap_or_default(), + ); + let user_prompt = match render_direct_codex_user_prompt( + &prompt, + attachments.as_deref().unwrap_or_default(), + ) { + Ok(prompt) => prompt, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, creation_type.as_deref(), Some(&turn_emitter), + Some(&mut audit), ) - .await?; - persist_direct_codex_assistant_reply_at(root, &turn_id, &reply).map_err(|error| { + .await + { + Ok(reply) => reply, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) { + audit.finish(false); turn_emitter.emit("failed", Some("none"), None); - redact_agent_runtime_error( + return Err(redact_agent_runtime_error( root, &format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"), 500, - ) - })?; + )); + } + audit.finish(true); turn_emitter.emit("completed", Some("none"), Some(reply.clone())); Ok(reply) } diff --git a/docs/README.md b/docs/README.md index b7fca8ff7..40195c0ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 +- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 - [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):当前策划入口、审批和恢复合同。 - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7a4610c9e..2936ef7c9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -25,6 +25,14 @@ --- +## 2026-08-31 Direct 回合把 Codex item 落成有界行为账本 + +- 背景:sidecar 已让模型看见本轮附件路径,但 native 读 / MCP / 写文件只存在于隔离 `CODEX_HOME` 的瞬时 stdout,回合结束即删。无法判断「没读附件」还是「读了仍走默认收集类」。 +- 决策:GUI DirectProject 每个 `clientTurnId` 追加 `.agent/runtime/direct-codex/turns/.jsonl`,并在 `agent.db` 写一条 `direct.codex.turn` 摘要。记 sidecar 提供的路径与文件 hash、`item/completed` 的 Read/List/Search/MCP/写文件(不含 stdout、patch、MCP result),以及 `offeredRead` / `firstDesign`。审计 fail-open,不阻断做游戏。Home、CLI、Supervisor 收据模型不接。不灌附件正文,不强制读取,不为 GDD 开特例。 +- 影响范围:`direct_codex_audit.rs`、Direct GUI command 边界、Codex collect 循环;前端 / jsonl 气泡 / sidecar 文案不变。 +- 验证方式:Rust fixture 覆盖 turn_start hash、绝对路径相对化、stdout/diff 不落盘、art brief 保留、list/search 不算已读、firstDesign 顺序、256 条截断、写盘失败不 panic;sidecar 渲染与 Direct 活动词测试保持通过。 +- 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、issue #212。 + ## 2026-08-31 Direct 本轮附件只映射路径,不灌正文、不区别 GDD - 背景:issue #212。首页附件已经复制到 `assets/uploads/` 并登记,但 Direct 首轮只把用户原文发给 Codex,原文件名不是磁盘路径,模型会另起一套玩法。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 86289e98a..db230fbc1 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -25,9 +25,10 @@ AI 游戏创作 / DirectProject / UI workflow: 2. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` 3. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 4. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` -5. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` -6. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` -7. UI 编辑器、宿主壳和当前测试专题文档 +5. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` +6. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` +7. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` +8. UI 编辑器、宿主壳和当前测试专题文档 图片画布 / 媒体生成: diff --git a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md index c80f6e27e..a0c1f88cb 100644 --- a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md @@ -27,7 +27,7 @@ - 不把附件全文拼进 prompt,不按扩展名决定是否读取。 - 不把「没读到就阻断」做成门禁。 - 不扫 manifest 里历史 `kind=uploaded`。 -- 不做 native 读取审计(issue #212 的第二问题,另排期)。 +- 不做 native 读取审计;该项由 [`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) 承接。 - 不改 DirectHome 在「无项目路径」时的现有文案和列表格式。 - 不改 `enterCreatedHomeProject` 的空正文兜底句(与做方案共用)。 diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md new file mode 100644 index 000000000..fdfdf2730 --- /dev/null +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md @@ -0,0 +1,389 @@ +# Direct 回合行为审计账本 + +- 日期:2026-08-31 +- 状态:现行合同(已按本文落地) +- 问题:Gitea issue #212 的第二段(Direct 原生读 / 工具行为无法从项目产物判断);用于分析「附件已映射仍未按文档实施」 +- 关联:[`【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`](./【技术方案】DirectProject本轮附件路径映射-2026-08-31.md)、[`【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md`](./【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md) +- 原则:落地后代码简洁可维护;审计是 Direct 行为时间线,不是 GDD 特例,也不替代 sidecar + +## 0. 一句话 + +Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动词,隔离 `CODEX_HOME` 随后删除。本方案在项目内留下有界、可共享的回合账本:本轮提供了哪些附件路径、按什么顺序做了读/搜/列表/MCP/写文件,以及第一次定玩法的动作是什么。用来区分「没读附件」和「读了仍走默认收集类」,不灌正文、不强制读取、不拷会话目录。 + +## 1. 目标与非目标 + +### 目标 + +一次带 `clientTurnId` 的 DirectProject GUI 回合结束后,只凭项目目录应能回答: + +1. 本轮 sidecar 是否发出,原名映射到哪些项目相对路径,文件当时的 `contentSha256`。 +2. 模型是否用 native 命令 / 列表 / 搜索 / 看图 / MCP 打开过那些路径(路径 + 当时磁盘 hash,不是 stdout)。 +3. **顺序**:读附件是在第一次美术 brief / 第一次写 `game/` 之前还是之后。 +4. 第一次「定玩法」动作是什么(优先 `taonier_prepare_game_art.brief`,否则其它生成类 MCP 或对 `game/` 的写入)。 + +覆盖后续手打回合:只要 GUI Direct 有 `clientTurnId` 就记账本,附件可以为空。 + +### 非目标 + +- 不证明「理解并按 GDD 实施」。那是对照 `game/index.html`、美术产物做的产品判断;账本只提供行为时间线。 +- 不把 issue #212 原文的 NLP / 「关键内容进入上下文」做成自动判决。 +- 不灌附件正文进 prompt,不强制先读再继续,不为 GDD 开协议特例。 +- 不改 sidecar 文案、jsonl 用户原文、工作台气泡。 +- 不改做成游戏固定 prompt / PR #210 注入。 +- 不复用 Supervisor `agent.runtime.action_receipt` / `file.read`。 +- 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。 +- 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。 +- 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。 +- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。 +- 本期不改 UI,不在聊天面板展示审计。 +- 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。 + +## 2. 现状 + +```text +Codex item/completed + commandExecution / mcpToolCall / fileChange / imageView / … + │ + ├─ 现用:收成 Activity("validation"|"controlled-tool"|…) + │ → Tauri 进度,不落盘 + └─ 不用:隔离 CODEX_HOME session(含 stdout)→ tempdir Drop 删除 +``` + +项目里现有: + +| 产物 | 记下的 | 缺的 | +|---|---|---| +| `.agent/conversations/project.jsonl` | 用户原文 + 助手终稿 | sidecar、工具调用 | +| `.agent/agent.db` | init / upload / 美术登记 / 对话指针 | native 读、MCP 调用、`agc_write_file` | +| `.agent/logs/command.log` | 权限确认 | 原生命令 | +| `asset.register` / `canvas.asset_generate` | 路径、切片、部分 `source.prompt` | 与读附件的先后 | +| 隔离 `CODEX_HOME` | Codex 自己的 session | 回合结束即删 | + +Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Read | ListFiles | Search | Unknown`,`Read.path` 在协议侧会拼成 cwd 绝对路径。Direct cwd 就是项目根(`resolve_direct_codex_project_authority` 不再强制 `game/` 子目录)。抽取时把绝对路径收回项目相对 POSIX,失败则丢路径,不写宿主绝对路径。 + +`agc_write_file` 经 tool bridge 落盘,当前不写 `agent.db`。不给每个 MCP 单独打点;统一在 `item/completed` 抽一次。 + +## 3. 分析用判据(相对 issue 收窄) + +落地后,对类似 `gameagent-9baa5293` 的 run,应能三分: + +| 时间线 | 结论 | 下一刀不该打哪 | +|---|---|---| +| `offeredRead.read=false`,`firstDesign` 已是 `taonier_prepare_game_art` 且 brief 是收集类 | 没打开附件就定了玩法 | 不是「GDD 解析不够」 | +| 先 `Read` 且 hash 对上,brief 仍是收集类 | 读了但没用 | sidecar 已够;看四切片 / icon-spec「收集物」/ 完成合同 | +| 只有 `ListFiles` / `Search` 命中 uploads,没有 `Read` | 发现了没读正文 | 映射可能够,缺的是读 | +| `Read` 的 path 是 `fast_gdd.md` 而不是 `assets/uploads/…` | sidecar 没被当成磁盘路径 | 还是路径合同 | + +不在账本里写「已遵循 GDD」或「未遵循 GDD」布尔。 + +## 4. 落点 + +两层,都在项目 `.agent/` 控制面内,模型读不到: + +1. **权威时间线**(每回合一个 jsonl,只追加) + `.agent/runtime/direct-codex/turns/.jsonl` +2. **总索引一条摘要**(方便继续翻现有 `agent.db`) + `recordType: "direct.codex.turn"` + +`clientTurnId` 沿用现有规则:trim 后 6–160 位 ASCII 字母数字或连字符,首位字母或数字。文件名用规范化后的 id,不再二次编码。 + +不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。 + +jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。 + +## 5. 记录合同 + +camelCase JSON。禁止出现附件正文、命令 stdout、patch diff、宿主绝对路径、Token、URL 签名。 + +### 5.1 `turn_start` + +在 sidecar **已经渲染之后**、Codex turn **启动之前**写入。`promptSha256` 哈希的是 **用户原文**(command 入参 `prompt`),不是带 sidecar 的全文。 + +```json +{ + "recordType": "direct.codex.turn_start", + "clientTurnId": "Abc123-def", + "sidecarPresent": true, + "promptSha256": "", + "promptChars": 120, + "attachments": [ + { + "name": "fast_gdd.md", + "localPath": "assets/uploads/upload-1788164530559-fast_gdd.md", + "mediaType": "text/markdown", + "size": 8119, + "status": "imported", + "contentSha256": "", + "hashSkipped": null + } + ] +} +``` + +- `attachments` 清洗复用 sidecar:`sanitize_attachment_name` / `media_type` / `status` / `local_path`。把这些函数改成 `pub(crate)`,审计模块不要复制一份。 +- 条数上限仍 8;超出只在 sidecar 文案里写「另有 N 个未展开」,账本 `attachments` 同样只留前 8,另加 `attachmentsOmitted: N`。 +- `sidecarPresent`:本轮渲染走了 Project 头(任一条有合法 path 或 status)。Home 形态不会出现在本账本(Home 不记账)。 +- `contentSha256`:对清洗后的 `localPath` 读项目文件做 SHA-256 小写 hex。文件不存在则省略 hash,`hashSkipped: "missing"`。超过 `DIRECT_CODEX_AUDIT_HASH_MAX_BYTES`(2 MiB)则 `hashSkipped: "too-large"`。`.agent` / `.git` / `..` 路径本来就不会出现在 sidecar 输出里。 + +无附件:`attachments: []`,`sidecarPresent: false`,仍然写 `turn_start`。 + +### 5.2 `item` + +仅 `item/completed`。`item/started` 和 `outputDelta` 不落盘。 + +公共字段: + +```json +{ + "recordType": "direct.codex.item", + "clientTurnId": "Abc123-def", + "seq": 1, + "itemId": "item-…", + "itemType": "commandExecution", + "status": "completed" +} +``` + +`seq` 从 1 起,按成功写入的 item 递增。`itemType` 取 Codex `item.type` 原词;未知类型仍记账 `itemType`,不附带未清洗 payload。 + +按类型附加字段: + +| `item.type` | 追加 | 禁止 | +|---|---|---| +| `commandExecution` | `command` 截断 240 字;`exitCode`;`durationMs`;`actions[]` | `aggregatedOutput` | +| `mcpToolCall` | `tool`、`server`(可省略默认 `agc_tools`)、`durationMs`、§5.4 参数 | `result`、`error` 原文(只留 `status` / `errorKind`) | +| `fileChange` | `changes: [{ path, kind }]`,`kind` 为 `add` / `delete` / `update` | `diff`、`movePath` 的宿主绝对路径(相对化失败则整条 change 丢 path) | +| `imageView` | `path` | 图像字节 | +| `functionCallOutput` | `name`、`namespace` | `output` | +| `webSearch` | `query` 截断 400 字 | 结果页正文 | +| `agentMessage` / `userMessage` / `plan` / `reasoning` / `contextCompaction` / `hookPrompt` | **整类跳过**(终稿已在 jsonl;推理正文不是本账本) | — | +| 其它未知 | 只留公共字段 | 原始 `item` 对象 | + +`commandExecution.actions[]`: + +```json +{ "type": "read", "path": "assets/uploads/upload-…-fast_gdd.md", "contentSha256": "…", "hashSkipped": null } +{ "type": "listFiles", "path": "assets" } +{ "type": "search", "query": "fast_gdd", "path": null } +{ "type": "unknown" } +``` + +- `Read.path` 先相对化再清洗;失败则该 action 记 `{ "type": "read", "pathRejected": true }`,不写绝对路径。 +- 相对化成功后,对磁盘文件按 §5.1 同一套 hash 规则补 `contentSha256`。 +- `command` 里若相对化失败,把 `command` 整段丢掉,改 `commandRedacted: true`(避免 `type C:\Users\…\fast_gdd.md` 进账本)。 + +### 5.3 MCP 参数白名单 + +只抄这些键,其它键丢弃。字符串再经 path 清洗或截断。 + +| 工具 | 落盘参数 | 正文类字段 | +|---|---|---| +| `agc_list_project_files` | `path`、`query`(120)、`kind`、`offset`、`limit` | 无 | +| `agc_write_file` | `path`、`contentChars`(`content` 的字符数,不是正文) | 不落 `content` | +| `taonier_prepare_game_art` | `mode`、`brief`(截断 4000)、`briefChars`、`briefSha256` | **要 brief 原文**(分析定玩法的吸烟枪;上限已是 MCP 合同) | +| `agc_generate_image` | `kind`、`aspectRatio`、`imageSize`、`assetName`、`outputPath`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 不落 32k 全文 | +| `agc_edit_image` | `sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 同上 | +| `agc_create_or_derive_resource` | `kind`、`mode`、`sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | MCP 上限已是 4000 | +| `agc_list_registered_assets` | `kind`、`assetId`、`includeSequenceFrames`、`offset`、`limit` | 无 | +| `agc_list_account_assets` | `folderId`、`query`、`offset`、`limit` | 无 | +| `agc_import_account_assets` | `assetIds`(最多 8 个 id,超出 `assetIdsOmitted`)、`localPaths`(清洗后相对路径,最多 8) | 无 | +| `agc_remove_background` | `sourceLocalAssetId`、`assetName` | 无 | +| `agc_browser_playtest` | `attempt` | 无 | +| `agc_web_search` | `query` 截断 400、`maxResults` | 无 | +| `agc_read_skill_resource` | `skillName`、`relativePath` | 不落 Skill 正文 | +| 未知 MCP 名 | 只留 `tool` + `status` | 不落 `arguments` | + +`brief` / 截断后的 `prompt` 是 **模型自己写的设计文本**,不是用户 GDD 转储。这是分析「仍走收集类」的关键,允许进 jsonl。`agent.db` 摘要只留 `briefPreview` 240 字。 + +### 5.4 `turn_end` + +派生摘要,不是第二真相。字段必须能从本文件已写入的 `turn_start` + `item` 重算出来。 + +```json +{ + "recordType": "direct.codex.turn_end", + "clientTurnId": "Abc123-def", + "completed": true, + "itemCount": 17, + "itemsTruncated": false, + "offeredRead": [ + { + "localPath": "assets/uploads/upload-1788164530559-fast_gdd.md", + "read": false + } + ], + "firstDesign": { + "kind": "mcp:taonier_prepare_game_art", + "seq": 3, + "tool": "taonier_prepare_game_art", + "briefPreview": "俯视角收集冒险小游戏…" + } +} +``` + +`offeredRead.read=true` 当且仅当本 attempt 内存在 `actions.type=read` 或 `imageView` 或 MCP 参数里的 `path` / `localPaths`,清洗后与 `localPath` 字符串相等。hash 对不上仍记 `read: true`,另加 `contentSha256Match: false`(读了另一份同路径文件或读时文件已变)。没有 hash 可对则省略 `contentSha256Match`。 + +`firstDesign`:本 attempt 第一条满足任一条件的 item: + +1. MCP:`taonier_prepare_game_art` / `agc_generate_image` / `agc_edit_image` / `agc_create_or_derive_resource` +2. `agc_write_file` 且 path 以 `game/` 开头或文件名是 `index.html` +3. `fileChange` 且任一条 change path 满足 2 + +列表、搜索、读、Skill 读取、账户素材查询、playtest、web_search **不算** firstDesign。没有则 `firstDesign: null`。 + +`kind` 取值:`mcp:` / `write:` / `fileChange:`。 + +### 5.5 `agent.db` 摘要 + +```json +{ + "recordType": "direct.codex.turn", + "clientTurnId": "Abc123-def", + "turnLog": ".agent/runtime/direct-codex/turns/Abc123-def.jsonl", + "sidecarPresent": true, + "offeredCount": 1, + "offeredRead": [ { "localPath": "assets/uploads/…-fast_gdd.md", "read": false } ], + "firstDesign": { "kind": "mcp:taonier_prepare_game_art", "seq": 3, "briefPreview": "…" }, + "itemCount": 17, + "itemsTruncated": false, + "completed": true, + "auditWriteFailed": false +} +``` + +`turnLog` 必须是项目相对 POSIX。不要把 jsonl 全文复制进 `agent.db`。单条仍受 Ordinary 1 MiB 限制;摘要本身应远小于此。 + +### 5.6 上限 + +| 项 | 值 | +|---|---| +| 每回合 item 条数 | 256;超出再写一条 `recordType: "direct.codex.items_truncated"`,之后 item 丢弃但仍把 `turn_end.itemsTruncated=true` | +| `command` | 240 字 | +| `brief` / 生成类 `prompt` 落盘 | 4000 字 | +| `briefPreview` | 240 字 | +| 文件 hash | 2 MiB | +| 附件条数 | 8(与 sidecar 相同) | +| jsonl 单行 | 沿用现有 jsonl 追加上限;超长截断正文类字段,不截断结构 | + +## 6. 调用链 + +```text +chat_with_game_creator_direct_codex + 规范化 clientTurnId + DirectCodexTurnAudit::start(root, clientTurnId, originalPrompt, attachments) + → 写 turn_start(fail-open) + render_direct_codex_user_prompt // 现有 sidecar,不变 + run_direct_game_creator_turn_at_with_creation_type_and_emitter(..., audit) + → Codex collect 循环在 DirectProject + item/completed 调 audit.observe_item + Ok/Err 都 audit.finish(completed) + → 写 turn_end + agent.db 摘要 +``` + +- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。 +- Home command 不接 audit。 +- 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。 +- `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。 + +`direct_game_creator_codex_chat_at_with_optional_observer` 增加可选 `audit: Option<&mut DirectCodexTurnAudit>`,再传到 `run_turn_with_direct_observer`。仅 `workspace_mode == DirectProject` 且 `audit` 为 Some 时抽取。 + +`run_direct_game_creator_turn_inner` 的 UI observer 保持只处理 `AccumulatedText` / `Activity`。 + +回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 `start` 过就 `finish(false)`,保留已观察到的 item。Codex 尚未启动则 `itemCount=0`。 + +## 7. 失败语义 + +审计 **不得** 把做游戏打失败。所有写盘包在 sink 内: + +- 单次追加失败:记内存 `audit_write_failed=true`,后续 item 仍尝试写;`finish` 时摘要带 `auditWriteFailed: true`。 +- 连摘要都写不进去:只在 Direct debug / 现有进度通道能承受的前提下忽略;不新增用户可见报错文案。 +- 不引入新的 Tauri 事件名。 + +与 `conversation.write` 失败不同:助手终稿落盘失败仍按现有逻辑拒绝返回。审计失败不走那条。 + +## 8. 安全 + +- 路径:与 sidecar 同一套相对 POSIX 清洗;相对化失败不写原绝对路径。 +- 控制面:hash / 读文件只用 `resolve_local_project_path`;拒绝 `.agent` / `.git` / 敏感文件。这些路径若出现在 commandActions 里,只记 `pathRejected`。 +- 不把 `aggregated_output`、MCP result、function output、diff 暂存在内存再截断——抽取函数根本不读这些键。 +- jsonl 位于 `.agent/runtime/**`,现有 Direct 控制面边界禁止模型当普通项目文档读。 +- 前端观察者和审计 sink 分叉,禁止图省事 `observer(Item { params })`。 + +## 9. 代码落地 + +新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs): + +- `DirectCodexTurnAudit` +- `start` / `observe_item` / `finish` +- 相对化、hash、MCP 白名单、`firstDesign` / `offeredRead` 派生 +- 单元测试(见 §11) + +[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs):`mod direct_codex_audit` + `pub(crate) use`。 + +[`direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs):清洗函数改 `pub(crate)`,行为不变。 + +[`codex_app_server.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs): + +- `run_turn_with_direct_observer` / `direct_game_creator_codex_chat_at_with_optional_observer` 增加 `audit: Option<&mut DirectCodexTurnAudit>` +- collect 循环 `Item { completed: true, .. }` 且 DirectProject 时 `audit.observe_item(¶ms)` +- **不要** 把 `params` 塞进 `DirectCodexTurnObservation` +- 现有 Home / ToolHost / 安全活动词测试保持逐字 + +[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs): + +- `chat_with_game_creator_direct_codex` 创建 audit(原文 + attachments),Ok/Err 都 `finish` +- 把 audit 传入 `run_direct_game_creator_turn_at_with_creation_type_and_emitter` +- 该函数和 inner 增加可选 audit;CLI 入口签名不变 + +不要给 `agc_write_file` / 每个 MCP handler 再写一份平行审计。 + +前端、DTO、sidecar 文案、home.suite 附件断言:本期不改。不新增 UI。 + +文档:落地提交时把本文状态改为「现行合同(已按本文落地)」;`decision-log.md` 记一条;不要把 08-30 技术说明改成已修复。 + +## 10. 测试 + +全部是 Rust 单元测试,用 fixture item JSON,不拉真 Codex。 + +1. `turn_start`:原文 hash 稳定;sidecar 路径清洗后出现;非法 `../` 不进 attachments;无附件 `sidecarPresent=false`。 +2. 附件文件写入临时项目后 `contentSha256` 与直接 hash 一致;缺文件 `hashSkipped=missing`;超过 2 MiB `too-large`。 +3. `commandExecution` + `commandActions: [{type:read, path: }]` → 相对路径 + hash;`aggregatedOutput` 即使在 fixture 里也不出现在落盘 JSON。 +4. `Read` 相对化失败 → `pathRejected`,落盘 JSON 不含 `C:\\` / `Users`。 +5. `mcpToolCall` `taonier_prepare_game_art`:`brief` 保留;`result` 丢掉。 +6. `agc_write_file`:有 path 与 `contentChars`,无 content。 +7. `agc_generate_image`:32k prompt 只留 4000 + `promptChars` + sha256。 +8. `fileChange`:path + kind,无 diff。 +9. `offeredRead`:读路径等于 offered → `read=true`;只 list/search → `read=false`。 +10. `firstDesign`:先 read 再 art → kind 是 mcp art,seq 是 art 那条;只有 read → `null`。 +11. 第 257 条 item 触发 truncated,`turn_end.itemsTruncated=true`。 +12. `agent.db` 摘要含 `turnLog` 相对路径、`offeredRead`、`firstDesign.briefPreview`。 +13. 写盘注入失败:`finish` 不 panic、不返回 Err 给调用方(sink 方法是 `()`)。 +14. 未知 `item.type` 只留公共字段。 +15. 现有 DirectHome 活动词测试、sidecar 渲染测试不受影响。 + +不测:真模型是否读 GDD、是否生成弹幕射击、浏览器验收文案。 + +## 11. 验收(方案落地后的人工分析) + +用一次「上传 md + 做成游戏 / 首页做游戏」的本地项目: + +1. 存在 `.agent/runtime/direct-codex/turns/.jsonl`。 +2. `agent.db` 有对应 `direct.codex.turn`。 +3. `turn_start.attachments[].localPath` 与 sidecar 项目路径一致。 +4. jsonl **没有** GDD 正文、没有 `aggregatedOutput`、没有 patch。 +5. 能根据 `offeredRead` + `firstDesign` 填上 §3 四行表的其中一行,而不用猜隔离 session。 +6. 工作台气泡仍是用户原文;jsonl 对话仍无 sidecar。 +7. `npm run check:encoding`、`git diff --check`、相关 Rust 单测通过。 + +## 12. 实现顺序 + +1. `direct_codex_audit.rs` + 清洗函数 `pub(crate)` + fixture 测试 +2. `codex_app_server` collect 接 sink;观察者枚举不变 +3. GUI Direct command 创建 / finish sink +4. encoding 与定向 `cargo test` +5. 合入时改本文状态、decision-log、sidecar 文档里「native 审计另排期」那一行改为指向本文 + +## 13. 与 sidecar / issue 的边界 + +- sidecar:让模型 **知道路径**。已落地,合同不变。 +- 本账本:让人 **看见模型做了什么**。不替代 sidecar,也不在本方案里做强制读取。 +- issue #212 主问题仍是消费失败;本账本是为下一刀修复提供证据,不是把 212 关单。 diff --git a/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md b/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md index 4de2f960b..4ac032b68 100644 --- a/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md +++ b/docs/technical/【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md @@ -206,6 +206,7 @@ Direct app-server 使用 ephemeral thread。stdout 中的 `item/started`、`item - 策划 run ID:`gameagent-cd7f6c81` - 做游戏 run ID:`gameagent-77b5aa31` - 上传 GDD(项目相对路径):`assets/uploads/upload-1788083777445-fast_gdd.md` +- 审计账本方案(已落地,不关闭本 Issue):[`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) - 建议随 Issue 附上或引用对应 run 的以下复核材料: - Direct 对话:`.agent/conversations/project.jsonl` - Direct Agent DB:`.agent/agent.db`