修复浏览器点击因果收口时序
Project CI / Repository checks (push) Failing after 35s
Project CI / Frontend tests (push) Failing after 2m21s
Project CI / Backend tests (push) Successful in 4m9s
Project CI / Native shell tests (push) Successful in 20m17s

冻结可信输入监听器同步产生的最后状态

点击返回后在隔离世界收口并排除定时任务污染

补齐后注册点击监听器真实Chrome回归与指纹文档
This commit is contained in:
2026-08-04 04:41:56 +08:00
parent e12586b0b8
commit c804b1e564
4 changed files with 195 additions and 39 deletions
@@ -24,12 +24,12 @@ pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SA
pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12;
pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12;
pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str = concat!(
"primary-action=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-click-dispatch-tail-sequence-advance\n",
"primary-action=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-post-click-dispatch-microtask-sequence-advance\n",
"tetris-primary-action=same-piece-rotation-change\n",
"tetris-start-opportunity=same-piece-gravity-row-or-semantic-lock-progress\n",
"tetris-post-action=probe-before-gameplay-to-new-piece-lock-board-and-line-check-progress\n",
"tetris-restart=board-counters-reset\n",
"restart=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-click-dispatch-tail-sequence-advance"
"restart=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-post-click-dispatch-microtask-sequence-advance"
);
#[derive(Clone, Debug, Deserialize)]
@@ -52,14 +52,21 @@ struct GenericActionSequenceProbeRaw {
static GENERIC_ACTION_SEQUENCE_PROBE_NONCE: AtomicU64 = AtomicU64::new(1);
fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Result<String, String> {
fn generic_action_sequence_probe_script(
selector: &str,
ready_key: &str,
finish_key: &str,
) -> Result<String, String> {
let selector = serde_json::to_string(selector)
.map_err(|_| "固定试玩动作因果探针 selector 无法编码".to_string())?;
let ready_key = serde_json::to_string(ready_key)
.map_err(|_| "固定试玩动作因果探针 ready key 无法编码".to_string())?;
let finish_key = serde_json::to_string(finish_key)
.map_err(|_| "固定试玩动作因果探针 finish key 无法编码".to_string())?;
Ok(format!(
r#"(() => new Promise((resolve) => {{
const readyKey = {ready_key};
const finishKey = {finish_key};
const controls = document.querySelectorAll({selector});
if (controls.length !== 1 || !(controls[0] instanceof HTMLElement)) {{
globalThis[readyKey] = 'invalid-control';
@@ -71,17 +78,16 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu
let timeoutId = null;
let stateObserver = null;
const inputEventTypes = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
const dispatchTailTargets = [];
let synchronousAfterState = null;
let clickObserved = false;
const cleanup = () => {{
if (timeoutId !== null) clearTimeout(timeoutId);
if (stateObserver !== null) stateObserver.disconnect();
for (const type of inputEventTypes) {{
window.removeEventListener(type, observeInput, true);
}}
for (const target of dispatchTailTargets) {{
target.removeEventListener('click', observeClickDispatchTail, false);
}}
try {{ delete globalThis[readyKey]; }} catch (_) {{}}
try {{ delete globalThis[finishKey]; }} catch (_) {{}}
}};
const finish = (value) => {{
if (settled) return;
@@ -108,15 +114,7 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu
if (!baselineCaptured) {{
baselineCaptured = true;
}}
}}
function observeClickDispatchTail(event) {{
if (!event.isTrusted || !targetsControl(event)) return;
if (event.currentTarget !== window && !event.cancelBubble) return;
finish({{
status: baselineCaptured && inputEventObserved ? 'completed' : 'missing-input-baseline',
beforeState,
afterState: baselineCaptured && inputEventObserved ? readState() : null,
}});
if (event.type === 'click') clickObserved = true;
}}
stateObserver = new MutationObserver(() => {{
const activeEvent = globalThis.event;
@@ -126,6 +124,7 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu
&& targetsControl(activeEvent)) {{
inputEventObserved = true;
if (!baselineCaptured) baselineCaptured = true;
synchronousAfterState = readState();
return;
}}
if (!baselineCaptured) beforeState = readState();
@@ -134,12 +133,13 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu
for (const type of inputEventTypes) {{
window.addEventListener(type, observeInput, {{ capture: true }});
}}
for (let target = control; target; target = target.parentNode) {{
dispatchTailTargets.push(target);
target.addEventListener('click', observeClickDispatchTail, false);
}}
dispatchTailTargets.push(window);
window.addEventListener('click', observeClickDispatchTail, false);
globalThis[finishKey] = () => finish({{
status: baselineCaptured && inputEventObserved && clickObserved ? 'completed' : 'missing-input-baseline',
beforeState,
afterState: baselineCaptured && inputEventObserved && clickObserved
? (synchronousAfterState === null ? beforeState : synchronousAfterState)
: null,
}});
globalThis[readyKey] = 'armed';
timeoutId = setTimeout(() => {{
finish({{ status: 'timeout', beforeState, afterState: null }});
@@ -156,13 +156,28 @@ fn generic_action_sequence_probe_ready_script(ready_key: &str) -> Result<String,
))
}
fn generic_action_sequence_probe_finish_script(finish_key: &str) -> Result<String, String> {
let finish_key = serde_json::to_string(finish_key)
.map_err(|_| "固定试玩动作因果探针 finish key 无法编码".to_string())?;
Ok(format!(
"(() => {{ const finish = globalThis[{finish_key}]; if (typeof finish !== 'function') return false; finish(); return true; }})()"
))
}
pub(in crate::browser) fn generic_action_sequence_probe_fingerprint_material() -> String {
let ready_key = "__genarrativeActionProbeFingerprintReady";
let install = generic_action_sequence_probe_script(PLAYTEST_PRIMARY_ACTION_SELECTOR, ready_key)
.expect("fixed generic action probe fingerprint inputs must serialize");
let finish_key = "__genarrativeActionProbeFingerprintFinish";
let install = generic_action_sequence_probe_script(
PLAYTEST_PRIMARY_ACTION_SELECTOR,
ready_key,
finish_key,
)
.expect("fixed generic action probe fingerprint inputs must serialize");
let ready = generic_action_sequence_probe_ready_script(ready_key)
.expect("fixed generic action probe ready fingerprint inputs must serialize");
format!("install-and-finish-script:\n{install}\nready-script:\n{ready}")
let finish = generic_action_sequence_probe_finish_script(finish_key)
.expect("fixed generic action probe finish fingerprint inputs must serialize");
format!("install-script:\n{install}\nready-script:\n{ready}\nfinish-script:\n{finish}")
}
fn validate_generic_action_sequence_probe(
@@ -222,7 +237,11 @@ async fn click_with_generic_action_sequence_probe(
"__genarrativeActionProbeReady-{}-{nonce}",
std::process::id()
);
let script = generic_action_sequence_probe_script(selector, &ready_key)?;
let finish_key = format!(
"__genarrativeActionProbeFinish-{}-{nonce}",
std::process::id()
);
let script = generic_action_sequence_probe_script(selector, &ready_key, &finish_key)?;
let context_id = page
.secondary_execution_context()
.await
@@ -277,7 +296,26 @@ async fn click_with_generic_action_sequence_probe(
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
click_playtest_control(page, selector, action, deadline).await
click_playtest_control(page, selector, action, deadline).await?;
let finish_script = generic_action_sequence_probe_finish_script(&finish_key)?;
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(|| format!("固定试玩动作 {action} 因果探针收口超时"))?;
let params = EvaluateParams::builder()
.expression(finish_script)
.context_id(context_id.clone())
.return_by_value(true)
.await_promise(false)
.build()
.map_err(|_| format!("固定试玩动作 {action} 收口探针参数无效"))?;
let evaluated = tokio::time::timeout(remaining, page.evaluate(params))
.await
.map_err(|_| format!("固定试玩动作 {action} 因果探针收口超时"))?
.map_err(|_| format!("固定试玩动作 {action} 因果探针收口失败"))?;
if !evaluated.into_value::<bool>().unwrap_or(false) {
return Err(format!("固定试玩动作 {action} 因果探针收口身份失效"));
}
Ok(())
};
let (raw, click_result) = tokio::join!(evaluate_probe, click_when_ready);
click_result?;
@@ -1137,6 +1175,7 @@ mod tests {
let script = generic_action_sequence_probe_script(
PLAYTEST_PRIMARY_ACTION_SELECTOR,
"__testReadyKey",
"__testFinishKey",
)
.expect("render action probe");
assert!(script.contains("new Promise"));
@@ -1145,20 +1184,21 @@ mod tests {
assert!(script.contains("new MutationObserver"));
assert!(script.contains("const activeEvent = globalThis.event"));
assert!(script.contains("window.addEventListener(type, observeInput"));
assert!(script.contains("if (event.type === 'click') clickObserved = true"));
assert!(script.contains("synchronousAfterState = readState()"));
assert!(script.contains("globalThis[finishKey] = () => finish"));
assert!(
script.contains("target.addEventListener('click', observeClickDispatchTail, false)")
script.contains("synchronousAfterState === null ? beforeState : synchronousAfterState")
);
assert!(
script.contains("window.addEventListener('click', observeClickDispatchTail, false)")
);
assert!(script.contains("event.currentTarget !== window && !event.cancelBubble"));
assert!(script.contains("afterState: baselineCaptured && inputEventObserved ? readState()"));
assert!(script.contains("content.length <= 131072"));
assert!(script.contains("status: baselineCaptured && inputEventObserved ? 'completed'"));
assert!(!script.contains("finishKey"));
assert!(script.contains(
"status: baselineCaptured && inputEventObserved && clickObserved ? 'completed'"
));
assert!(script.contains("finishKey"));
assert!(script.contains("resolve(value)"));
assert!(!script.contains("__genarrativeGenericActionSequenceProbe"));
assert!(!script.contains("beforeGameplay"));
assert!(!script.contains("afterGameplay"));
assert!(!script.contains("observeClickDispatchTail"));
}
}
@@ -260,11 +260,11 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
generic,
"adc95709431dc762d3293a580133a1a4dc671ced3e6d0db75d214bb1e7c73964"
"fc145c8d8af2801e29114c2ce6e4a3a5a6940617c53fd79803103b539b5ca48b"
);
assert_eq!(
tetris,
"92e6eae0b8af8eca2baf0ddfc6d5084a1145e7be18f6f15e944289d4d9df98a6"
"ea42a14d583426da748bd1aa6ed1d2c2427d20451161fbe5ec32e97bc0eed214"
);
assert_eq!(
lane,
@@ -1386,6 +1386,122 @@ async fn real_chrome_generic_playtest_accepts_early_mouse_and_late_click_action_
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_click_listener_registered_during_pointerdown() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Late Listener Generic Browser Fixture</title></head>
<body>
<main>Late listener generic fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Collect</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"score":0}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#38bdf8';
context.fillRect(48, 48, 96, 64);
const publish = () => { stateNode.textContent = JSON.stringify(state); };
const advance = (mutation) => {
mutation();
state.sequence += 1;
publish();
};
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') {
advance(() => { state.phase = 'playing'; });
}
});
const primary = document.querySelector('[data-playtest-id="primary-action"]');
window.addEventListener('pointerdown', (event) => {
if (!event.isTrusted || event.target !== primary || state.phase !== 'playing') return;
window.addEventListener('click', (clickEvent) => {
if (clickEvent.isTrusted && clickEvent.target === primary && state.phase === 'playing') {
advance(() => { state.score += 1; });
}
}, { once: true });
}, true);
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) {
advance(() => {
state.phase = 'ready';
state.score = 0;
});
event.stopImmediatePropagation();
}
});
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Late listener generic fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real late-listener generic browser validation");
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
result.diagnostics, result.viewport_results
);
let playtest = result.playtest.expect("generic playtest result");
assert!(playtest.passed, "{:#?}", playtest.diagnostics);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.final_sequence, Some(3));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_timer_deferred_action_progress() {
@@ -5927,7 +5927,7 @@
- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,并把主图/切片摘要及 Canvas 身份冻结到 `.agent/runtime/art-spritesheet-contract.json` 私有回执;主图安装、回执或资产登记失败时必须恢复整组旧合同。generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码,并要求实际素材、公开清单、当前 Canvas 登记与私有回执在内容摘要、规范像素摘要、可见 alpha、四类唯一性和资源身份上全部一致。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。
- 图集身份与恢复补充:canonical game-chat 图集是 External 通用资源模型的严格完成子集。主图与四个切片都必须含非空 `assetObjectId`,五个 ID 互不复用;同一对象在顶层、resource 与 asset 中重复出现的 `assetObjectId``taskId` 都必须逐项完全一致,冲突时不得择一冻结。公开切片清单和私有回执同时冻结 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,四项切片按 usage 唯一且逐项比较 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256`。旧项目仅缺私有回执时不得从可编辑公开清单伪造回执;只有同一 `project-supervisor-game-chat` 父 run 下处于 running 的 `art-asset-plan` scheduled child、固定输出路径且当前合同确实失效时,才允许 `replaceExisting=true` 原位 repair。任何 canonical 文件变化前,Runtime 必须在 `.agent/runtime` 私有事务目录原子持久化九个固定合同路径的旧状态并回读,随后写 `prepared` marker;主图字节先写 staging,再安装 canonical 主图,随后才提交四切片、公开清单、私有回执和 Canvas 登记,登记成功后写 `committed` marker 才可回收快照和 backup。恢复只在取得同一项目写锁后按持久 transaction id 扫描;`prepared``committed` 必须整组恢复旧合同,`committed` 只做幂等清理,不能凭随机 previous/replacement 文件名干扰正在提交的事务。这样即使进程被强杀且远端暂不可用,也能恢复完整旧合同,不留下“旧主图 + 新切片”或半写 canonical PNG。
- 图集事务退役补充:九路径快照在创建和恢复读取时都使用跨平台不跟随符号链接 / reparse point 的文件句柄核算 64 MiB 总预算,marker、journal 与快照均通过有界双次读取和句柄元数据复核拒绝同长度并发改写;实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复开始时锚定可信事务目录句柄,每次读取控制文件前后都复核目录身份,拒绝 rename、junction 或替换目录。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后缓存全部 canonical 路径的恢复前状态;每项落盘前再次校验目标与父目录,后续项失败时按逆序回滚本轮已应用项,但回滚前必须 CAS 证明目标仍等于本轮安装结果,外部修改不得被覆盖并进入 reconciliation。末尾路径竞态或快照损坏不得留下静默的新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。
- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白,并按 `type / language / nomodule` 判断 Chromium 中的可执行脚本;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达匿名或命名函数、短路表达式、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定未被局部变量或函数参数遮蔽的正式棋盘。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图只按显式 export/import binding 传递语义,side-effect import 不暴露被导入模块的局部绑定,ASI 换行与 template `${...}` 内真实 import 仍参与依赖解析;文件按去重数量和累计 2 MiB 上限有界读取,对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一浏览器任务内 trusted 输入分发尾部冻结的状态作为 click 前基线,页面全局对象不能改写因果证据,真实 window bubble 处理器仍可被正确验收,0ms timer 不得抢入该因果窗口。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。
- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白,并按 `type / language / nomodule` 判断 Chromium 中的可执行脚本;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达匿名或命名函数、短路表达式、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定未被局部变量或函数参数遮蔽的正式棋盘。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图只按显式 export/import binding 传递语义,side-effect import 不暴露被导入模块的局部绑定,ASI 换行与 template `${...}` 内真实 import 仍参与依赖解析;文件按去重数量和累计 2 MiB 上限有界读取,对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中保留点击前基线,MutationObserver 只冻结 trusted 输入 listener 同步产生的最后状态,CDP 点击返回后再收口该冻结值,因此后注册的同步 click listener 仍会计入,而 RAF / timer 任务不会污染因果证据。页面全局对象不能改写隔离世界证据,capture-phase、stopPropagation 与真实 window bubble 处理器均应正确验收。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``start-dev-stack.mjs``src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``response_stream.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导
@@ -829,7 +829,7 @@ game-project/
- `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。
- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId``taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须通过可信有界双读快照到 `.agent/runtime` 私有事务目录并写 `prepared` markerCanvas 资产登记成功并写 `committed` marker 后才可清理;marker、journal 和快照必须由跨平台不跟随 symlink / reparse point 的句柄读取,拒绝同长度并发改写。恢复开始时锚定事务目录句柄,每次控制文件读取前后都复核目录身份;随后在同一项目写锁内完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,再按 transaction id 整组回滚或幂等清理。每项写入前重新校验目标与父目录,晚序目标竞态或任一末尾快照损坏时逆序撤销本轮已应用项;回滚前必须 CAS 证明目标仍是本轮安装结果,外部修改不得被覆盖,冲突进入 reconciliation。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。
- 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previouscanonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1``playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris``activePieceId``rotation``row``lockedPieces``lineClearChecks``clearedLines``occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并按 `type / language / nomodule` 判断可执行脚本;它忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、不可达匿名或命名函数、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断真实控制,并作用于未被局部变量或函数参数遮蔽的正式棋盘。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,只按显式 export/import binding 传递语义,side-effect import 不共享局部词法绑定;ASI 换行和 template `${...}` 内真实表达式仍参与依赖解析,文件按去重数量并受 256 文件、累计 2 MiB 上限约束,对象属性和正则正文不能伪造依赖。浏览器因果探针运行于 Chromium 隔离执行上下文,在同一浏览器任务内冻结 trusted 输入分发尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase 或 window bubble click 处理器也不会被误判0ms timer 不得抢入因果窗口;探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1``playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris``activePieceId``rotation``row``lockedPieces``lineClearChecks``clearedLines``occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并按 `type / language / nomodule` 判断可执行脚本;它忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、不可达匿名或命名函数、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断真实控制,并作用于未被局部变量或函数参数遮蔽的正式棋盘。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,只按显式 export/import binding 传递语义,side-effect import 不共享局部词法绑定;ASI 换行和 template `${...}` 内真实表达式仍参与依赖解析,文件按去重数量并受 256 文件、累计 2 MiB 上限约束,对象属性和正则正文不能伪造依赖。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 同步产生的最后状态,并在 CDP 点击返回后收口冻结值;这样后注册的同步 click listener 会计入,RAF / timer 不会污染证据。受测页面不能通过全局变量改写证据,既有 capture-phase、stopPropagation 或 window bubble 处理器也不会被误判;探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。
- 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足``可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。
- tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization`file.*.path``project.patchset.changes[*].path``project.git_commit.paths[*]``command.*.cwd``image.inspect.paths[*]``canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。