加固游戏静态门与图集事务一致性

使用 Oxc AST 与语义符号验证 JavaScript 模块、作用域和控制流
锚定图集事务目录并保证九文件快照与错误回滚一致
修复早注册捕获监听器下的可信点击因果验收
补齐默认与命名空间模块投影、回归测试和长期技术文档
This commit is contained in:
2026-08-04 05:58:17 +08:00
parent c804b1e564
commit bf8eff53e3
9 changed files with 3047 additions and 718 deletions
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,12 @@ futures = "0.3"
http = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
jsonschema = { version = "0.49.3", default-features = false }
oxc_allocator = "0.143.0"
oxc_ast = "0.143.0"
oxc_ast_visit = "0.143.0"
oxc_parser = "0.143.0"
oxc_semantic = "0.143.0"
oxc_span = "0.143.0"
rmcp = { version = "2.2.0", default-features = false, features = ["client", "reqwest-native-tls", "transport-child-process", "transport-streamable-http-client-reqwest"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1566,6 +1566,37 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() {
None,
"the legacy JavaScript language value remains executable",
);
let explicit_javascript_type_overrides_language = valid.replacen(
"<script>\nlet board",
"<script type=text/javascript language=vbscript>\nlet board",
1,
);
assert_eq!(
inherited_gameplay_semantics_gap(
task,
explicit_javascript_type_overrides_language.as_bytes(),
),
None,
"an explicit JavaScript type makes Chromium ignore the legacy language attribute",
);
let explicit_plain_type_overrides_language = valid.replacen(
"<script>\nlet board",
"<script type=text/plain language=javascript>\nlet board",
1,
);
assert_eq!(
inherited_gameplay_semantics_gap(task, explicit_plain_type_overrides_language.as_bytes())
.as_deref(),
Some("board-state"),
"an explicit non-JavaScript type stays inert even when language says JavaScript",
);
let invalid_javascript = valid.replacen("stepDown();setInterval", ")stepDown();setInterval", 1);
assert_eq!(
inherited_gameplay_semantics_gap(task, invalid_javascript.as_bytes()).as_deref(),
Some("board-state"),
"a syntactically invalid script must not contribute semantic bait",
);
let vertical_tab_tag = valid.replacen("<script>\nlet board", "<script\u{000b}>\nlet board", 1);
assert_eq!(
@@ -1717,6 +1748,30 @@ fn inherited_tetris_contract_binds_fall_clear_and_rotation_semantics() {
Some("piece-lock"),
"a board parameter must be bound to the real board at the lock call",
);
let guarded_fall = valid.replace(
"function stepDown(){if(hasCollision()){lockPiece();}else{current.y+=1;sequence+=1;publish();}}",
"function stepDown(){if(paused)/* guard } */return;if(hasCollision()){lockPiece();}else{current.y+=1;sequence+=1;publish();}}",
);
let guarded_fall = guarded_fall.replace(
"let sequence=0,lockedPieces=0,lineClearChecks=0,clearedLines=0;",
"let sequence=0,lockedPieces=0,lineClearChecks=0,clearedLines=0,paused=false;",
);
assert_eq!(
inherited_gameplay_semantics_gap(task, guarded_fall.as_bytes()),
None,
"a conditional guard return must not truncate the reachable fall path",
);
let template_fall = valid.replace(
"current.y+=1;sequence+=1;publish();",
"`${/* a brace } inside a comment */ /}/.test('}') ? (current.y+=1) : (current.y+=1)}`;sequence+=1;publish();",
);
assert_eq!(
inherited_gameplay_semantics_gap(task, template_fall.as_bytes()),
None,
"template interpolation comments and regex braces must not hide executable gameplay",
);
}
#[test]
@@ -2023,6 +2078,23 @@ import './gameplay/tetris.mjs';"#,
"a valid newline-delimited static import must remain discoverable",
);
let fake_reexport_html = inline_module_html.replace(
"import './main.js';",
"const foo=1;export {foo}\nconst from='./gameplay/tetris.mjs';",
);
let fake_reexport = read_external_gameplay_javascript_at(root, &fake_reexport_html)
.expect("ASI must terminate a local export before an unrelated from binding");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
fake_reexport_html.as_bytes(),
&fake_reexport,
)
.as_deref(),
Some("board-state"),
"a later from variable must not turn a local export into a fake re-export",
);
let inline_classic_html = inline_module_html.replace(" type=\"module\"", "");
let inline_classic = read_external_gameplay_javascript_at(root, &inline_classic_html)
.expect("ignore an invalid static import in a classic inline script");
@@ -2103,6 +2175,18 @@ import './gameplay/tetris.mjs';"#,
"const load = function(){ return import('./gameplay/tetris.mjs'); };",
"an uncalled anonymous function expression is unreachable",
),
(
"const load = () => import('./gameplay/tetris.mjs');",
"an uncalled expression-body arrow is unreachable",
),
(
"function load(){return import('./gameplay/tetris.mjs')} function invoke(load){load()} invoke(()=>{});",
"a parameter call must not activate a shadowed top-level loader",
),
(
"function load(){return import('./gameplay/tetris.mjs')} function invoke(){const load=()=>{};load()} invoke();",
"a lexical binding call must not activate a shadowed top-level loader",
),
] {
let unreachable_html = inline_classic_html.replace("import './main.js';", bootstrap);
let unreachable = read_external_gameplay_javascript_at(root, &unreachable_html)
@@ -2123,6 +2207,7 @@ import './gameplay/tetris.mjs';"#,
"(function(){ return import('./gameplay/tetris.mjs'); })();",
"const load = function(){ return import('./gameplay/tetris.mjs'); }; load();",
"const loaded = `${import('./gameplay/tetris.mjs')}`;",
"const loaded = `${/* } */ /}/.test('}') && import('./gameplay/tetris.mjs')}`;",
] {
let reachable_html = inline_classic_html.replace("import './main.js';", bootstrap);
let reachable = read_external_gameplay_javascript_at(root, &reachable_html)
@@ -2270,14 +2355,14 @@ import './gameplay/tetris.mjs';"#,
let exported_scope_a = gameplay[..split_at]
.replace("let board=", "export let board=")
.replace("let current=", "export let current=")
.replace("function rotatePiece()", "export function rotatePiece()")
.replace("function clearLines()", "export function clearLines()");
let exported_scope_a = format!("{exported_scope_a}\nexport {{ rotatePiece as spin }};");
fs::write(root.join("game/scope-a.mjs"), exported_scope_a)
.expect("write exported gameplay bindings");
fs::write(
root.join("game/scope-b.mjs"),
format!(
"import {{ board, current, rotatePiece, clearLines as clearRows }}\nfrom './scope-a.mjs'\nrotatePiece();\n{}",
"import {{ board, current, spin as rotatePiece, clearLines as clearRows }}\nfrom './scope-a.mjs'\nrotatePiece();\n{}",
gameplay[split_at..].replace("clearLines();", "clearRows();")
),
)
@@ -2297,6 +2382,234 @@ import './gameplay/tetris.mjs';"#,
None,
"explicitly imported exported gameplay bindings may form one semantic chain",
);
fs::write(
root.join("game/shadowed-import.mjs"),
format!(
"import {{ spin }} from './scope-a.mjs'; function decoy(spin){{spin();}} decoy(()=>{{}}); {}",
&gameplay[split_at..]
),
)
.expect("write shadowed import consumer");
let shadowed_import_html = external_html.replace(
"<script type=\"module\" src=\"./main.js\"></script>",
"<script type=\"module\" src=\"./shadowed-import.mjs\"></script>",
);
let shadowed_import = read_external_gameplay_javascript_at(root, &shadowed_import_html)
.expect("read a module whose import is only shadowed by a parameter");
assert!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
shadowed_import_html.as_bytes(),
&shadowed_import,
)
.is_some(),
"a shadowed parameter reference must not project an otherwise unused import",
);
let default_scope_a = gameplay[..split_at]
.replace("let board=", "export let board=")
.replace("let current=", "export let current=")
.replace(
"function rotatePiece()",
"export default function rotatePiece()",
)
.replace("function clearLines()", "export function clearLines()");
fs::write(root.join("game/scope-a.mjs"), default_scope_a)
.expect("write default-exported gameplay bindings");
fs::write(
root.join("game/scope-b.mjs"),
format!(
"import rotatePiece, {{ board, current, clearLines as clearRows }} from './scope-a.mjs';\nrotatePiece();\n{}",
gameplay[split_at..].replace("clearLines();", "clearRows();")
),
)
.expect("write default-import gameplay consumer");
let default_modules = read_external_gameplay_javascript_at(root, &bound_module_html)
.expect("read modules joined by a default import");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
bound_module_html.as_bytes(),
&default_modules,
),
None,
"a named default export must retain its declaration through import projection",
);
let anonymous_default_scope_a = gameplay[..split_at]
.replace("let board=", "export let board=")
.replace("let current=", "export let current=")
.replace("function rotatePiece()", "export default function()")
.replace("function clearLines()", "export function clearLines()");
fs::write(root.join("game/scope-a.mjs"), anonymous_default_scope_a)
.expect("write anonymous default-exported gameplay bindings");
let anonymous_default_modules = read_external_gameplay_javascript_at(root, &bound_module_html)
.expect("read modules joined by an anonymous default import");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
bound_module_html.as_bytes(),
&anonymous_default_modules,
),
None,
"an anonymous default function must receive the importer binding during projection",
);
let namespace_scope_a = gameplay[..split_at]
.replace("let board=", "export let board=")
.replace("let current=", "export let current=")
.replace("function rotatePiece()", "export function rotatePiece()")
.replace("function clearLines()", "export function clearLines()");
fs::write(root.join("game/scope-a.mjs"), namespace_scope_a)
.expect("write namespace-exported gameplay bindings");
let namespace_consumer = gameplay[split_at..]
.replace("board", "gameplay.board")
.replace("current", "gameplay.current")
.replace("clearLines", "gameplay.clearLines");
fs::write(
root.join("game/scope-b.mjs"),
format!(
"import * as gameplay from './scope-a.mjs';\ngameplay.rotatePiece();\n{namespace_consumer}"
),
)
.expect("write namespace-import gameplay consumer");
let namespace_modules = read_external_gameplay_javascript_at(root, &bound_module_html)
.expect("read modules joined by a namespace import");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
bound_module_html.as_bytes(),
&namespace_modules,
),
None,
"namespace member references must retain exported gameplay semantics",
);
fs::write(
root.join("game/namespace-bridge.mjs"),
"export * as gameplay from './scope-a.mjs';",
)
.expect("write namespace re-export bridge");
fs::write(
root.join("game/scope-b.mjs"),
format!(
"import {{ gameplay }} from './namespace-bridge.mjs';\ngameplay['rotatePiece']();\n{namespace_consumer}"
),
)
.expect("write namespace re-export consumer");
let namespace_reexport_modules = read_external_gameplay_javascript_at(root, &bound_module_html)
.expect("read modules joined by a namespace re-export");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
bound_module_html.as_bytes(),
&namespace_reexport_modules,
),
None,
"a namespace re-export must preserve statically referenced member bindings",
);
fs::write(
root.join("game/invalid-link.mjs"),
"import { missingGameplay } from './scope-a.mjs'; missingGameplay();",
)
.expect("write invalid module link");
let invalid_link_html = external_html.replace(
"<script type=\"module\" src=\"./main.js\"></script>",
"<script type=\"module\" src=\"./invalid-link.mjs\"></script>",
);
let invalid_link_error = read_external_gameplay_javascript_at(root, &invalid_link_html)
.expect_err("an imported name missing from the dependency must fail module linking");
assert!(
invalid_link_error.contains("missingGameplay")
|| invalid_link_error.contains("missinggameplay"),
"unexpected module-link error: {invalid_link_error}",
);
fs::write(
root.join("game/case-sensitive-export.mjs"),
"export const GameplayState = 1;",
)
.expect("write case-sensitive export");
fs::write(
root.join("game/case-mismatch-link.mjs"),
"import { gameplayState } from './case-sensitive-export.mjs'; gameplayState;",
)
.expect("write case-mismatched import");
let case_mismatch_html = external_html.replace(
"<script type=\"module\" src=\"./main.js\"></script>",
"<script type=\"module\" src=\"./case-mismatch-link.mjs\"></script>",
);
assert!(
read_external_gameplay_javascript_at(root, &case_mismatch_html)
.expect_err("ESM exported names must remain case-sensitive during linking")
.contains("gameplayState"),
);
fs::write(root.join("game/star-a.mjs"), "export const duplicate=1;")
.expect("write first star export");
fs::write(root.join("game/star-b.mjs"), "export const duplicate=2;")
.expect("write second star export");
fs::write(
root.join("game/star-bridge.mjs"),
"export * from './star-a.mjs'; export * from './star-b.mjs';",
)
.expect("write ambiguous star bridge");
fs::write(
root.join("game/ambiguous-link.mjs"),
"import {duplicate} from './star-bridge.mjs'; duplicate;",
)
.expect("write ambiguous import");
let ambiguous_link_html = external_html.replace(
"<script type=\"module\" src=\"./main.js\"></script>",
"<script type=\"module\" src=\"./ambiguous-link.mjs\"></script>",
);
assert!(
read_external_gameplay_javascript_at(root, &ambiguous_link_html)
.expect_err("ambiguous star exports must fail ESM linking")
.contains("duplicate"),
);
fs::write(
root.join("game/default-only.mjs"),
"export default function rotatePiece() {}",
)
.expect("write default-only export");
fs::write(
root.join("game/star-default-bridge.mjs"),
"export * from './default-only.mjs';",
)
.expect("write star default bridge");
fs::write(
root.join("game/missing-default-link.mjs"),
"import rotatePiece from './star-default-bridge.mjs'; rotatePiece();",
)
.expect("write invalid default import through star export");
let missing_default_html = external_html.replace(
"<script type=\"module\" src=\"./main.js\"></script>",
"<script type=\"module\" src=\"./missing-default-link.mjs\"></script>",
);
assert!(
read_external_gameplay_javascript_at(root, &missing_default_html)
.expect_err("export star must not re-export a dependency's default binding")
.contains("default"),
);
fs::write(
root.join("game/invalid-syntax.mjs"),
"export function broken( {",
)
.expect("write invalid JavaScript module");
let invalid_syntax_html = external_html.replace(
"<script type=\"module\" src=\"./main.js\"></script>",
"<script type=\"module\" src=\"./invalid-syntax.mjs\"></script>",
);
assert!(
read_external_gameplay_javascript_at(root, &invalid_syntax_html)
.expect_err("a syntactically invalid module must fail closed")
.contains("JavaScript"),
);
}
#[test]
@@ -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-post-click-dispatch-microtask-sequence-advance\n",
"primary-action=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-host-completed-cdp-click-gesture-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-post-click-dispatch-microtask-sequence-advance"
"restart=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-host-completed-cdp-click-gesture-sequence-advance"
);
#[derive(Clone, Debug, Deserialize)]
@@ -79,7 +79,6 @@ fn generic_action_sequence_probe_script(
let stateObserver = null;
const inputEventTypes = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
let synchronousAfterState = null;
let clickObserved = false;
const cleanup = () => {{
if (timeoutId !== null) clearTimeout(timeoutId);
if (stateObserver !== null) stateObserver.disconnect();
@@ -114,7 +113,6 @@ fn generic_action_sequence_probe_script(
if (!baselineCaptured) {{
baselineCaptured = true;
}}
if (event.type === 'click') clickObserved = true;
}}
stateObserver = new MutationObserver(() => {{
const activeEvent = globalThis.event;
@@ -134,9 +132,9 @@ fn generic_action_sequence_probe_script(
window.addEventListener(type, observeInput, {{ capture: true }});
}}
globalThis[finishKey] = () => finish({{
status: baselineCaptured && inputEventObserved && clickObserved ? 'completed' : 'missing-input-baseline',
status: baselineCaptured && inputEventObserved ? 'completed' : 'missing-input-baseline',
beforeState,
afterState: baselineCaptured && inputEventObserved && clickObserved
afterState: baselineCaptured && inputEventObserved
? (synchronousAfterState === null ? beforeState : synchronousAfterState)
: null,
}});
@@ -1184,16 +1182,14 @@ 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("synchronousAfterState === null ? beforeState : synchronousAfterState")
);
assert!(script.contains("content.length <= 131072"));
assert!(script.contains(
"status: baselineCaptured && inputEventObserved && clickObserved ? 'completed'"
));
assert!(script.contains("status: baselineCaptured && inputEventObserved ? 'completed'"));
assert!(!script.contains("clickObserved"));
assert!(script.contains("finishKey"));
assert!(script.contains("resolve(value)"));
assert!(!script.contains("__genarrativeGenericActionSequenceProbe"));
@@ -260,11 +260,11 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
generic,
"fc145c8d8af2801e29114c2ce6e4a3a5a6940617c53fd79803103b539b5ca48b"
"e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d"
);
assert_eq!(
tetris,
"ea42a14d583426da748bd1aa6ed1d2c2427d20451161fbe5ec32e97bc0eed214"
"b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c"
);
assert_eq!(
lane,
@@ -1265,7 +1265,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_early_mouse_and_late_click_action_flow() {
async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
@@ -1309,29 +1309,25 @@ async fn real_chrome_generic_playtest_accepts_early_mouse_and_late_click_action_
});
const primary = document.querySelector('[data-playtest-id="primary-action"]');
const restart = document.querySelector('[data-playtest-id="restart"]');
let restartReachedDocumentBubble = false;
window.addEventListener('pointerdown', (event) => {
if (event.isTrusted && event.target === primary && state.phase === 'playing') {
advance(() => { state.score += 1; });
}
}, true);
primary.addEventListener('click', (event) => {
if (event.isTrusted) event.stopPropagation();
});
document.addEventListener('click', (event) => {
if (event.isTrusted && event.target === restart) {
restartReachedDocumentBubble = true;
}
});
window.addEventListener('click', (event) => {
if (event.isTrusted && event.target === restart && restartReachedDocumentBubble) {
if (!event.isTrusted) return;
if (event.target === primary) {
event.stopImmediatePropagation();
return;
}
if (event.target === restart) {
advance(() => {
state.phase = 'ready';
state.score = 0;
});
restartReachedDocumentBubble = false;
event.stopImmediatePropagation();
}
});
}, true);
})();
</script>
</body>
@@ -5973,3 +5973,10 @@
- MCP 边界:动态 MCP 函数的 `arguments.input` 必须在创建 durable pending 前按当前 catalog 的原始 `inputSchema` 本地校验;native parser 负责把错误归类为可修复的 arguments-schema,统一 enrichment 覆盖 legacy 兼容解析并把错误接回同一 repair 链。实际 MCP 调用前还必须按当前 catalog schema 重验一次,阻断升级前遗留的 schema 外 durable pending。校验器关闭 HTTP 与文件解析能力,外部 `$ref`、无效 schema、required/type/enum/additionalProperties 不匹配全部失败关闭,错误不得回显参数或 schema 私密值。
- Native PromptProvider 请求只描述实际广告的 `update_agent_plan`、动作函数、`respond_to_user` 和动态 MCP 函数;内部 `mcp.call` wrapper、`thinkingSummary/planUpdate` envelope、空 actions 以及无 function-tools 文本回退不再进入实时 Prompt。required-nullable 字段未使用时显式传 JSON `null`,空对象 input 只允许权威空 schema 工具。
- Supervisor 合同:配置 External Editor API Key 时,`art-asset-plan` 的 owner 产物统一为 `assets/manifest.art.json``assets/art-spritesheet.png`;未配置 Key 时只要求 `assets/manifest.art.json`,不得伪造或要求三个 PNG。版本化 Bundle 的视觉合同和 playbook 不得给出互斥 expectedArtifacts。
## 2026-08-04 图集事务与 Tetris 完成门使用句柄和 AST 收口
- 图集事务:九文件旧合同在写 `prepared` 前必须全部持有可信源句柄并整体复读;Unix 事务控制文件统一通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW` 操作,Windows 目录句柄拒绝 delete sharing 并用文件身份复核。恢复目标从 canonical 项目根逐组件拒绝 symlink / reparse pointCAS 安装后的任何清理错误都按实际 canonical 状态把当前项纳入逆序回滚,不能留下新旧混合合同。
- JavaScript / ESMTetris 静态连续性检查以 Oxc parser、semantic 与 AST visitor 为权威。无效语法、ASI、template interpolation、正则 / 注释、表达式体箭头、参数和词法遮蔽、export alias、re-export 与缺失导出链接不再由字符串扫描猜测;HTML `type` 存在时优先于 legacy `language`。源码投影仍只是静态语义门,最终完成继续要求绑定当前 revision 的真实 Chromium 固定试玩回执。
- 浏览器因果:状态证据仍只冻结 trusted input listener 及其点击派生微任务内的变化;完整手势身份改由宿主在成功完成 Chromium 元素鼠标输入后调用隔离世界 finish。更早注册的 `window` capture listener 即使调用 `stopImmediatePropagation()` 也不能阻断探针自身的完成身份,页面脚本不能伪造 host finishRAF / timer 继续不计入动作结果。
- 验证边界:Linux 定向回归覆盖目录相对读写与清理、祖先 symlink、CAS 安装后错误、九文件混合快照、Tetris AST 反例和七项真实 Chrome generic 试玩。Windows cfg 代码必须继续在真实 Windows CI / 发布构建验证;本地缺少 MinGW C compiler 时,安装了 Rust target 也不能把交叉 `cargo check` 失败误报为源码失败。
@@ -827,9 +827,9 @@ game-project/
- 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。
- game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。
- `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 文件。
- 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 改动前必须先全部打开可信源句柄,完成有界双读,并在 journal 持久化后、发布 `prepared` marker 前再次整体复读与身份校验;任一文件在九文件捕获窗口变化都失败关闭,不能形成跨版本混合快照。事务控制文件在 Unix 通过锚定目录句柄的 `openat / unlinkat + O_NOFOLLOW` 读取和清理;Windows 以拒绝 delete sharing 的目录句柄阻止 rename / replacement,并以句柄文件身份复核子文件,不能退回 pathname 前后检查。恢复目标必须从 canonical 项目根开始逐组件检查全部祖先,任何中间 symlink / reparse point 都拒绝。CAS 安装先把目标字节持久化到同目录私有 staging,再以不覆盖的原子 hard-link / rename 切换;即使 canonical 已安装后清理 backup/staging 才报错,也必须把当前项纳入同轮逆序回滚。Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复继续在同一项目写锁内完整验证全部 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 隔离执行上下文,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,不能交付完成。
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1``playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris``activePieceId``rotation``row``lockedPieces``lineClearChecks``clearedLines``occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,并 `type` 存在时忽略 legacy `language`,再结合 `nomodule` 判断可执行脚本。JavaScript / ESM 必须先通过 Oxc parser 与 semantic;无效语法失败关闭,import/export production、ASI、default / namespace / alias / re-export 链接、template `${...}` 内表达式、注释与正则边界都以 AST 为权威,不得跨换行猜测 `from` 或把未链接模块当完成证据。函数定义、表达式体箭头、调用可达性和参数 / 局部遮蔽按 semantic symbol identity 判断;guard return / throw 只终止其真实控制流分支,不能截断后续可达玩法。字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、短路动态 import、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵继续不构成证据filter / splice 消行必须由满行判断真实控制,并作用于正式棋盘。本地 `.js / .mjs`、inline module 传递依赖统一限制在 `game/`,文件按去重数量并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,Promise 闭包保存点击前基线,MutationObserver 只冻结 trusted 输入 listener 及其点击派生微任务产生的最后状态;宿主只有在 Chromium 元素鼠标输入成功完成后才调用隔离世界 finish,把该 CDP 结果作为完整手势证据,页面无法伪造。这样更早注册的 `window` capture listener 即使 `stopImmediatePropagation()`,以及后注册的同步 click listener,都不会造成假阴性;RAF / timer 仍不会污染证据。探针 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 重放,禁止分别执行原响应和持久响应。