契约先行(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。
196 lines
26 KiB
Markdown
196 lines
26 KiB
Markdown
---
|
||
topic: 产物执行沙箱
|
||
canonical: true
|
||
date: 2026-06-21
|
||
---
|
||
|
||
# 产物执行沙箱
|
||
|
||
> **这是什么**:一款 AI 生成的游戏是不可信代码,本文讲它怎么在玩家浏览器里被隔离着安全跑起来——取包、sha256 完整性校验、srcdoc 注入、iframe + CSP 隔离、postMessage 双向桥双校验、SDK 受控能力面、生命周期回流,一条端到端链路。
|
||
> **给谁看**:要改宿主 / 桥 / CSP / 校验逻辑的前端工程师,做安全审计的人,想搞清"生成出来的游戏能调什么、不能碰什么"的产品与架构。
|
||
|
||
绘境AI 的生成主线已经从"禁代码、填参数"迁到"让 agent 写码"。游戏逻辑不再是一组受控的配置字符串,而是模型现写的 JavaScript 本体——注入面就是代码本身(`.agents/rules/security-and-reliability.md` §1.1 记的这次口径迁移)。平台必须把每一款生成游戏当成有恶意、有 bug 的不可信输入来对待:它可能想读宿主的 localStorage、想发网络请求把玩家数据外带、想用一个同步死循环冻死页面。这套执行沙箱就是兜住这些的那道边界。
|
||
|
||
它和另一条信任边界——"插件↔引擎"受控面(`api.d.ts` 的 `PluginContext`,讲游戏代码经哪些受控方法调引擎能力)——是**两条正交的边界**,别混。受控面管的是"游戏代码调引擎时只能走公开 API",活在 iframe **内部**;本文这套沙箱管的是"整个 iframe 与外面的宿主平台之间的隔离",是 iframe **这层壳本身**。受控面在沙箱里面,沙箱在受控面外面。软著 `docs/ip/ruanzhu-5-game-runtime` 和专利 `docs/ip/patents/01-受控插件引擎` 写的是前者(受控插件面),不是本文这套 iframe 产物沙箱,引用时不要张冠李戴。
|
||
|
||
现状是 🟡:iframe + 桥 + 双校验 + sha256 这套机制都已建成、真跑通过,但有三处真实债没填——CSP 文档口径和代码已经对不上、sandbox 属性三处不一致、后端下发的 sandboxAttr 前端根本没接线;而且整套现在跑在**同源过渡态**(同源 srcdoc + `allow-same-origin`),HJ-AUDIT-001 那条"独立源就绪前禁用 `allow-same-origin`"的红线当前并未在代码里强制。这些债在 §8 逐条带 file:line 记清楚,读完它再判合规,别看到"机制都建了"就以为已经安全收口。
|
||
|
||
## 1. 端到端链路:一款不可信游戏怎么跑起来
|
||
|
||
一款游戏从后端取包到在玩家面前可玩,要穿过取包、校验、注入、隔离、建桥、引导、回流这一串环节。每一环都假设上一环的产物可能有问题。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant BE as 后端 AppRuntimeController
|
||
participant GP as GamePlayer.vue
|
||
participant IJ as inject.ts
|
||
participant IF as iframe(srcdoc · origin=null)
|
||
participant BR as HostBridge
|
||
participant UP as 上层 Play/Feed
|
||
|
||
GP->>BE: GET /package/{versionId}(取运行包清单 + 沙箱策略)
|
||
BE-->>GP: RuntimePackageRespVO(manifestUrl · checksum · sandbox.allowOrigins)
|
||
GP->>BE: GET /package/{versionId}/manifest(取原始 JSON 文本)
|
||
BE-->>GP: manifest.json 原始字节(String,不包 CommonResult)
|
||
Note over GP: fetchAndVerifyManifest:对原始文本算 sha256<br/>与 checksum 严格比对,不过即退 demo 兜底
|
||
GP->>IJ: buildIframeSrcdoc(pkg, traceId, gameId, versionId)
|
||
Note over IJ: 函数序列化注入 + engineBundle 独立内联 script<br/>硬编码 CSP 写进 srcdoc meta
|
||
IJ-->>GP: 完整 HTML 文本
|
||
GP->>IF: <iframe sandbox srcdoc>挂载
|
||
GP->>BR: attachBridge(buildOriginAllowlist,含 'null')
|
||
IF->>IF: boot():分流 engineBundle / demo
|
||
IF->>BR: postMessage(game_loaded / game_start,经双校验)
|
||
BR->>UP: emit('ready') / emit('start')
|
||
IF->>BR: postMessage(game_end,宿主轮询 latch 代发)
|
||
BR->>UP: emit('end')
|
||
```
|
||
|
||
取包这一步在 `GamePlayer.vue` 的 `resolvePackage`(:131-176)分四态走。创作者预览时,`props.manifest` 直接传一个内存里的包进来,不走网络(:133);玩家试玩时按 `versionId` 调 `getRuntimePackage` 拿运行包清单,清单里带 `manifestUrl` 就去 `fetchAndVerifyManifest` 拉真包并做 sha256 校验(:152-154);清单没有 `manifestUrl`(mock 阶段后端还没回填)就退 `buildDemoPackage` 兜底(:169);取包或校验中途出任何错,也退 demo 兜底,只 `console.warn` 记一笔、**不向上 emit error**(:143-148、:155-163)。最后这条不向上抛错是有意的:父级 `Play` 把 error 当致命态处理,会用 `v-else-if` 把宿主整个隐藏掉显示"加载失败",而 demo 兜底本可以正常试玩,两者冲突,所以可恢复的失败只降级不上抛。
|
||
|
||
沙箱白名单 `allowOrigins` 在 `launch`(:184-197)里顺带读出来——只在用默认取包器时,从 `respVO.sandbox?.allowOrigins` 取(:191-192)。拿到包之后 `launch` 调 `attachBridge`(:228-250),用 `buildOriginAllowlist(pendingAllowOrigins, includeNull=true)` 建桥。
|
||
|
||
加载超时按装载路分流。引擎包路 `ENGINE_LOAD_TIMEOUT_MS=12000`(:90),旧 demo 路 `LEGACY_LOAD_TIMEOUT_MS=5000`(:85)。引擎冷启动要 4.8~5.3 倍于旧路、加上内联 bundle 解析会逼近甚至超过 5s,所以引擎路放宽到 12s 防误杀;首屏 P75<3s 由 `perf_first_screen` 单独监测,和这个防挂死上界正交(:86-89)。
|
||
|
||
## 2. 三重边界:游戏能调什么、不能碰什么
|
||
|
||
不可信代码被三层封堵关在笼子里。这三层在不同时机、不同位置生效,合起来才是完整的封锁。
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
subgraph OUT["iframe 外 · 宿主平台(玩家数据 / 网络 / 真 localStorage)"]
|
||
SDKH["SDK 受控面回包<br/>ad / pay / storage / track"]
|
||
ENG["引擎能力<br/>ctx.getEngine() · render(mainContext)"]
|
||
end
|
||
subgraph IF["iframe 内 · 不可信生成代码"]
|
||
G["游戏逻辑(模型现写 JS)"]
|
||
end
|
||
G -->|✅ 唯一对外通道 postMessage| SDKH
|
||
G -->|✅ 受控面调用| ENG
|
||
G -.->|❌ CSP connect-src none| NET["fetch / XHR / 网络出站"]
|
||
G -.->|❌ CSP default-src none + sandbox| DOM["宿主 document / window 逃逸"]
|
||
G -.->|❌ sandbox 无 allow-top-navigation| NAV["顶层导航 / 弹窗"]
|
||
B1["build 段 scanLogic 静态门<br/>编译前拦 Function/eval/import/fetch/window/while(true)…"] -.->|出厂前已净化| G
|
||
```
|
||
|
||
**第一层在 build 段,不在 runtime——但它属已废 gamedef 路的 build 段机制。** `game-runtime/src/host/build-from-source.mjs` 的 `scanLogic`(:73)在把 gameDefinition 装配成可玩工厂、交 esbuild 打包**之前**,对模型产出的 `behavior.code` 和 `rule.condition` 做静态扫描。这套扫描是 gamedef 专属的:它针对的 `behavior.code` / `rule.condition` 串字段只存在于 gameDefinition,而 A-model 写真 src/ 多文件没有这类逻辑串字段,故随 gamedef 废弃而不再是现行路径。`LOGIC_BANS`(:42-63)是实际的拦截清单:沙箱逃逸类拦 `process`、`require`、动态 `import(`、`globalThis`、`eval(`、`.constructor`、`Function(`;DOM 与网络类拦 `document`、`window`、`fetch(`、`XMLHttpRequest`、`addEventListener`、`requestAnimationFrame`;死循环类拦 `while(true)`、`while(1)`、`for(;;)`;还有确定性逃逸类拦 `Math.random`、`Date.now`、`new Date`、`performance.now`(取证要可复现,逻辑里只许用 `rt.random` / `rt.time.now`)。`rule.condition` 另有专属禁则 `CONDITION_BANS`(:65-70):它必须是一个无副作用的布尔表达式,所以拦 `;`(不许是语句)、拦赋值 `=`、拦调世界变更方法 `addScore/setScore/spawn/destroy`、拦 `win()/lose()`。命中任何一条就转成 `validationError` 回灌给 repair 反馈,而不是靠 prompt 自觉。这层为什么必须在 build 边界做:同步 JS 没法在进程内硬中断,死循环和逃逸一旦执行就晚了,所以在执行前的校验边界把危险模式拦掉(:13-15 的信任模型注释)。
|
||
|
||
**第二层是 iframe 的 CSP。** `inject.ts`(:80-81)把 CSP 硬编码进 srcdoc 的 `<meta>` 标签,内容是 `default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data: blob: https:; connect-src 'none';`。`connect-src 'none'` 是这层最关键的一条——它禁掉一切网络出站。引擎 bundle 不在 iframe 内 fetch,而是由宿主层 fetch 后内联进来,所以 `connect-src 'none'` 不会卡死引擎装载(:72-73)。`default-src 'none'` 兜底封掉其余所有未显式放开的资源类型。这里有一处和文档对不上的真实债:`script-src` 实际是 `'unsafe-inline' 'unsafe-eval'`,不是文档写的 `'self'`,原因和承接见 §8 漂移①。
|
||
|
||
**第三层是 sandbox 属性,以及由它决定的唯一对外通道。** iframe 带 `sandbox` 属性隔离,游戏与宿主平台之间**只有 postMessage 一条通道**。这条约束直接决定了产品形态:广告、支付、存储这些要碰平台资源的事,游戏自己在 iframe 内办不了——它没有网络(`connect-src 'none'`)、碰不到宿主 DOM(sandbox)。所以这些全部经 postMessage 抛给 iframe 外的宿主侧去办,宿主办完再把结果回包进来。这就是为什么 SDK 的 ad/pay/storage 都是"游戏发请求→宿主侧执行→回包"的形状,而不是游戏直接调。
|
||
|
||
## 3. postMessage 双向桥与双校验
|
||
|
||
桥是 `game-studio/src/host/bridge.ts` 的 `HostBridge` 类。游戏与宿主之间的每一条消息都走它,而它把 iframe 来的消息当不可信边界处理:先过 origin 闸,再过 schema 闸,任一不过就丢弃,绝不分发。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
M["iframe postMessage 来一条消息"] --> O{origin 在白名单?}
|
||
O -->|否| R1["onReject('origin_not_allowed')<br/>丢弃"]
|
||
O -->|是| S{schema 逐字段校验过?}
|
||
S -->|否| R2["onReject(reason)<br/>丢弃"]
|
||
S -->|是| P{是宿主请求的回包?<br/>requestId 配对命中}
|
||
P -->|是| RP["消费 pending 回调<br/>clearTimeout + resolve"]
|
||
P -->|否| D["分发给上层 GamePlayer"]
|
||
```
|
||
|
||
`onWindowMessage`(:108-139)是入口。第一道 origin 白名单(:113),`isOriginAllowed`(:142-145)对空白名单**默认拒绝**——白名单为空视为不允许任何来源,这是安全默认。第二道 `validateEnvelope`(:151-199)逐字段校验信封:`channel` 必须严格等于固定标识 `wanxiang-game-sdk`(:162,否则是噪声或伪造),`type` 必须在 8 个枚举的 `VALID_TYPES` 内(:166),`direction` 必须合法(:170),`traceId` 必须是字符串(:177,贯穿链路必备),`payload` 必须存在(:181,只挡 undefined),`requestId` 若带则必须是字符串(:185)。两道闸任一不过都走 `onReject` 记录并丢弃(:114、:122)。这里有个细节是有意的:`validateEnvelope` 不抛异常,只返回判定结果——注释 :149 写得明白,"异常本身也是一种攻击面"。
|
||
|
||
消息过了两道闸,还要看是不是宿主先前请求的回包。`onWindowMessage`(:129-135)检查 `requestId` 是否命中 `pending` 表,命中就清掉定时器、`resolve` 对应的 Promise、消费掉,不再往上分发。不是回包才 `onMessage` 给上层(:138)。
|
||
|
||
宿主往游戏发消息用 `post`(:206-224),`targetOrigin` 用 `'*'`——因为 srcdoc 文档的 origin 是 `null`,没法精确指定(注释 :205、:219)。`request`(:230-244)是 Promise 化的"请求-响应",带 5s 超时(`REQUEST_TIMEOUT_MS`),超时就清理并以 `undefined` 兜底 resolve,**绝不 reject**(:233-237)——向上抛 reject 会打断游戏,所以宁可降级。`dispose`(:250-255)在 `GamePlayer` 卸载时移监听、清所有挂起请求的定时器,防泄漏。
|
||
|
||
桥能识别的 8 种 type 与方向如下(`SDK_CHANNEL` 恒为 `wanxiang-game-sdk`):
|
||
|
||
| type | 方向 | 用途 |
|
||
|---|---|---|
|
||
| init | host → game | 宿主下发初始化(游戏侧自启动兜底,幂等) |
|
||
| lifecycle | game → host | game_loaded / game_start / game_end 回流 |
|
||
| track | game → host | 遥测埋点上报 |
|
||
| error | game → host | 游戏侧错误上报 |
|
||
| ad | 双向 | 游戏请求广告 → 宿主弹桩层 → 回包 |
|
||
| pay | 双向 | 游戏请求支付 → 宿主弹桩层 → 回包 |
|
||
| storage | 双向 | idle 离线存档读写(经宿主受信边界) |
|
||
| ready | game → host | SDK 就绪信号 |
|
||
|
||
## 4. SDK 受控面契约
|
||
|
||
iframe 内的游戏只能经一个全局对象 `window.WanxiangGameSDK` 与外界打交道,加上引擎装载契约(`bootGameHost` / `render(mainContext)` / `ctx.getEngine()`)。受控面之外一律被 CSP 和 sandbox 封死。受控面实现在 `game-studio/src/host/sdk/index.ts`,暴露 `init` / `on` / `track` / `reportError` / `ad`(:126)/ `pay`(:161)。
|
||
|
||
每个受控面都守一条降级铁律:**异常绝不向游戏抛、超时就降级、写类操作 fire-and-forget**。`init`(:181)注入 `traceId` 贯穿"生成→运行→上报"全链路;`track`(:203)是 10 条 / 5s 的批量缓冲;`ad` / `pay` 经桥的 `request` 走 5s 超时降级,超时静默兜底而不打断游戏。这条铁律的根源在玩家验收门那五条 AND 里的"无错"——游戏运行期一旦抛未捕获错误就判不过,所以受控面任何意外都得自己吞掉。
|
||
|
||
storage 这个面尤其要小心,因为它直接写宿主的 localStorage。`GamePlayer.vue` 的 `handleStorage`(:337-396)设了四道闸:
|
||
|
||
- **闸①·key 白名单**:`key` 必须恰好等于 `idle:gameId:versionId`(:342-343),其它一律丢弃。tycoon 这类无离线态的模板根本不发 storage,白名单也不含它的前缀。
|
||
- **闸②·大小上限**:`value` 长度超 4KB 丢弃(:353),防滥用 localStorage 配额(idle 存档实际不到 200B)。
|
||
- **闸③·JSON 校验**:写入前 `JSON.parse` 校验是合法 JSON 才落(:359-365);回读时也校验、且只接受对象形态(:382-385),防有人手工篡改 localStorage 注入脏数据。
|
||
- **闸④·命名空间隔离**:落盘时键加 `wxgame:` 前缀(:348),不污染其它 localStorage。
|
||
|
||
〔W-SAVE-CAP 增补 2026-07-06〕白名单增第二个恰等键族 `save:gameId:versionId`(复杂游戏主存档,北极星三款起用):单槽上限 65536 码元,写入值必须是 `{schemaVersion: 整数≥1, data: 纯对象}` 版本信封、闸内 fail-closed;`idle:` 族行为逐字不变。闸逻辑抽成纯函数 `game-studio/src/host/storageGate.ts`(可 node 直测),`handleStorage` 改为调它;本节原行号引用以现码为准。契约 = `contracts/sdk-interface.d.ts` SaveEnvelope;游戏侧配套 = save-progress 插件 v1.1.0 `setVersioned/getVersioned`(逐级迁移+降档不毁档兜底);设计与验收 = `docs/plans/2026-07-06-002-feat-W-SAVE-CAP-存档定容与schemaVersion-plan.md`。
|
||
|
||
读分支有个设计点:回包给游戏的是**宿主侧已经解析好的对象**,不是原始字符串(:373-374)。解析在宿主这道受信边界做,runtime 侧零 `JSON.parse`,守住"游戏代码不引可抛路径"的红线。整个 `handleStorage` 任何意外异常都静默吞、不崩宿主(:391-394)。
|
||
|
||
ad / pay 的桩弹层(:402-464)按 `requestId` 配对回包,把广告 / 支付请求弹成一层桩 UI,玩家点完再把结果回包给游戏。生命周期 `handleLifecycle`(:294-319)把 `game_loaded` 翻成 `phase=ready` 并清掉加载超时,把 `game_end` 翻成 `emit('end')`。
|
||
|
||
## 5. 完整性校验:sha256 双通道
|
||
|
||
manifest 从后端拉下来后,要确认它在传输途中没被篡改、和后端写包时算的 checksum 一致。`inject.ts` 的 `sha256Hex`(:335-348)走双通道:安全上下文(https / localhost)优先用原生 Web Crypto `SubtleCrypto`,性能最好(:339-345);非安全上下文回退一份纯 JS 的 SHA-256 实现 `sha256BytesToHex`(:356-413,FIPS 180-4 完整 64 轮压缩)。
|
||
|
||
为什么要这份纯 JS 回退:创始人用局域网 IP 明文 http(比如 `http://100.64.0.7:4173`)跨内网机访问时,浏览器判定为"非安全上下文",`crypto.subtle` 直接是 `undefined`(:322-330 注释)。旧实现这时会抛错,导致 `fetchAndVerifyManifest` 失败、退 demo 兜底,而 W-CLEAN 之后 demo 是"模板体系重构中"并发 game_error,最终页面误报"游戏加载失败 / 网络波动"——真因是上下文不安全,根本不是网络。回退纯 JS 后,完整性校验在明文 IP http 和安全上下文下行为一致,整款游戏都能正常装载。两通道对同一份 UTF-8 字节摘要,结果逐字节一致。
|
||
|
||
校验链路两端对齐:后端 `PackageFactory` 写包时算 checksum 落库,前端宿主拉 manifest 时验。后端这边 `AppRuntimeController.getPackageManifestRaw`(:81-96)返回的是 manifest 原始 JSON 文本——返回类型是 `String`,`GlobalResponseBodyHandler` 只拦 `CommonResult` 不拦它,`StringHttpMessageConverter` 原样写出字节、不重序列化,保证字节一致性(:88-89)。前端 `fetchAndVerifyManifest`(:450-482)对拿到的原始文本算 sha256 与清单的 checksum 严格比对(:464-468),一致才返回真包,否则抛错由 `GamePlayer` 退 demo 兜底。这条端点 `@PermitAll` 匿名可取(:76),门禁压在 Service 层做 scene+status 判定,匿名宿主能取 manifest 试玩。整个拉取只是 GET 拿数据、无脚本执行,拿到的 `GamePackage` 只作数据注入 iframe,不会被当可执行内容求值(:442-443)。
|
||
|
||
## 6. srcdoc 函数序列化注入
|
||
|
||
iframe 的内容由 `inject.ts` 的 `buildIframeSrcdoc`(:45-50)拼出来,作为 srcdoc 注入。它有一个不常见的手法叫"函数序列化注入":`createWanxiangSDK` 和 `startRuntime` 都是不依赖模块外部符号的纯工厂函数,这里用 `Function.prototype.toString()` 取它们的源码文本(:52-53),拼进 iframe 的内联 `<script>` 里在沙箱内重新求值执行。这样同一份 TS 源既被宿主工程类型检查(和 SDK / Runtime 契约对齐),又在 iframe 内真跑,不用为它们单独配打包产物(:7-14)。代价是工厂函数内不能出现只存在于 TS 的运行期符号(import 的值、装饰器、枚举值引用),现实现都满足。
|
||
|
||
引擎包分支在 `boot()`(:201-282)里按 `pkg.engineBundle` 是否存在分流。有 engineBundle 时,bundle 文本作一个**独立的内联 `<script>`**注入(:63-65),暴露 `window.__GameBundle`——这个全局名是契约冻结的,和 `game-package.schema.json`、`contract.ts`、`entry-bundle.js` 逐字一致。随后引导脚本调 `window.__GameBundle.bootGameHost({canvas, seed})` 启动引擎掌帧,把"引擎就绪 / 游戏终态"桥接成 SDK lifecycle 的 game_loaded / game_end(:252-260)。没有 engineBundle 时走旧 `startRuntime` 兜底,显示"模板体系重构中"(:277-281)。
|
||
|
||
bundle 是可执行脚本,内联进 `<script>` 唯一的注入面就是它可能含能提前闭合脚本块的字面序列,所以经 `escapeBundleForInlineScript`(:313-317)断掉 `</script>`(含大小写变体)和 `<!--`,让它无法越出 `<script>` 边界。这是纵深防御——bundle 文本来自后端 engineBundle、整包 sha256 已覆盖其完整性,不是用户自由输入。
|
||
|
||
游戏没有自己的 emit 通道发 game_end,所以宿主轮询代发。`watchGameEnd`(:216-229)每 500ms 查 `host.state().phase`,到 `gameover` 就 latch 一个 game_end{completed:true}(:221-225),带上 gameoverReason 便于取证定位,并用 `gameEndEmitted` 幂等守卫防重复发。这是过渡口径:ref 游戏是纯引擎工厂、内部没 SDK,自发不出 game_end,只能由引导经装载契约 `host.state()` 探测终态后代发;W-G1 生成主线落地后,game_end 改由各品类标准信号产出,这个轮询桩可下线(:212-215)。
|
||
|
||
## 7. 两条装载路共用一套沙箱
|
||
|
||
绘境AI 有两条游戏装载路,装载形态不同,但**执行沙箱是同一套**。Tier0/1 的廉价线走 engineBundle 内嵌:**现行由 A-model 写真 `src/` 多文件、经 esbuild 打成 bundle、内联进 iframe**(原 gameDefinition 中间表示经 `new Function` 解释那条路已于 2026-06-21 判错误路线、废弃,见[生成引擎子树](生成引擎/) README §3.2;无论早期 gamedef 还是现行 A-model 真 src/,最终都构建成同一形态的 bundle,本档这套沙箱不受影响)。tier2 的 Phaser 真工程走另一条:经独立源项目契约构建出 bundle(生成运行时 SoT [`生成引擎/agentic运行时架构图说.md`](生成引擎/agentic运行时架构图说.md) 点明 tier2 复用同一装载路,原「tier2实现详设」已并入该 SoT)。但两条路出来的 bundle 都暴露 `window.__GameBundle`、都调 `bootGameHost`、都跑在同一套 iframe + CSP + 桥里。
|
||
|
||
从沙箱视角看,这意味着不管哪条装载路,执行隔离的边界、CSP 的封锁、桥的双校验、`__GameBundle` 全局名和 `bootGameHost` 入口约定全部一致。tier2 换的是"游戏内容怎么造出来",换不掉"造出来之后怎么被关进笼子跑"。引擎宿主侧 `game-runtime/src/host/boot-game-host.js` 的 `bootGameHost`(:75-460)在 real 通道关掉自身 GL、由引擎五回调掌帧、绘制面用引擎的 mainContext,游戏经 `boot.ctx.getEngine()` 拿能力、零直接 import 引擎——这套对两条装载路是公共的。
|
||
|
||
## 8. 现状与已知债
|
||
|
||
机制建成不等于合规收口。下面四条债逐条带 file:line,审计和后续收敛照它走。
|
||
|
||
**漂移①·CSP 文档 vs 代码。** `.agents/rules/security-and-reliability.md` §1.1(:31)、skills 手册、契约注释都写 `script-src 'self'`,且说 CSP 经游戏包托管侧 HTTP 响应头下发、不以 meta 为唯一依赖。但 `inject.ts`:81 实际是 `script-src 'unsafe-inline' 'unsafe-eval'`,且就硬编码在 srcdoc 的 `<meta>` 里。`'unsafe-eval'` 是 gamedef 运行时必需的:声明式 gameDefinition 的 behaviors / rules 各带逻辑 JS 串,`gd-runtime.js` 用 `new Function`(:248、:263)编译执行,缺它会抛 EvalError 被静默吞进 errors[]、tickBehaviors 空转、游戏只渲首帧静态、世界永不演进(bug#2 实证 2026-06-21,A/B 加后 errCnt 7→0、score 0→演进)。文档口径已过期,以代码为准。`'unsafe-eval'` 的风险由纵深防御承接:逻辑串先经 `scanLogic` 静态拦危险面,加 `connect-src 'none'` 无网络出站,加 iframe sandbox 隔离,三重边界使它不扩大真实攻击面。`gd-runtime.js` 的 `new Function` 本身只有 try-catch 隔离、没有静态扫描,但危险面已经在 build 边界被 `scanLogic` 拦掉了——这是"build 时静态拦截 + runtime 执行已净化代码"的纵深防御(`inject.ts`:71 注释提到 scanLogic 略有 build/runtime 混称,但实质成立)。
|
||
|
||
**漂移②·sandbox 属性三处不一致。** DB 列 `game_runtime_package.sandbox_attr` 默认值是 `'allow-scripts'`(`V3.0.0__create_game_runtime.sql`:67,scaffold 脚本数据也是 `'allow-scripts'`);前端 iframe 硬编码的是 `'allow-scripts allow-same-origin'`(`GamePlayer.vue`:544);文档和契约示例写的也是 `'allow-scripts allow-same-origin'`。三处不同源。
|
||
|
||
**半孤儿③·sandboxAttr 下发但前端不消费。** 后端确实填充并返回了 `SandboxPolicyVO.sandboxAttr`——`RuntimeConvert.java`:66 有 `sandbox.setSandboxAttr(pkg.getSandboxAttr())`,前端 `contract.ts` / `types.ts` / mock 也都声明了这个字段。但 `GamePlayer` 从不把 `sandboxAttr` 应用到 `<iframe sandbox=>` 元素上,它只消费 `allowOrigins`(:191-192)。也就是说 `sandbox_attr` 这条 DB→VO→前端的链在"属性维度"是没接线的半孤儿,实际生效的是前端 :544 那个硬编码值。这直接呼应 HJ-AUDIT-001:那条红线想靠下发的 sandboxAttr 在独立源就绪前去掉 `allow-same-origin`,而当前下发根本没被消费,红线在代码里没强制。
|
||
|
||
**HJ-AUDIT-001 同源红线当前是过渡态。** 红线(`.agents/rules/security-and-reliability.md` §1.1:30)说 `allow-same-origin` 只有当游戏包托管在独立源(usercontent 子域、与宿主不同源)时才能用,因为同源下 iframe 能触宿主 DOM / 存储、甚至自己移除 sandbox。但代码现状是:同源 srcdoc + `allow-same-origin`(:544),且 origin 白名单还把 `'null'` 含进去(`buildOriginAllowlist` 在 `includeNull` 时加字符串 `'null'`,因为 srcdoc 文档 origin 就是 null)。这是已知的、独立源落地前的过渡态,不是已合规。别因为机制都建了就误判它安全收口。
|
||
|
||
**收敛方向。** 三条收敛要一起做才闭合:游戏包迁到独立的 usercontent 子域(让 `allow-same-origin` 真正安全)、CSP 从 srcdoc meta 改成游戏包托管侧的 HTTP 响应头下发、把 sandboxAttr 这条链在前端真接线(让 :544 的硬编码值改由后端下发的策略驱动)。三者落地后,§8 这几条债才算清掉。
|
||
|
||
## 9. 关键指针
|
||
|
||
代码锚:
|
||
- iframe 宿主容器 / 取包四态 / storage 四闸 / 加载超时 — `game-studio/src/host/GamePlayer.vue`
|
||
- postMessage 双向桥 / 双校验 / 超时降级 / dispose — `game-studio/src/host/bridge.ts`
|
||
- srcdoc 注入 / CSP / engineBundle 分支 / sha256 双通道 / escapeBundle — `game-studio/src/host/inject.ts`
|
||
- SDK 受控面(init/on/track/reportError/ad/pay)— `game-studio/src/host/sdk/index.ts`
|
||
- 引擎宿主 bootGameHost / real 通道掌帧 — `game-runtime/src/host/boot-game-host.js`
|
||
- build 段静态门 scanLogic / LOGIC_BANS / CONDITION_BANS — `game-runtime/src/host/build-from-source.mjs`
|
||
- 后端取包 / manifest 原始文本端点 — `game-cloud/game-module-runtime/.../controller/app/runtime/AppRuntimeController.java`
|
||
- 沙箱策略组装(SandboxPolicyVO)— `game-cloud/game-module-runtime/.../convert/runtime/RuntimeConvert.java`
|
||
|
||
契约:
|
||
- postMessage 协议类型 — `contracts/sdk-interface.d.ts`
|
||
- engineBundle / checksum 字段 — `contracts/game-package.schema.json`
|
||
- SandboxPolicyVO / manifestUrl / GAP-2 取包约束 — `contracts/api-schemas/runtime.yaml`
|
||
- 沙箱列 sandbox_attr / allow_origins — `game-cloud/huijing-server/src/main/resources/db/migration/V3.0.0__create_game_runtime.sql`
|
||
- 装载契约 GameHostBootContext / GameInstance / GameHostFactory — `game-runtime/src/core/game-host.d.ts`
|
||
|
||
关联档:
|
||
- 装载链与引擎能力边界 — [`生成引擎/agentic运行时架构图说.md`](生成引擎/agentic运行时架构图说.md)(与本档正交:它讲装载 + 引擎能力,本档讲执行隔离 + 安全边界)
|
||
- 沙箱高层红线 — `.agents/rules/security-and-reliability.md` §1.1
|
||
- 运行时打包与多渠道手册 — `.agents/skills/runtime-and-multichannel.md`
|
||
- tier2 复用同一沙箱 — [`生成引擎/agentic运行时架构图说.md`](生成引擎/agentic运行时架构图说.md)(§5.6 源项目契约与装载)
|