games-development-ai/docs/architecture/03-产物执行沙箱图说.md
lili 2ce0ed3a9d feat(save-cap): W-SAVE-CAP 存档面定容+schemaVersion——单槽版本信封,北极星三款共享基建
契约先行(sdk-interface.d.ts+镜像 additive):白名单新增恰等键族 save:{gameId}:{versionId}(≤65536 码元,
写入必须 {schemaVersion≥1, data} 信封,宿主闸 fail-closed);idle: 族 4096 逐字不动;弃多 key 分片。
宿主闸抽纯函数模块 storageGate.ts(四闸合一可 node 直测);save-progress 插件 v1.0.0→v1.1.0 additive:
setVersioned/getVersioned 逐级迁移/缺级兜底/降档读新档不毁档/无版本旧档按 0 版进链+探针两计数。
证据:插件单测 18/18、game-runtime 31/31、宿主闸负向演示脚本入仓真跑全断言过、vue-tsc+vite build 绿。
三书 M 门影子存档降级口径解除条件就位(卡牌 M2/割草 M4/夜市 M5 切换)。设计页 docs/plans/2026-07-06-002。
2026-07-06 09:24:44 -07:00

238 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
date: 2026-06-22
topic: 产物执行沙箱图说——绘境AI 架构图集按领域拆分·03
status: 现行为主 · 每图具体映射源档见该图脚注 / 内联小注 · 通用图例见 00-系统总览图说
---
# 03 · 产物执行沙箱图说
> 本篇是绘境AI **架构图集**的一部分(全 8 篇:[00 系统总览](00-系统总览图说.md) + 0107 各域)。**通用图例、状态码、按角色读路径见 [00 · 系统总览图说](00-系统总览图说.md)**。
> **本域命门(最该据图 review 的设计缝)**:别把机制已建当已安全;两条正交边界别混。
---
> **本域讲什么故事**:沙箱回答一个被独立成档的硬问题——**一款 AI 生成的不可信游戏代码,怎么在玩家浏览器里被隔离着安全跑起来**。生成主线已从「禁代码、填参数」迁到「让 agent 写码」,注入面就是模型现写的 JavaScript 本体,平台必须把每一款生成游戏当成有恶意、有 bug 的不可信输入。它的七张图讲清三层封堵(build 段静态扫描 / iframe + CSP / sandbox 属性)、取包 sha256 校验注入时序、postMessage 双向桥双校验、SDK 受控能力面、两条正交边界、CSP / 同源红线 / 产物消毒、三容器加载 / 失败 / 销毁态机。读完这个域,你知道「这套隔离方案是不是想要的那个」。
>
> **最该据图 review 的命门缝**:沙箱域评审最该守的纪律点是「**别把机制已建当成已经安全**」——iframe、桥、双校验、sha256 这套都真跑通过了,但它现在跑在同源过渡态,HJ-AUDIT-001 那条「独立源就绪前禁用 allow-same-origin」的红线在代码里并未强制,origin 白名单还含字符串 'null'。还有两条最该看清的概念边界:**两条信任边界正交、别混**(沙箱边界 = iframe 壳 vs 宿主、受控面 = 游戏代码 vs 引擎,受控面在沙箱里面、沙箱在受控面外面,破一条另一条仍兜);以及 **'unsafe-eval' 看着危险但风险被纵深防御承接住了、不是漏洞**(逻辑串先经 build 段静态扫描、再加 connect-src 'none' 无出网、再加 iframe sandbox 隔离)。
> **本域阅读约定与同步纪律**:本图说不另立设计,只把架构域里 `产物执行沙箱.md` 这一份 canonical 设计档画出来。frontmatter 记了它的当前 commit hash 作为防漂移门。设计一变动,本图说与对应 SVG 必须同步更新。**本域状态分布**:这套机制(iframe + 桥 + 双校验 + sha256)**已全部建成、真跑通过**,所以七张图的整图状态都是**现**。但「机制建成不等于合规收口」——源档 §8 逐条带 file:line 记了四处真实债(CSP 文档口径与代码对不上、sandbox 属性三处不一致、后端下发的 sandboxAttr 前端没接线、HJ-AUDIT-001 同源红线当前是过渡态)。这些**现状债**不是「待建」,而是「现行机制里的已知缺口」,图上一律用**橙色虚线框 + 文字小标**与「现行已建的实心块」一眼区分开。
> **本域徽章**(主要关心其中两个):**安全**(`#dc2626`):这是本域的主轴。三层封堵(build 段静态门 / iframe CSP / sandbox 属性)、桥的 origin + schema 双校验、storage 四道闸、sha256 取包完整性、bundle 内联前的 `</script>` 消毒——全是为了把不可信代码关在笼子里。**质量**(`#15803d`):受控面那条「异常绝不向游戏抛、超时就降级、写类 fire-and-forget」的降级铁律,根源是玩家验收门五条 AND 里的「无错」——游戏运行期一旦抛未捕获错误就判不过,所以受控面任何意外都得自己吞掉。本域图说最该守的纪律点是:**别把「机制已建」当成「已经安全」**。读图时凡见橙色虚线框,就是「这里还有债、别当已合规」。
### 沙箱图 1 · 不可信代码隔离全景SVG新·架·现
![沙箱图 1 不可信代码隔离全景](架构/assets/sbx-01-不可信代码隔离全景.svg)
这张全景图回答整份文档的总问题:**一段模型现写的不可信 JavaScript,凭什么敢放进玩家的浏览器里跑**。答案是三层封堵,它们在不同时机、不同位置生效,合起来才是完整的封锁,单看任何一层都不够。图从左到右按「出厂前 → iframe 这层壳 → iframe 外的宿主平台」铺开,用绿色标出唯一被放行的通道、用红色虚线标出每一个被封死的逃逸面。
最左边一层最容易被忽略,因为它**不在 runtime、而在 build 段**;同时要点明它属于**已废 gamedef 路的 build 段机制**。`build-from-source.mjs``scanLogic` 在把 gameDefinition 装配成可玩工厂、交给 esbuild 打包**之前**,对模型产出的 `behavior.code``rule.condition` 做静态扫描,命中危险模式就转成 `validationError` 回灌给 repair,而不是指望 prompt 自觉。这套静态扫描是 gamedef 专属的——它扫的 `behavior.code` / `rule.condition` 串字段只存在于 gameDefinition;A-model 写真 src/ 多文件、没有这类逻辑串字段,这一层随 gamedef 废弃而不再是现行路径。为什么这一层必须放在 build 边界做?因为同步 JS 在进程内没法被硬中断——一个 `while(true)` 死循环或一次沙箱逃逸只要执行了就晚了,所以唯一的机会是在执行前的校验边界把危险模式拦掉。图上把 `LOGIC_BANS`(逃逸 / DOM 网络 / 死循环 / 确定性四类)和 `CONDITION_BANS`(条件必须是无副作用的纯布尔表达式)的实际拦截清单都列了出来,让人看清这层拦的不是抽象的「危险代码」,而是一份具体的黑名单。
中间是 iframe 这层壳,它叠了两道:**CSP** 硬编码进 srcdoc 的 `<meta>`,其中 `connect-src 'none'` 是最关键的一条——它禁掉一切网络出站,所以即便游戏代码想把玩家数据 `fetch` 出去也发不出去;引擎 bundle 不在 iframe 内 fetch,而是由宿主层 fetch 后内联进来,所以这条不会卡死引擎装载。**sandbox 属性**则封死宿主 DOM 逃逸、顶层导航与弹窗,并把游戏与宿主之间的通道收窄到只剩 postMessage 一条。这条收窄直接决定了产品形态:广告、支付、存储这些要碰平台资源的事,游戏在 iframe 内根本办不了(没网络、碰不到宿主 DOM),只能经 postMessage 抛给宿主侧去办、办完再回包——这就是为什么 SDK 的 ad/pay/storage 都是「游戏发请求 → 宿主执行 → 回包」的形状,而不是游戏直接调。
这张图必须让人看出的「设计缝」画在右下角那个橙色虚线框里:**机制建成 ≠ 合规收口**。当前整套跑在同源过渡态(同源 srcdoc 配 `allow-same-origin`),HJ-AUDIT-001 的同源红线在代码里没强制;CSP 的文档口径写的是 `script-src 'self'`、代码实为 `'unsafe-inline' 'unsafe-eval'`;sandbox 属性在 DB 默认值、前端硬编码、文档示例三处不一致;后端下发的 `sandboxAttr` 前端压根没消费。这四条债的收敛要一起做(迁独立 usercontent 子域、CSP 改 HTTP 响应头下发、sandboxAttr 真接线)才闭合,详见图 2 注记与源档 §8。把这块诚实画出来,正是为了不让人看到「三层都建了」就以为已经安全了。
### 沙箱图 2 · 取包 → sha256 校验 → iframe + CSP → 注入 时序SVG新·时·现
![沙箱图 2 取包校验注入时序](架构/assets/sbx-02-取包校验注入时序.svg)
这张时序图把一款不可信游戏「从后端取包到在玩家面前可玩」的全过程展开成六条泳道之间的一串动作。它的叙事主线是:**每一环都假设上一环的产物可能有问题**,所以取包之后必有完整性校验、注入之时必带 CSP、建桥之后所有消息必过双校验。
取包这一步并不是一条直线,而是 `resolvePackage` 分四态走,图上方用橙色虚线框列了出来:创作者预览时 `props.manifest` 直接传一个内存里的包进来、不走网络;玩家试玩时按 `versionId` 取运行包清单、清单里带 `manifestUrl` 就去拉真包做 sha256 校验;清单没有 `manifestUrl`(mock 阶段后端还没回填)就退 demo 兜底;取包或校验中途出任何错,也退 demo 兜底。这里有一个关键且有意的设计:可恢复的失败**只 `console.warn`、不向上 emit error**。原因是父级 `Play` 把 error 当致命态处理、会用 `v-else-if` 把宿主整个隐藏掉显示「加载失败」,而 demo 兜底本可以正常试玩,两者冲突,所以宁可降级也不上抛。
中段是这张图的安全支点——**sha256 双通道校验**(图中绿色块)。manifest 从后端拉下来后要确认传输途中没被篡改、和后端写包时算的 checksum 一致。校验走双通道:安全上下文(https / localhost)优先用原生 `SubtleCrypto`,性能最好;非安全上下文回退一份纯 JS 的 SHA-256 完整实现。为什么需要这份纯 JS 回退?因为创始人用局域网 IP 明文 http(例如 `http://100.64.0.7:4173`)跨内网机访问时,浏览器判定为「非安全上下文」、`crypto.subtle` 直接是 `undefined`,旧实现这时会抛错、导致校验失败退 demo 兜底,最终页面误报「游戏加载失败 / 网络波动」——真因是上下文不安全,根本不是网络。回退纯 JS 后,完整性校验在明文 IP http 和安全上下文下行为一致。后端这一端也对齐了字节一致性:取 manifest 的端点返回的是原始 JSON 文本(返回类型 `String`,`GlobalResponseBodyHandler` 只拦 `CommonResult` 不拦它),保证前后端对同一份字节算摘要。
后段是注入与回流。`buildIframeSrcdoc` 用一个不常见的手法叫「函数序列化注入」:`createWanxiangSDK``startRuntime` 都是不依赖模块外部符号的纯工厂函数,用 `Function.prototype.toString()` 取源码文本拼进 iframe 内联 `<script>` 重新求值——这样同一份 TS 源既被宿主工程类型检查、又在 iframe 内真跑,不用为它们单独配打包产物。引擎 bundle 则作一个独立内联 `<script>` 注入、暴露契约冻结的全局名 `window.__GameBundle`,并经 `escapeBundleForInlineScript` 断掉 `</script>` 变体防止它越出脚本块。最后一条回流值得记住:**游戏没有自己的 emit 通道发 game_end**,所以宿主 `watchGameEnd` 每 500ms 轮询 `host.state().phase`、到 `gameover` 就 latch 一个 `game_end` 代发——这是过渡口径,ref 游戏是纯引擎工厂、内部没 SDK,自发不出终态,等 W-G1 生成主线落地后改由各品类标准信号产出、这个轮询桩可下线。图右侧旁注还点出加载超时按装载路分流(引擎包 12s、旧 demo 路 5s),因为引擎冷启动是旧路的 4.8~5.3 倍、内联 bundle 解析会逼近甚至超过 5s,放宽到 12s 是为了防误杀,而首屏 P75<3s `perf_first_screen` 单独监测与这个防挂死上界正交
### 沙箱图 3 · postMessage 双向桥与双校验Mer新·时·现
下面这张流程图把桥(`bridge.ts` `HostBridge` )处理一条 iframe 来消息的判定过程画清楚桥的设计立场是:** iframe 来的每一条消息都当不可信边界处理**——先过 origin 再过 schema ,任一不过就丢弃绝不分发;过了两道闸还要分辨它是不是宿主先前请求的回包,是回包就消费掉不是才往上分发
```mermaid
flowchart LR
M["iframe postMessage 来一条消息"] --> O{"① origin 在白名单?<br/>isOriginAllowed"}
O -->|"否(空白名单默认拒绝)"| R1["onReject('origin_not_allowed')<br/>丢弃"]:::deny
O -->|是| S{"② schema 逐字段校验?<br/>validateEnvelope"}
S -->|否| R2["onReject(reason)<br/>丢弃"]:::deny
S -->|是| P{"是宿主请求的回包?<br/>requestId 配对命中 pending"}
P -->|是| RP["消费 pending 回调<br/>clearTimeout + resolve"]:::ok
P -->|否| D["onMessage 分发给上层 GamePlayer"]:::ok
classDef deny fill:#fee2e2,stroke:#dc2626,stroke-width:2px;
classDef ok fill:#dcfce7,stroke:#16a34a,stroke-width:1.5px;
```
第一道是 **origin 白名单**:`isOriginAllowed` 对空白名单**默认拒绝**——白名单为空视为不允许任何来源,这是安全默认而非疏忽因为 srcdoc 文档的 origin `null`,建桥时 `buildOriginAllowlist` `includeNull` 时会把字符串 `'null'` 连同宿主自身 origin 一起加进白名单(这也正是 §8 同源债的一个落点:过渡态下白名单含 `'null'`)。第二道是 **schema 逐字段校验** `validateEnvelope`:`channel` 必须严格等于固定标识 `wanxiang-game-sdk`(否则是噪声或伪造),`type` 必须在 8 个枚举的 `VALID_TYPES` (即图 4 那张 type 表的全集),`direction` 必须合法,`traceId` 必须是字符串(贯穿链路必备),`payload` 必须存在,`requestId` 若带则必须是字符串这里有个有意的细节:`validateEnvelope` **不抛异常、只返回判定结果**——源码注释写得明白,「异常本身也是一种攻击面」,所以校验器自己绝不制造可抛路径
过了两道闸之后的回包识别,是为了支撑请求-响应语义:宿主往游戏发消息用 `post`(`targetOrigin` `'*'`,因为 srcdoc origin `null` 没法精确指定), `request` Promise 化的请求-响应 5s 超时(`REQUEST_TIMEOUT_MS`),**超时就以 `undefined` 兜底 resolve绝不 reject**——向上抛 reject 会打断游戏,所以宁可降级卸载时 `dispose` 移监听清所有挂起请求的定时器防泄漏这张图没有现行/远期边界,它就是现行桥的真实判定流;要注意的设计缝只有一处,且不在桥本身而在它的输入侧——白名单含 `'null'` 是同源过渡态的产物,独立源落地后这一项会随 §8 收敛一起收紧
### 沙箱图 4 · 受控面(SDK bridge)能力面SVG新·类·现
![沙箱图 4 受控面能力面](架构/assets/sbx-04-受控面能力面.svg)
这张类图回答平台到底向不可信游戏注入了什么能力又是怎么注入的」。结论是:iframe 内的游戏只能经一个全局对象 `window.WanxiangGameSDK`(加上引擎装载契约)与外界打交道,受控面之外一律被 CSP sandbox 封死——没有这个 SDK,平台就只是个静态文件托管图把受控面拆成 Core 层和 Plugin ,并把另一条并列的入口(引擎装载契约)单列在右栏
**Core 层内联进游戏入口、压缩后小于 8KB、首屏即在**,暴露 `init` / `on` / `track` / `reportError` / `ad` / `pay``init` 注入 `traceId` 贯穿生成 运行 上报全链路;`track` 10 / 5s 的批量缓冲写类 fire-and-forget;`ad` / `pay` 经桥的 `request` 5s 超时降级。**Plugin 层按需懒加载首屏不付代价**,其中 storage 这个面尤其要小心,因为它直接写宿主的真 localStorage——图上把它的四道闸列全了:闸只放行恰好等于 `idle:gameId:versionId` key(tycoon 这类无离线态的模板根本不发 storage白名单也不含它的前缀), 4KB 以上的 value(idle 存档实际不到 200B),在写入前用 `JSON.parse` 校验回读时只接受对象形态(防有人手工篡改 localStorage 注入脏数据),落盘时加 `wxgame:` 前缀做命名空间隔离这里有个设计点值得点出:回包给游戏的是宿主侧**已经解析好的对象**、不是原始字符串,解析在宿主这道受信边界做runtime 侧零 `JSON.parse`,守住游戏代码不引可抛路径的红线。〔W-SAVE-CAP 增补 2026-07-06:白名单增第二个恰等键族 `save:gameId:versionId`(复杂游戏主存档北极星三款起用)——单槽 65536 码元写入必须是 `{schemaVersion≥1, data:纯对象}` 版本信封 fail-closed;`idle:` 族逐字不变;闸逻辑抽纯函数 `game-studio/src/host/storageGate.ts`,细则见产物执行沙箱同日增补段与 W-SAVE-CAP plan。〕
整张图最该让人记住的是那条贯穿所有受控面的**降级铁律**:异常绝不向游戏抛超时就降级写类操作 fire-and-forget它的根源不是工程洁癖,而是玩家验收门那五条 AND 里的无错」——游戏运行期一旦抛出未捕获错误就判不过,所以受控面任何意外都得自己吞掉右栏的引擎装载契约(`window.__GameBundle.bootGameHost` / `render(mainContext)` / `ctx.getEngine()`)** SDK 并列的第二条受控入口**,但它属于另一条正交边界(详见图 5),图上特意提醒:软著 `ruanzhu-5` 和专利 `01-受控插件引擎` 写的是这条受控插件面不是 iframe 产物沙箱,引用时别张冠李戴图下方的 8 postMessage type 表是桥 schema 闸的枚举全集,把每个 type 的方向和用途列清,让人对照图 3 看清双校验放行的到底是哪 8 类消息」。
### 沙箱图 5 · 两条正交边界(别混)SVG新·架·现
![沙箱图 5 两条正交边界](架构/assets/sbx-05-两条正交边界.svg)
这张图专治一个最容易混的概念:系统里有**两条信任边界**,它们方向正交位置嵌套,必须分清边界 **沙箱边界**——「整个 iframe 这层壳外面的宿主平台之间的隔离,它活在 iframe 壳本身,是本档(产物执行沙箱.md)的主轴;边界 **受控面**——iframe 内部、「游戏代码引擎能力之间的约束(`api.d.ts` `PluginContext`),它管游戏代码调引擎时只能走公开 API」,是生成运行时 SoT `架构/生成引擎/agentic运行时架构图说.md`(引擎与运行时已并入)的主轴一句话记牢:**受控面在沙箱里面,沙箱在受控面外面。**
图用嵌套的方框把这个关系画成可视:红色虚线大框是沙箱壳紫色虚线框嵌在里面是受控面最内层才是不可信游戏代码——它被两层边界一起关着右上角的同心嵌套示意是这张关系的速记版之所以强调它们正交」,是因为两条边界各管一段不互为替代缺一不可,而且任何一条被破时另一条仍在兜:如果沙箱边界破了(比如同源过渡态 `allow-same-origin` 下游戏触到了宿主 DOM),受控面拦不住它——这正是 §8 同源债的要害;反过来如果受控面破了(游戏绕开公开 API 去抓引擎内部),外层沙箱壳仍兜得住,不致让数据外泄或出网把这层破了一条还有另一条的纵深关系画出来,就能解释为什么两条边界都不能省
这张图右下角把最容易混的三处钉死,都是实际踩过或容易踩的坑:一是引用知识产权时别张冠李戴(软著/专利写的是受控插件面=边界②,不是 iframe 产物沙箱=边界①);二是关联档分工别串(生成运行时 SoT `架构/生成引擎/agentic运行时架构图说.md` 讲装载 + 引擎能力=边界②,本档讲执行隔离 + 安全边界=边界①,两者正交不重叠);三是收敛各管各的(沙箱债 §8 的同源/CSP/sandboxAttr 属边界①,受控面的血统债 random/time/input 属边界②,别合并谈)。这张图没有现行/远期边界,它讲的是一个**当前就成立的概念结构**;它和图 1 的关系是图 1 三层封堵的全景」、 5 两条边界的嵌套关系」,一个讲手段一个讲概念分层
### 沙箱图 6 · CSP / 同源红线 / 产物消毒Mer新·讲·现
下面这张讲解图把沙箱里与外界资源打交道最硬的三条红线集中起来:CSP `connect-src 'none'`HJ-AUDIT-001 `allow-same-origin` 同源红线以及 bundle 内联前的产物消毒它们分别管不准出网」「同源下不准用 same-origin」「注入的脚本不准越界」,是沙箱安全性的三块基石,也是 §8 现状债集中的地方
```mermaid
flowchart TB
subgraph CSP["① CSP(硬编码进 srcdoc &lt;meta&gt;)"]
direction TB
C1["default-src 'none' —— 兜底封掉一切未显式放开的资源类型"]
C2["connect-src 'none' —— 禁一切网络出站(最关键)<br/>引擎 bundle 由宿主层 fetch 后内联,不在 iframe 内 fetch,故不被卡"]
C3["script-src 'unsafe-inline' 'unsafe-eval'<br/>'unsafe-eval' 是已废 gamedef 路的 CSP 债(gamedef 用 new Function 编译逻辑串)<br/>A-model 真 src/ 经 esbuild 无 new Function,该债随 gamedef 废而可收紧"]:::debt
end
subgraph SO["② 同源红线 HJ-AUDIT-001(当前=过渡态)"]
direction TB
O1["红线:allow-same-origin 只有当游戏包托管在独立源<br/>(usercontent 子域·与宿主不同源)时才可用"]
O2["现状债:现行同源 srcdoc + allow-same-origin,红线代码里未强制<br/>origin 白名单还含字符串 'null'"]:::debt
end
subgraph SAN["③ 产物消毒"]
direction TB
N1["escapeBundleForInlineScript:断 &lt;/script&gt;(含大小写/空白变体)和 &lt;!--<br/>让 bundle 文本无法越出 &lt;script&gt; 边界"]
N2["纵深防御:bundle 来自后端 engineBundle、整包 sha256 已覆盖完整性<br/>消毒是额外一层、不是唯一依赖"]
end
DEF["纵深承接:'unsafe-eval' 的风险由三重边界一起兜——<br/>逻辑串先经 build 段 scanLogic 静态拦危险面 + connect-src 'none' 无出网 + iframe sandbox 隔离"]:::ok
C3 --> DEF
O2 --> FIX["收敛方向(三件一起做才闭合):<br/>① 迁独立 usercontent 子域(让 allow-same-origin 真安全)<br/>② CSP 从 srcdoc meta 改 HTTP 响应头下发<br/>③ sandboxAttr 这条 DB→VO→前端链真接线"]:::fix
classDef debt fill:#fff7ed,stroke:#d97706,stroke-width:1.5px,stroke-dasharray:5 4;
classDef ok fill:#dcfce7,stroke:#16a34a,stroke-width:1.5px;
classDef fix fill:#eff6ff,stroke:#2563eb,stroke-width:1.5px;
```
读这张图要抓住一个反复出现的张力:**`'unsafe-eval'` 看着危险,但它是 gamedef 运行时必需的且风险被纵深防御承接住了**。声明式 gameDefinition behaviors / rules 各带逻辑 JS ,`gd-runtime.js` `new Function` 编译执行, `'unsafe-eval'` 会抛 `EvalError` 被静默吞进 errors[]、tickBehaviors 空转游戏只渲首帧静态世界永不演进(这是 2026-06-21 实证过的 bug:加上之后 errCnt 7 降到 0score 0 开始演进)。它的风险承接是一条纵深链:逻辑串先在 build 边界经 `scanLogic` 静态拦掉危险面,再加 `connect-src 'none'` 断网络出站,再加 iframe sandbox 隔离——三重边界使它不扩大真实攻击面所以图上把 `'unsafe-eval'` 标成债色但用一条绿线指向纵深承接」,表达它是有意的不是漏洞」。这里也要诚实记一笔文档漂移:`.agents/rules/security-and-reliability.md` §1.1skills 手册契约注释都写 `script-src 'self'`,但代码实为 `'unsafe-inline' 'unsafe-eval'`且硬编码在 srcdoc meta ,**以代码为准文档口径已过期**。
第二块同源红线是本域最该警惕的过渡态HJ-AUDIT-001 `allow-same-origin` 只有当游戏包托管在独立源时才能用,因为同源下 iframe 能触宿主 DOM / 存储甚至自己移除 sandbox,沙箱铁律就失效了;但代码现状是同源 srcdoc `allow-same-origin`origin 白名单还含 `'null'`,红线在代码里没强制这是已知的独立源落地前的过渡态,不是已合规图把它和收敛方向连在一起,强调三件事(迁独立 usercontent 子域CSP HTTP 响应头下发sandboxAttr 真接线)必须一起做才闭合——只做一件不解决问题第三块产物消毒相对干净:`escapeBundleForInlineScript` `</script>` `<!--` bundle 越出脚本块,而且 bundle 来自后端整包 sha256 已覆盖完整性,消毒只是纵深里额外的一层不是唯一依赖
### 沙箱图 7 · 三容器加载 / 失败 / 销毁态机Mer新·状·现
下面这张状态图回答 feed 上下滑动切游戏,沙箱容器的生命周期怎么管绘境AI feed 是短视频式的游戏流,玩家上下滑会连续切换游戏,所以沙箱容器不是一个而是逻辑上的三个角色在轮转:**当前正在播放的前一个正在销毁的后一个正在预加载的**。这张图把这三态的流转以及加载失败怎么降级画清楚
```mermaid
stateDiagram-v2
direction LR
[*] --> Preload : 进入可视区前预取
Preload : 后一个 · 预加载
Preload : 取包 + sha256 校验 + buildIframeSrcdoc<br/>iframe 挂载但未掌帧
Current : 当前 · 播放中
Current : boot 掌帧 · game_loaded→phase=ready<br/>game_start · watchGameEnd 轮询终态
Disposing : 前一个 · 销毁中
Disposing : dispose 移监听 + 清挂起请求定时器<br/>iframe 卸载 · 防泄漏
Preload --> Current : 滑到它(成为当前)
Current --> Disposing : 滑走(被下一个取代)
Disposing --> [*] : 释放完成
Current --> Fallback : 加载失败 / sha256 不过 / 超时
Fallback : 降级 demo 兜底<br/>只 console.warn · 不向上 emit error
Fallback --> Disposing : 滑走时照常销毁
note right of Current
加载超时按装载路分流:
引擎包 12s · 旧 demo 路 5s
(引擎冷启动 4.8~5.3×)
end note
```
读这张图要抓住两个设计点其一,**三容器轮转是为了让即点即玩成立**:如果每次滑到一款游戏才开始取包校验注入冷启动引擎,玩家会看到明显的等待;提前在进入可视区之前就把后一个容器预加载好(取包 + sha256 + 挂载 iframe但还不掌帧),滑到时才 `boot` 掌帧,等待就被藏进了滑动的过程里与此对称,前一个滑走的容器进入销毁态,`dispose` 必须移监听清掉所有挂起请求的定时器——这一步不能省,否则切几十款游戏后会积累一堆未清理的监听和定时器内存泄漏其二,**加载失败是一条降级支路不是终止**:sha256 校验不过取包出错或加载超时,都不让宿主整个隐藏显示加载失败」,而是降级到 demo 兜底 `console.warn`不向上 emit error(原因同图 2——父级 `Play` error 当致命态会隐藏宿主, demo 本可正常试玩)。这条降级支路汇回正常的销毁流:滑走时它照常被 `dispose`
这张图没有现行/远期边界,它是现行 feed 容器生命周期的真实形态;唯一的过渡口径在终态怎么来」——`watchGameEnd` 轮询 `host.state().phase` `gameover` latch 一个 `game_end` 代发,是因为 ref 游戏自己没有 emit 通道, W-G1 落地后改由各品类标准信号产出轮询桩可下线(与图 2 同源)。
### 沙箱图 8 · postMessage 信封数据模型(契约#3 跨端 wire 契约)Mer新·数据模型·现
下面这张类图把跨信任边界的每一条消息长什么样从字段层面定死前面图 2 / 3 讲的是消息**怎么流怎么被校验**,这张图补的是另一个维度:消息**本身的数据结构**。它来自契约 `contracts/sdk-interface.d.ts`( #3 类契约 · SDK宿主 postMessage 协议),是宿主侧和游戏侧都必须逐字遵守的跨端 wire 契约——游戏侧组装消息宿主侧 `validateEnvelope` 逐字段校验,两端对的就是这一份结构把它单独画出来,是为了给安全审计一个合法消息的字段全集清单,也给游戏侧实现一个照着填的模板
```mermaid
classDiagram
direction TB
class PostMessageEnvelope~T~ {
+channel: 'wanxiang-game-sdk' 〔必·定值〕
+type: PostMessageType 必·8枚举内
+direction: MessageDirection 〔必·合法值〕
+traceId: string 〔必·贯穿链路〕
+payload: T 〔必·须存在〕
+requestId?: string 〔选·配对用〕
}
class PostMessageType {
«enum»
init
lifecycle
telemetry
error
ad
pay
social
storage
}
class MessageDirection {
«enum»
game_to_host
host_to_game
}
class StorageSetPayload {
+key string
+value string_JSON
}
class StorageGetPayload {
+key string
}
class StorageResultPayload {
+key string
+value object_or_null
}
class AdPayPayload {
+req slotId_orderId_amount
+resp rewarded_shown_paid
}
PostMessageEnvelope~T~ *-- PostMessageType : type
PostMessageEnvelope~T~ *-- MessageDirection : direction
PostMessageEnvelope~T~ <|.. StorageSetPayload : payload(type=storage·写)
PostMessageEnvelope~T~ <|.. StorageGetPayload : payload(type=storage·读请求·带requestId)
PostMessageEnvelope~T~ <|.. StorageResultPayload : payload(type=storage·读回包)
PostMessageEnvelope~T~ <|.. AdPayPayload : payload(type=ad/pay·带requestId)
note for PostMessageEnvelope~T~ "validateEnvelope 逐字段必过项(bridge.ts):\nchannel 严格等于 'wanxiang-game-sdk' · type 在 VALID_TYPES 内\ndirection 合法 · traceId 是字符串 · payload 存在(挡 undefined)\nrequestId 若带须是字符串。校验器不抛异常、只返判定(异常本身亦攻击面)。"
note for PostMessageType "8 枚举各自方向/用途(契约注释):\ninit=host→game 初始化 · lifecycle=game→host 生命周期 · telemetry=game→host 遥测 · error=game→host 错误\nad/pay/social/storage=双向(广告/支付/分享/键值存储,iframe 外宿主侧办、按 requestId 回包)。\n⚠ 漂移:这是契约 canonical 命名(telemetry/social);运行时 bridge VALID_TYPES 实为 track/ready(见图 4)——\n命名维度契约与代码未对齐、属已知漂移,以两侧各自语义为准,别当已收口。"
```
这张图分三块读。**主类 `PostMessageEnvelope<T>` 是信封本体**,六个字段里五个必填一个可选:`channel` 是定值常量 `wanxiang-game-sdk`,宿主据它一眼滤掉非本协议的噪声或伪造消息;`type`( `PostMessageType` 8 枚举之一)、`direction`( `MessageDirection` 两值之一)、`traceId`(贯穿生成运行上报链路的字符串)、`payload`(泛型载荷必须存在)都是必填;只有 `requestId` 是可选——它专给请求-回包要配对的消息(ad / pay / storage ),写类的 track / lifecycle / error fire-and-forget不带它图上每个字段都标了/和约束,新人对照右侧 `validateEnvelope` 那条注记,就能一眼看出一条消息要全过哪几关才不被丢弃」。
**第二块是挂在信封 `payload` 下的几个 typed payload**,展示泛型 `<T>` 在实际消息里被具化成什么storage 这一族最完整,正好演示请求-回包的三段形状:写存档用 `StorageSetPayload{key,value}`(value JSON 字符串),读请求用 `StorageGetPayload{key}`( requestId),读回包用 `StorageResultPayload{key,value}`——这里有个契约级的关键细节,回包的 `value` **宿主侧已经解析好的对象**(`Record<string,unknown>|null`)、不是原始字符串,且契约语义明令数组按未命中处理(resolve null),这正是runtime 侧零 JSON.parse不引可抛路径红线在数据结构上的落点ad / pay 一族我用一个示意类 `AdPayPayload` 概括其请求字段(slotId / orderId / amount)与回包字段(rewarded / shown / paid),它们都靠 requestId 配对
**第三块是必须诚实标出的一条设计缝**,画在 `PostMessageType` 的注记里:这张图严格按契约 `sdk-interface.d.ts` ,所以 8 个枚举是契约的 canonical 命名( `telemetry` / `social`);但运行时桥 `bridge.ts` `VALID_TYPES` 实际用的是 `track` / `ready` 这套命名(就是图 4 那张 8 type )。也就是说契约声明与代码实现在type 命名这一维度并未对齐——这是一条已知漂移,不是已收口,读图时以两侧各自语义为准别把它当成铁板一块除这条命名漂移外,信封的字段结构本身契约与代码是一致的且已真跑通过,所以整图状态是」;它没有现行/远期边界,讲的是一个当前就成立的跨端数据契约
> **产物执行沙箱图清单与状态表**
| # | 图名 | 家族 | 形式 | 状态 | 覆盖内容 |
|---|---|---|---|---|---|
| 1 | 不可信代码隔离全景 | | SVG新 | | 三层封堵(build scanLogic / iframe CSP / sandbox 属性)+ 唯一放行通道 postMessage + 封死逃逸面 + 同源过渡态四债8)诚实标注 |
| 2 | 取包 sha256 校验 iframe + CSP 注入 时序 | | SVG新 | | resolvePackage 四态 + sha256 双通道(Web Crypto / JS 回退)+ 字节一致性 + 函数序列化注入 + __GameBundle + 加载超时分流 + game_end latch 代发 |
| 3 | postMessage 双向桥与双校验 | | Mer新(内联) | | origin (空白名单默认拒绝· 'null')+ schema 逐字段闸 + 回包 requestId 配对 + request 5s 超时降级不 reject + 校验不抛异常 |
| 4 | 受控面(SDK bridge)能力面 | | SVG新 | | Core <8KB(init/on/track/reportError/ad/pay)+ Plugin 层懒加载(storage 四闸)+ 引擎装载契约(第二条入口)+ 降级铁律 + 8 type |
| 5 | 两条正交边界 | | SVG新 | | 沙箱边界(iframe vs 宿主,边界①)vs 受控面(游戏引擎,边界②)嵌套关系 + 破一条另一条仍兜 + IP/关联档/收敛三处别混 |
| 6 | CSP / 同源红线 / 产物消毒 | | Mer新(内联) | | connect-src 'none' + 'unsafe-eval' 纵深承接(漂移以代码为准)+ allow-same-origin 同源红线过渡态(HJ-AUDIT-001)+ escapeBundle 消毒 + 收敛三件一起做 |
| 7 | 三容器加载 / 失败 / 销毁态机 | | Mer新(内联) | | 当前播放 / 前一销毁 / 后一预加载 三态轮转 + dispose 防泄漏 + 加载失败降级 demo(不上抛)+ 加载超时分流 + game_end latch 过渡口径 |
| 8 | postMessage 信封数据模型 | 数据模型 | Mer新(内联) | | PostMessageEnvelope&lt;T&gt; 字段级结构(channel 定值 / type 8枚举 / direction / traceId / payload / requestId? 配对)+ validateEnvelope 逐字段必过项 + storage 三段 payload + ad/pay 配对 + 契约枚举 vs 运行时 VALID_TYPES 命名漂移诚实标注 |
> **状态分布**:现 ×8(机制全建成、真跑通过)。本域无「待接 / 待建 / 缓做 / future」整图——但**现行机制内有四处已知债**(CSP 文档口径漂移、sandbox 属性三处不一致、sandboxAttr 半孤儿、HJ-AUDIT-001 同源红线过渡态),另有**一处契约 vs 代码命名漂移**(图 8:契约 type 枚举 telemetry/social 与运行时 VALID_TYPES 的 track/ready 未对齐),均在对应图(图 1 / 图 6 / 图 8)用橙色虚线框或注记与「现行已建实心块」区分,绝不画成「已合规收口」。**防漂移门**:本文 frontmatter 记源档 `产物执行沙箱.md @ 38357c3d`(随 7fff6518 总图说提交锚定),源档变更即比对;图 8 另锚契约 `contracts/sdk-interface.d.ts`(第 #3 类契约),契约字段变更即比对本图。**单源说明**:本域所有图均为新作(SVG新 / Mer新),无「引」类;源设计档 `产物执行沙箱.md` 自身在 §1/§2/§3 内嵌了三段 Mermaid(端到端时序、三重边界、桥双校验),本图说不复制它们——图 2 是把 §1 时序升保真为 SVG、图 3 的桥流程按「桥判定流」单独框选重画、图 1/4/5 则是源档没有的新视角(全景 / 类图 / 边界嵌套);图 8 是从契约 `sdk-interface.d.ts` 提取的信封字段级数据模型,与源档 §3 的桥流程(讲消息怎么流)正交互补(讲消息长什么样),不复制 §3 的流程图。
---