续上轮(banner+README),把 broader/沙箱档里 gamedef-当现行/迁移中清成「已废错误路线、 A-model 现行」(opus 代理 + 我补漏): - 00-系统总览:左轨现行 gameDefinition→A-model 写真 src/,无迁移/cutover - 03/架构产物沙箱:scanLogic(behavior.code/rule.condition 扫描)、new Function/unsafe-eval 标为已废 gamedef 路 build 段机制,A-model 真 src/ 经 esbuild 无 new Function; **沙箱层(iframe/CSP/桥/九门)对两路通用、保留**;装载路 L146 改 A-model 现行 - OpenGame对照:L325「当前实现 gamedef」→已废、L318 smart_edit 注 A-model 文本补丁回归 - 验收门:九门收窄 91.7% 标为 gamedef 路历史实测 + 该验收策略引擎无关对 A-model 适用 - 数据飞轮:去「待 gamedef→src 迁移协调」(无迁移,gamedef 已废) 门:全仓死链 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
34 KiB
date, topic, status
| date | topic | status |
|---|---|---|
| 2026-06-22 | 产物执行沙箱图说——绘境AI 架构图集(按领域拆分·03) | 现行为主 · 每图具体映射源档见该图脚注 / 内联小注 · 通用图例见 00-系统总览图说 |
03 · 产物执行沙箱图说
本篇是绘境AI 架构图集的一部分(全 8 篇:00 系统总览 + 01–07 各域)。通用图例、状态码、按角色读路径见 00 · 系统总览图说。 本域命门(最该据图 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新·架·现〕
这张全景图回答整份文档的总问题:一段模型现写的不可信 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新·时·现〕
这张时序图把一款不可信游戏「从后端取包到在玩家面前可玩」的全过程展开成六条泳道之间的一串动作。它的叙事主线是:每一环都假设上一环的产物可能有问题,所以取包之后必有完整性校验、注入之时必带 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 闸,任一不过就丢弃、绝不分发;过了两道闸还要分辨它是不是宿主先前请求的回包,是回包就消费掉、不是才往上分发。
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新·类·现〕
这张类图回答「平台到底向不可信游戏注入了什么能力、又是怎么注入的」。结论是: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,守住「游戏代码不引可抛路径」的红线。
整张图最该让人记住的是那条贯穿所有受控面的降级铁律:异常绝不向游戏抛、超时就降级、写类操作 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新·架·现〕
这张图专治一个最容易混的概念:系统里有两条信任边界,它们方向正交、位置嵌套,必须分清。边界① 是沙箱边界——「整个 iframe 这层壳」与「外面的宿主平台」之间的隔离,它活在 iframe 壳本身,是本档(产物执行沙箱.md)的主轴;边界② 是受控面——iframe 内部、「游戏代码」与「引擎能力」之间的约束(api.d.ts 的 PluginContext),它管「游戏代码调引擎时只能走公开 API」,是 引擎与运行时.md 的主轴。一句话记牢:受控面在沙箱里面,沙箱在受控面外面。
图用嵌套的方框把这个关系画成可视:红色虚线大框是沙箱壳、紫色虚线框嵌在里面是受控面、最内层才是不可信游戏代码——它被两层边界一起关着。右上角的「同心嵌套示意」是这张关系的速记版。之所以强调它们「正交」,是因为两条边界各管一段、不互为替代、缺一不可,而且任何一条被破时另一条仍在兜:如果沙箱边界破了(比如同源过渡态 allow-same-origin 下游戏触到了宿主 DOM),受控面拦不住它——这正是 §8 同源债的要害;反过来如果受控面破了(游戏绕开公开 API 去抓引擎内部),外层沙箱壳仍兜得住,不致让数据外泄或出网。把这层「破了一条还有另一条」的纵深关系画出来,就能解释为什么两条边界都不能省。
这张图右下角把「最容易混的三处」钉死,都是实际踩过或容易踩的坑:一是引用知识产权时别张冠李戴(软著/专利写的是受控插件面=边界②,不是 iframe 产物沙箱=边界①);二是关联档分工别串(引擎与运行时.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 现状债集中的地方。
flowchart TB
subgraph CSP["① CSP(硬编码进 srcdoc <meta>)"]
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:断 </script>(含大小写/空白变体)和 <!--<br/>让 bundle 文本无法越出 <script> 边界"]
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 降到 0、score 从 0 开始演进)。它的风险承接是一条纵深链:逻辑串先在 build 边界经 scanLogic 静态拦掉危险面,再加 connect-src 'none' 断网络出站,再加 iframe sandbox 隔离——三重边界使它不扩大真实攻击面。所以图上把 'unsafe-eval' 标成债色、但用一条绿线指向「纵深承接」,表达「它是有意的、不是漏洞」。这里也要诚实记一笔文档漂移:.agents/rules/security-and-reliability.md §1.1、skills 手册、契约注释都写 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 是短视频式的游戏流,玩家上下滑会连续切换游戏,所以沙箱容器不是一个、而是逻辑上的三个角色在轮转:当前正在播放的、前一个正在销毁的、后一个正在预加载的。这张图把这三态的流转、以及「加载失败怎么降级」画清楚。
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 逐字段校验,两端对的就是这一份结构。把它单独画出来,是为了给安全审计一个「合法消息的字段全集」清单,也给游戏侧实现一个照着填的模板。
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<T> 字段级结构(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 的流程图。