合入 origin/master 最新提交 a465e481e
保留后台错误报告、AGC 受控搜索与 Router 计费安全修复
同步 SpacetimeDB migration、技能文档和配置契约
27 KiB
Direct 回合行为审计账本
- 日期:2026-08-31
- 状态:现行合同(已按本文落地)
- 问题:Gitea issue #212 的第二段(Direct 原生读 / 工具行为无法从项目产物判断);用于分析「附件已映射仍未按文档实施」
- 关联:
【技术方案】DirectProject本轮附件路径映射-2026-08-31.md、【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md - 原则:落地后代码简洁可维护;审计是 Direct 行为时间线,不是 GDD 特例,也不替代 sidecar
0. 一句话
Direct GUI 回合已经能看见 Codex item/completed,但只收成 UI 活动词,隔离 CODEX_HOME 随后删除。本方案在项目内留下有界、可共享的回合账本:本轮提供了哪些附件路径、按什么顺序做了读/搜/列表/MCP/写文件,以及第一次定玩法的动作是什么。用来区分「没读附件」和「读了仍走默认收集类」,不灌正文、不强制读取、不拷会话目录。
1. 目标与非目标
目标
一次带 clientTurnId 的 DirectProject GUI 回合结束后,只凭项目目录应能回答:
- 本轮 sidecar 是否发出,原名映射到哪些项目相对路径,文件当时的
contentSha256。 - 模型是否用 native 命令 / 列表 / 搜索 / 看图 / MCP 打开过那些路径(路径 + 当时磁盘 hash,不是 stdout)。
- 顺序:读附件是在第一次美术 brief / 第一次写
game/之前还是之后。 - 第一次「定玩法」动作是什么(优先
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/ MCPresult/ patchdiff/FunctionCallOutput正文。 - 不把原始 item JSON 送进 Tauri 前端事件(现有
DirectCodexTurnObservation仍只允许安全活动词和流式正文)。 - 不扫
kind=uploaded历史附件;只记本轮 sidecar 提供的集合。 - DirectHome、ToolHost、CLI
--direct-codex-chat(无clientTurnId)本期不写这份账本。 - 本期不改 UI,不在聊天面板展示审计。
- 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。
2. 现状
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/ 控制面内,模型读不到:
- 权威时间线(每回合一个 jsonl,只追加)
.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl - 总索引一条摘要(方便继续翻现有
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 的全文。
{
"recordType": "direct.codex.turn_start",
"clientTurnId": "Abc123-def",
"sidecarPresent": true,
"promptSha256": "<sha256 hex of original user text UTF-8>",
"promptChars": 120,
"attachments": [
{
"name": "fast_gdd.md",
"localPath": "assets/uploads/upload-1788164530559-fast_gdd.md",
"mediaType": "text/markdown",
"size": 8119,
"status": "imported",
"contentSha256": "<sha256 hex or omit>",
"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 不落盘。
公共字段:
{
"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[]:
{ "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 |
无 |
| 未知 MCP 名 | 只留 tool + status |
不落 arguments |
brief / 截断后的 prompt 是 模型自己写的设计文本,不是用户 GDD 转储。这是分析「仍走收集类」的关键,允许进 jsonl。agent.db 摘要只留 briefPreview 240 字。
5.4 turn_end
派生摘要,不是第二真相。字段必须能从本文件已写入的 turn_start + item 重算出来。
{
"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:
- MCP:
taonier_prepare_game_art/agc_generate_image/agc_edit_image/agc_create_or_derive_resource agc_write_file且 path 以game/开头或文件名是index.htmlfileChange且任一条 change path 满足 2
列表、搜索、读、Skill 读取、账户素材查询、playtest、web_search 不算 firstDesign。没有则 firstDesign: null。
kind 取值:mcp:<tool> / write:<path> / fileChange:<path>。
5.5 agent.db 摘要
{
"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. 调用链
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(仅开启流式时)映射为 streaming,IntermediateText 与 Activity 一律映射为 running,只向前端暴露安全活动词与可见正文,不携带原始 item JSON。
回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 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:
DirectCodexTurnAuditstart/observe_item/finish- 相对化、hash、MCP 白名单、
firstDesign/offeredRead派生 - 单元测试(见 §11)
agent.rs:mod direct_codex_audit + pub(crate) use。
direct_codex_attachments.rs:清洗函数改 pub(crate),行为不变。
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 / 安全活动词测试保持逐字
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。
turn_start:原文 hash 稳定;sidecar 路径清洗后出现;非法../不进 attachments;无附件sidecarPresent=false。- 附件文件写入临时项目后
contentSha256与直接 hash 一致;缺文件hashSkipped=missing;超过 2 MiBtoo-large。 commandExecution+commandActions: [{type:read, path: <abs>}]→ 相对路径 + hash;aggregatedOutput即使在 fixture 里也不出现在落盘 JSON。Read相对化失败 →pathRejected,落盘 JSON 不含C:\\/Users。mcpToolCalltaonier_prepare_game_art:brief保留;result丢掉。agc_write_file:有 path 与contentChars,无 content。agc_generate_image:32k prompt 只留 4000 +promptChars+ sha256。fileChange:path + kind,无 diff。offeredRead:读路径等于 offered →read=true;只 list/search →read=false。firstDesign:先 read 再 art → kind 是 mcp art,seq 是 art 那条;只有 read →null。- 第 257 条 item 触发 truncated,
turn_end.itemsTruncated=true。 agent.db摘要含turnLog相对路径、offeredRead、firstDesign.briefPreview。- 写盘注入失败:
finish不 panic、不返回 Err 给调用方(sink 方法是())。 - 未知
item.type只留公共字段。 - 现有 DirectHome 活动词测试、sidecar 渲染测试不受影响。
不测:真模型是否读 GDD、是否生成弹幕射击、浏览器验收文案。
11. 验收(方案落地后的人工分析)
用一次「上传 md + 做成游戏 / 首页做游戏」的本地项目:
- 存在
.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl。 agent.db有对应direct.codex.turn。turn_start.attachments[].localPath与 sidecar 项目路径一致。- jsonl 没有 GDD 正文、没有
aggregatedOutput、没有 patch。 - 能根据
offeredRead+firstDesign填上 §3 四行表的其中一行,而不用猜隔离 session。 - 工作台气泡仍是用户原文;jsonl 对话仍无 sidecar。
npm run check:encoding、git diff --check、相关 Rust 单测通过。
12. 实现顺序
direct_codex_audit.rs+ 清洗函数pub(crate)+ fixture 测试codex_app_servercollect 接 sink;观察者枚举不变- GUI Direct command 创建 / finish sink
- encoding 与定向
cargo test - 合入时改本文状态、decision-log、sidecar 文档里「native 审计另排期」那一行改为指向本文
13. 与 sidecar / issue 的边界
- sidecar:让模型 知道路径。已落地,合同不变。
- 本账本:让人 看见模型做了什么。不替代 sidecar,也不在本方案里做强制读取。
- issue #212 主问题仍是消费失败;本账本是为下一刀修复提供证据,不是把 212 关单。