Merge origin/master (b1c89fbba) into chore/rust-compile-warnings:按「让路 master + 最小集恢复」收口跨文件契约冲突
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

解冲突口径(本次同步 master 16 个提交):

- `agent/generation/canvas_generation.rs`、`command_exec.rs` 两个冲突文件不再整文件取一边,改为按
  hunk 三分法:master 的功能 hunk 全量移植;我们的清理只在 master 调用方不依赖时才保留。
  未恢复整块模块 / 整文件,未新增任何 `#[allow]` / `#[expect]`。
- 成对回退我们的「摘掉 brief 传参空转」清理:master #628 等新代码仍按带 briefs 的契约调用生成链,
  故 cg 的 8 处签名与 6 个调用方(direct_runtime / direct_tool_bridge / commands / commands/desktop /
  asset_generation_tasks)回到 master 形状,并按最小集恢复 `main.rs` 的 `AgentRoleBrief` /
  `AgentGroupBrief`(未恢复其它已删模块)。
- master #628 已收敛切片用途判定:客户端不再暴露/发送 `sliceCount`,本次跟 master 走(
  `PlatformArtAssetGenerationOptions` 不再有该字段),只保留结果侧 256 上限门。
- command_exec 的 `project_command_actual_target` 采用 master 的 `Vec<OsString>` 字节精确契约,
  同时保留我们「调用点全在 Linux 编译面」的 `#[cfg(target_os = "linux")]` 收窄(与 master 调用方不冲突)。
- 脚本缺陷修复:`scripts/check-rust-warnings.mjs` 编译失败不再可能假通过(cargo 退出码非 0 一律 exit 1,
  并把 stderr 尾部提到前面打印;去掉 `warnings.length === 0` 条件)。
- 取 master 版本带来的回流告警 2 条(`main.rs` 两个 brief 结构体的字段未被读)已登记进
  `scripts/warning-baseline.json`(agc-windows 16 条 / agc-linux 71 条,server-rs 0 条);
  按既定口径「不修上游告警」,只做记账。
This commit is contained in:
2026-10-07 21:01:34 +08:00
42 changed files with 3032 additions and 883 deletions
@@ -16,6 +16,7 @@ Connect to `https://www.genarrative.world/api/external/v1/mcp` using Streamable
- Upload local references using `prepare_asset_upload`: request a ticket, transfer the file from the client, then confirm the object. Confirmation does not create a canvas layer or a project/library record. Use the reference type accepted by the target tool; some operations require a registered resource or asset ID rather than an object key.
- Generation is paid and asynchronous. Keep one stable `idempotencyKey` per logical generation and retain the returned `operationId`. Call `check_generation` according to `pollAfterMs`; consume `result` only after `completed`, and report the safe error on `failed`. A polling timeout does not justify another generation.
- Read actual artifacts and warnings before claiming the requested deliverable is complete. Use project/library reads for complete persisted records, and `find_assets` with `action=get_download_url` for temporary media access.
- Treat `generationInputs` as generation context and provenance metadata, subject to each endpoint's preservation and rebuilding rules. Arbitrary fields, including `artSpec`, do not automatically enter the provider prompt or override request parameters. Put generation requirements in the endpoint's explicit inputs; see [Generation Inputs Metadata](references/requests-and-outputs.md#generation-inputs-metadata) for persistence, reads, and known consumers.
- Keep API Keys and temporary upload/download credentials out of chat, repository files, and logs. Business calls operate within the API Key's owner and scopes.
## Documentation Navigation
@@ -20,7 +20,7 @@ For generation, pass `projectId` with `canvasCompletion` when the result should
## Art Spec Routing
For a series of related art requests, an optional reusable spec can carry the shared requirements:
For a series of related art requests, an optional caller-defined spec can record the shared requirements. `artSpec` is an organizational convention inside `generationInputs`, not a server-defined generation parameter schema:
```json
{
@@ -35,7 +35,7 @@ For a series of related art requests, an optional reusable spec can carry the sh
}
```
Infer what is already clear and ask only for missing fields that block the selected endpoint. Reuse the current spec unless the user changes style, subject family, palette, format, or constraints. Store structured context under `generationInputs.artSpec` where supported and summarize it in the prompt when useful.
Infer what is already clear and ask only for missing fields that block the selected endpoint. Reuse the current spec unless the user changes style, subject family, palette, format, or constraints. Where the endpoint preserves custom metadata, `generationInputs.artSpec` can retain this context for later retrieval. To affect generation, always translate the relevant requirements into the endpoint's explicit inputs: image `prompt`, scene `sceneContent` / `stylePreset` / `customStyle`, or spritesheet `iconDescriptions`, plus the actual size and reference parameters. Neither `artSpec.references` nor `generationInputs.references` supplies reference media by itself. Scene and sound-effect generation rebuild their metadata and do not preserve an arbitrary `artSpec`; keep a caller-side copy when needed. See [Generation Inputs Metadata](requests-and-outputs.md#generation-inputs-metadata) for the rules applying to the entire `generationInputs` field.
## Intent Map
@@ -9,6 +9,7 @@ Use this reference to build generation payloads, carry canvas/library context, p
- [Polling State Machine](#polling-state-machine)
- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
- [Saving Existing Canvas Layout](#saving-existing-canvas-layout)
- [Generation Inputs Metadata](#generation-inputs-metadata)
- [Art Spec and Image Request](#art-spec-and-image-request)
- [Local Reference Requests](#local-reference-requests)
- [Compact Completed Result](#compact-completed-result)
@@ -135,7 +136,7 @@ Background removal preserves the source image dimensions. For normal canvas plac
Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed.
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
For the lower-level asset/resource creation endpoints, `generationInputs` follows the metadata and media-runtime boundaries described in [Generation Inputs Metadata](#generation-inputs-metadata).
## Saving Existing Canvas Layout
@@ -146,15 +147,46 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
`edit_canvas/register_resource` registers existing media but does not create a canvas layer. `organize_asset_library/create_asset` creates metadata but does not upload or generate media. For generated media placement, prefer the generation tool's supported `canvasCompletion`; inspect returned identities before registering anything again.
## Generation Inputs Metadata
`generationInputs` is optional JSON generation context: an input snapshot, provenance, and supported application metadata. An object is the useful shape for named fields; accepting `JsonValue` does not promise lossless storage of every JSON value. The selected endpoint may sanitize, augment, or rebuild it. Arbitrary metadata is not automatically included in the provider prompt, used as generation parameters, or applied to a later request. Put requirements into the endpoint's explicit inputs, such as `prompt`, `iconDescriptions`, `sceneContent`, style/size options, and its actual reference-media fields.
When an operation persists project resources or library assets, their accepted metadata is saved with those records. Retrieve it through `GET /api/external/v1/editor/projects/{projectId}` (`project.resources[].generationInputs`) or `GET /api/external/v1/editor/assets/library` (`library.assets[].generationInputs`), subject to owner/scopes and record existence. MCP equivalents are `find_assets/get_project_resources` and `find_assets/list_library`. A compact generation result is not a complete metadata read. Metadata is not embedded in the image bytes, and uploading an image does not restore a previous record's metadata.
| Operation | Current handling of `generationInputs` |
| --- | --- |
| Create a project resource or library asset | Preserve accepted metadata after removing client-supplied `references` and internal audit keys. This registers a record; it does not execute a generation recipe. |
| Generate an image | Preserve custom object fields on the provider's original image record after sanitization; rebuild `references` from actual authorized reference inputs. Character transparency processing may create a separate derived record. |
| Generate a scene | Rebuild V2 `version`, `action=scene.generate`, `fields`, and `references` from normalized scene parameters. Only the exact client marker `source=ai-game-creator-client` is retained additionally; arbitrary custom fields such as `artSpec` are discarded. |
| Edit an image | Preserve accepted custom fields and rebuild source/auxiliary `references` from `sourceReferenceId` and the actual reference inputs. |
| Remove a background | Preserve accepted custom fields and rebuild `references` from the actual source. Processing audit metadata remains internal. This metadata does not configure the removal operation. |
| Generate an icon spritesheet or extract UI assets | Preserve accepted custom fields on the provider's original image record. Transparent sheets and slices have separate processing-stage/source metadata and do not automatically inherit all custom fields. Follow the returned source references to read the original context. |
| Generate a character animation | Preserve accepted generation context on the final sequence record; formal frames and sequence duration are separate media fields, not runtime data inside `generationInputs`. |
| Generate a video | Preserve accepted context; object metadata can also receive an added/updated duration display field from normalized request parameters. |
| Generate a sound effect | Rebuild `fields`, empty `references`, and `soundEffect` metadata from the actual generation. Only `source`, `conversationId`, and `toolCallMessageId` are copied from a caller-supplied object; arbitrary fields such as `artSpec` are discarded. |
| Generate background music | Preserve accepted context after sanitization; generation parameters come from the explicit request fields. |
Preservation does not mean every field is inert. Known consumers include:
- The canvas reads `fields` / `references` for input display. Recognized V2 `version`, `action`, and stable field/reference IDs support restoring supported generation panels; arbitrary metadata does not guarantee a UI display or a “modify” action.
- Icon spritesheet generation reads the saved reference spec's `fields` entry titled `游戏类型` to select genre-specific prompt text. This does not cause arbitrary fields in the current request to be interpreted as prompts.
- Integrated clients use recognized `source` markers for queue/idempotency namespaces and result projections. These are application markers, not authentication or model instructions.
Reference metadata does not grant access or select reference media. Image operations rebuild it from actual inputs and authorized records; direct resource/asset creation drops caller-supplied references. Supply the documented `referenceId`, `sourceReferenceId`, or media-reference fields. Do not assume other operations provide the same provenance rebuilding.
Persisted metadata is bounded to 64 KiB of serialized JSON and cannot contain inline media Data URLs. Top-level internal audit fields `screenColorHex`, `mattingProvider`, and `mattingModel` are stripped from client metadata and owner-facing reads; the server can store its own internal audit values. When `assetKind=character-animation`, legacy runtime keys such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds` are rejected; use `imageSequenceFrames` and `imageSequenceDurationMs` for formal sequence data. Omitting metadata or sending null does not replace required request parameters; empty objects may normalize to null.
For reuse, read the saved record, recover the relevant requirements, and explicitly construct the next request. Keep the original complete request and idempotency key for retries: stored metadata, especially derived-asset metadata, is not a complete replayable HTTP payload.
## Art Spec and Image Request
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
`generationInputs.artSpec` is an optional caller-defined metadata convention with no automatic prompt or parameter effect. In the generic image request below, the prompt repeats the desired style, palette, composition, and exclusions, while `aspectRatio` and `imageSize` set the actual format. The saved spec can help a caller construct later requests. This example uses both canvas and library destinations; neither the spec nor both destinations are required for every generation. Do not copy this metadata expectation to the scene route, which rebuilds its own context.
```json
{
"prompt": "一张横版幻想森林背景,适合游戏主视觉,无文字",
"prompt": "一张横版幻想森林背景,适合游戏主视觉,手绘游戏概念图风格,翡翠绿与金色光斑,中心留出角色站位,无文字、无 UI 按钮",
"aspectRatio": "16:9",
"imageSize": "1K",
"projectId": "<projectId>",
@@ -11,18 +11,17 @@
"agc_update_plan.description": "更新当前回合的进度计划,字段与 update_plan 相同:可选 explanation,以及 plan 中的 step/status(pending、in_progress、completed)。它可与其它独立工具并行;同一计划的连续更新按依赖顺序提交。计划完成只表示进度,不代替宿主交付验收。",
"agc_write_file.parameters.path": "当前项目根下的相对路径,例如 game/index.html、assets/manifest.json 或 data/gameplay-spec.md",
"agc_write_file.parameters.content": "仅填写目标文件的完整原始 UTF-8 正文",
"taonier_prepare_game_art.description": "创建或恢复当前 AGC 项目的陶泥儿标准游戏美术包。默认复用有效美术包;根据当前对话需要选择 regenerate 重新生成。授权使用 AGC 客户端当前登录会话;遇到 401/403 时报告客户端登录或权限状态异常并停止。",
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求",
"taonier_prepare_game_art.description": "创建或恢复当前 AGC 项目的陶泥儿标准游戏美术包,返回总图、实际切片与路径标注预览;需看图识别用途,内容要求不保证切片数量或顺序。默认复用有效美术包;根据当前对话需要选择 regenerate 重新生成。授权使用 AGC 客户端当前登录会话;遇到 401/403 时报告客户端登录或权限状态异常并停止。",
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求,不超过 200 字符。写明主题、风格、玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效等具体需要的素材。需求明确时列出各项素材的数量、状态,并要求独立排布、留出切分间距;数量未确定时不编造。工具会补齐沿用规范图和素材独立排布的通用要求;数量是生成目标,内容类别和数量均不保证实际切片数量或返回顺序,须看图确认用途",
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
"agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。",
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令",
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,需求明确则列出各项素材的数量、状态,并要求独立排布、留出切分间距;数量未确定时不编造。数量是生成目标,不保证实际切片数量或返回顺序,须看图确认用途。去除首尾空白后的图集描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令",
"agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图",
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
"agc_generate_image.parameters.sliceMode": "仅适用于 kind=icon-spritesheet,且必填:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入需求中的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。",
"agc_generate_image.parameters.sliceMode": "仅适用于 kind=icon-spritesheet,且必填:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入需求中的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,实际切片数量由图像决定,内容需求不保证数量或用途顺序;查看返回图片后识别用途。",
"agc_generate_image.parameters.gridX": "grid 模式横向网格数量,只能与 sliceMode=grid 同时提供",
"agc_generate_image.parameters.gridY": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供",
"agc_generate_image.parameters.sliceCount": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别",
"agc_generate_image.parameters.screenColor": "抠图纯色背景,仅用于 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值为 auto 或下列色板 hex 之一,传值只填 hex 本身:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时选择与主体颜色明显不同的背景色",
"agc_edit_image.description": "修改一张已登记图片:换装、改色、换背景或局部重绘。sourceLocalAssetId 必须使用 agc_list_registered_assets 返回的当前项目图片 localAssetId。",
"agc_edit_image.parameters.sourceLocalAssetId": "当前项目已登记的图片 localAssetId",
@@ -6,7 +6,7 @@
"playtest.tetris": "完成合同要求 tetris-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level,以及 gameplay={kind:'tetris',activePieceId,rotation,row,lockedPieces,lineClearChecks,clearedLines,occupiedCells};gameplay 可包含额外 telemetry 字段,但这些字段不能替代固定必填字段。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。primary-action 必须同步旋转同一 activePieceId,不能只推进 sequence、替换活动方块或更新装饰状态;start 后 2 秒观察内必须出现同一 activePieceId 的 row 下落或真实锁定;primary-action 后 3 秒内必须真实锁定方块,锁定后 activePieceId 必须变化、lockedPieces 恰好增加 1、lineClearChecks 推进,且 occupiedCells 与 clearedLines 必须体现新增方块或实际消行,不能只增加计数;restart 后 occupiedCells、lockedPieces、lineClearChecks 与 clearedLines 必须全部归零。初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并至少持续 2 秒保持 playing。primary-action 必须同步推进 sequence 与 rotation;动作后 phase 可为 playing、won 或 lost,若保持 playing 则继续观察最多 3 秒以取得锁定和消行检查证据。restart 后必须推进 sequence、恢复 ready 或 playing,并持续 3 秒稳定观察。全部观察期间 sequence 不得回退;若首轮进入 lost,必须按 generic-v1 的重开重试合同证明第二次能进入 won 或保持 playing,不能固定失败。",
"playtest.laneDefense": "完成合同要求 lane-defense-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level、selectedDefenderId、defenders 数组、enemies 数组;每个 enemy 必须含非空 id、非负 lane、会随移动变化的 position、health 与正数 maxHealth。界面必须清晰显示一个原创项目标题、至少两个原创防御单位选项、资源与波次状态,以及开始、加速、下一关和重开等可理解操作;玩法类型不授权复刻现有游戏,不得沿用、翻译或近似改写现有作品的角色、单位名、Logo、贴图、标志性布局或受保护视觉语言。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"defender-option\"、data-playtest-id=\"lane-cell\"、data-playtest-id=\"speed-up\"、data-playtest-id=\"next-level\"、data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。防御单位多选项 UI 只能给一个真实控件设置 data-playtest-id=\"defender-option\" 作为自动化入口,关卡多格 UI 只能给一个真实控件设置 data-playtest-id=\"lane-cell\" 作为自动化入口,其余选项和格子不得复用这两个固定值。受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。",
"owner.visualUsage": "本轮必须实际接入已登记的平台美术切片:先用 asset.list 读取 assets/art-spritesheet-slices/manifest.json,再在 game/index.html 的可见 canvas 主循环中为 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四个切片分别创建 Image 并用相对路径加载;在 requestAnimationFrame 绘制中对每个已加载切片调用 ctx.drawImage(image, dx, dy, dw, dh) 或九参数裁剪形式,目标区域必须可见且至少 32×32。只放置 <img>/<picture>、只展示整张 assets/art-spritesheet.png、只写路径或只在注释中引用都不满足完成合同。",
"owner.visualRequirement": "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。以实际声明资源的登记状态作为验收依据。",
"owner.visualRequirement": "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;图集按实际产物数量返回,查看图片识别用途。以实际声明资源的登记状态作为验收依据。",
"owner.verifyCodePrototype": "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
"owner.verifyArtifact": "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
"owner.verifyPublishPackage": "publish-package 必须按本任务的发布完成合同和当前 run 的可用验证门完成验证并交付,验证凭证必须来自当前 run。",
@@ -15,8 +15,8 @@
"scene_constraints": "必须是可见的真实图片产物,适合作为 Canvas 或 HTML 游戏场景底图;不得做成素材图集、完整游戏截图、海报或概念板;必须原创,不得复刻现有游戏场景、Logo、贴图、标志性布局或受保护视觉语言",
"spritesheet_subject": "{};使用项目原创命名和原创阵营设计",
"spritesheet_style": "清晰可切分的原创 Web 游戏素材图集;严格沿用当前规范图的轮廓、材质、色板与光照,素材类别以当前玩法合同为准",
"spritesheet_composition": "{} 游戏素材图集,按玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效分区,留出清楚切分间距",
"spritesheet_constraints": "生成可直接用于游戏的真实透明图片,素材仅包含当前项目玩法合同需要的实体和反馈;角色轮廓、图标排布与配色采用项目原创设计",
"spritesheet_composition": "{} 游戏素材图集,覆盖当前玩法需要的玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效;每类可含多个素材,各素材独立排布并留出清楚切分间距",
"spritesheet_constraints": "素材仅包含当前项目玩法合同需要的实体和反馈,可独立用于游戏;角色轮廓、图标排布与配色采用项目原创设计",
"image_generation": "根据用户需求生成一张全新的原创图片。主体、环境、风格、构图、光线和色彩以用户描述为准;不要生成素材图集、规范展板、完整游戏截图或文字说明。不得修改或复述为已有图片编辑。\n\n用户需求:{}",
"character_generation": "根据用户需求生成一张全新的原创角色形象或人物立绘。清楚表现角色外貌、服饰、姿势、表情、画风、构图和背景;只生成一张完整图片,不要生成图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}",
"publication_generation": "根据用户需求生成一张全新的原创游戏发布宣传图。突出主体、卖点、氛围、构图、色彩和适合发布展示的画面层次;只生成一张完整图片,不要生成素材图集、规范展板、完整游戏截图或文字说明。不得复刻现有作品角色或 Logo。\n\n用户需求:{}",
@@ -36,7 +36,7 @@
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,实际数量由图像内容决定,不按返回顺序推断用途;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;仅提交 code,客户端负责绑定项目与编辑器进程。",
"unity.editor.execute.description": "在当前 Unity 项目已打开的编辑器中执行 C#。仅提交 code;结果待核对时禁止自动重发。",
"godot.editor.execute.description": "在当前 Godot 项目已打开的编辑器中执行支持 return/await 的 GDScript 函数体。仅提交 code;结果待核对时禁止自动重发。",
@@ -53,7 +53,6 @@
"canvas.asset_generate.parameters.sliceMode": "仅 assetKind=icon-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components",
"canvas.asset_generate.parameters.gridX": "只与 sliceMode=grid 同时提供",
"canvas.asset_generate.parameters.gridY": "只与 sliceMode=grid 同时提供",
"canvas.asset_generate.parameters.sliceCount": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数",
"agent.delegate.parameters.acceptanceCriteria": "初次委派必填。带 repairOfDelegationId 的返工或澄清续跑传 null,由 Runtime 从原 delivery 继承权威合同。",
"agent.delegate.parameters.expectedArtifacts": "与 acceptanceCriteria 同进同出:初次委派必填,返工与澄清续跑一起传 null 由 Runtime 继承。"
}
@@ -42,7 +42,7 @@
"art_director_without_editor": "{prompt}\n\n你负责确定原创视觉方向。这是只读协调任务:本轮验收以正式视觉方向结论为准,只完成正式 director 结论并直接交付,不修改项目文件,不调用 canvas.asset_generate。",
"art_director_with_editor": "{prompt}\n\n你负责确定原创视觉方向。根据项目实际需要决定是否生成图片、数量,并选择 canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词。需要参考图时使用已登记资源 ID,生成后核对返回资源、权限、计费和登记状态。",
"art_asset_plan_without_editor": "{prompt}\n\n你负责首版美术资产清单交付。本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate。完成清单后直接交付,由 Runtime 验证本人正式产物;不得调用 project.verify、game.static_smoke 或 preview.validate,也不得编辑 game/index.html。",
"art_asset_plan_with_editor": "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定。spritesheet 可通过 sliceCount 指定切片数量,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。",
"art_asset_plan_with_editor": "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定。spritesheet 按实际图像内容返回切片,需看图识别用途,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。",
"system_header": "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。你必须直接调用当前请求广告的原生函数:只在复杂任务首次拆解、实际进度变化、steer 调整顺序或最终收束时调用 update_agent_plan,并提交 explanation 与完整 steps。steps 只允许 pending、in_progress、completed 且同时最多一个 in_progress;已完成步骤必须保留 completed 状态,所有必要步骤 completed 后才能调用 respond_to_user。只能请求以下 Runtime 当前注册的原生可执行工具:{tool_catalog}。",
"completion_blocker_protocol": "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且通过完成门禁后,才能调用 respond_to_user 给出最终回复。",
"command_exec_linux": "command.exec 使用 {\"program\":\"受信任 PATH 中的裸可执行名\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120};Linux 命令运行在受限沙箱内,允许 bash -lc、管道、重定向和项目脚本,但不接受环境变量、宿主 executable 路径、mount 或网络策略输入",
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.74",
"version": "2026-08-26.75",
"skills": [
{
"name": "agc-unity-editor",
@@ -101,7 +101,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
"sha256": "dcf243784b7279fc4819140d746e607bde840a53bc419172d615c53364a2ecb4"
"sha256": "c0e4dc4ff9d23ed0c4832f9dc5e72926db2aee31318d807d2c35a0fd4e38f140"
},
{
"name": "agc-web-game-development",
@@ -16,7 +16,7 @@ Use
`agc_generate_image` for a single ordinary image, character image, visual-spec
image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices.
the complete game-art package and its actual slices.
With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"`
produce transparent-background results; write the prompt for the subject
@@ -29,12 +29,27 @@ required; choose it from the brief:
or brief actually names equal grid cells, fixed slots, or a concrete
column/row count; those dimensions must come from that requirement.
- Use `sliceMode="connected-components"` for free-form sheets, an open number of
subjects, or a request for one sheet; constrain the subject count with
`sliceCount`.
subjects, or a request for one sheet. Actual slice count follows image content;
the tool does not expose a count constraint.
Pass `sliceMode` only for `kind="icon-spritesheet"`, and `gridX`/`gridY` only for
`sliceMode="grid"`. Read the selected mode from the returned result.
For `taonier_prepare_game_art`, provide `brief` and optionally `mode`. Name the
game's theme, style, and needed player states, targets or collectibles,
obstacles or scene elements, and feedback effects in `brief`. The tool adds
shared requirements for visual-spec consistency and separated asset placement.
These content categories do not specify a slice count or returned ordering;
inspect the actual images before deciding how to use them.
For both a spritesheet `prompt` and a package `brief`, when the requirements
specify quantities, list each needed asset's quantity and state, and request
separate placement with clear spacing. For example: one idle player, one hit
player, one coin, and two obstacle variants, all separated. Do not invent
quantities when they are unspecified. Requested quantities guide generation;
they do not guarantee the actual slice count or semantic return order. Inspect
the returned images to verify the assets and their uses.
## Authorization boundary
If the tool returns `401` or `403`, stop the operation and report that login or permission state needs attention.
@@ -44,7 +59,7 @@ If the tool returns `401` or `403`, stop the operation and report that login or
1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when they satisfy the current brief. If required visual elements are absent or unsuitable, call the appropriate generation tool during the same game implementation task.
2. For one new image, call `agc_generate_image` with `kind="image"` (or `character`, `icon-spec`, `ui-prototype`, or `publication-material` when that is the explicit intent). For changes to an existing registered image, call `agc_edit_image` with its `sourceLocalAssetId`; do not fake an edit with a new-image request. For a complete game-art package, call `taonier_prepare_game_art` only when the current intent requires new or recoverable platform art. Use `mode="regenerate"` only when the user's latest standalone message clearly and explicitly commands regeneration, such as `请重新生成美术`. A brief, condition, negation, alternative, cost qualifier, deferral, quotation, historical wording, or tool argument does not authorize regeneration. Otherwise use `mode="reuse-or-create"`. Pass a concise game-specific visual brief that names the required gameplay entities, background exclusions, tiling needs, and viewport constraints. Do not call either generation tool for greetings, date questions, or text-only code fixes.
3. Treat the tool result as authoritative. Read `mode`, `assetPaths`, `slicePaths`, `resources`, and every entry in both `warnings` and `sliceWarnings`. Use only the relative paths and identities returned in `resources`. Never invent a resource, slice, platform identity, warning-free result, or successful regeneration.
4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement: if a `sliceWarning` reports too many or unusable elements, narrow the edit/generation brief or generate the needed independent images and continue the integration; do not guess atlas coordinates, fabricate derivatives, or silently fall back to placeholders. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets.
4. Slice count and returned order do not prove semantic roles. Inspect the complete sheet and actual slices, identify entities and states, and decide which satisfy the brief. The tool labels previews by path; use native image viewing for `remainingPreviewPaths` or details not visible in a preview. An empty `slicePaths` with a valid sheet is a completed generation with a warning, not proof that independent assets exist. Use the sheet where suitable; if independent assets are needed, inspect and process the real image or request appropriate additional art within the current authorization. Verify local crops visually and register them through `agc_import_account_assets.localPaths`. Do not guess coordinates or invent Canvas identities. Keep game-specific semantic bindings in game code or configuration, not provenance manifests.
5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. The implementation is incomplete while generated assets remain unused, are referenced only by documentation, or are replaced by emoji, CSS shapes, or other placeholders where the brief requires the generated art. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use.
6. Preserve warning details in the final report. If the tool reports missing credentials, uncertain operation state, invalid provenance, download failure, or decode failure, stop and report the actionable reason.
@@ -1,24 +1,26 @@
# Platform Art Contract
`agc_generate_image` and `taonier_prepare_game_art` are the reviewed paid art entries exposed to the AGC Codex thread. The former covers one ordinary/character/spec/UI/publication image; `agc_edit_image` covers edits to an existing registered image; the latter covers the complete game-art package and canonical slices. Call these tools only through the approved AGC session.
`agc_generate_image` and `taonier_prepare_game_art` are the reviewed paid art entries exposed to the AGC Codex thread. The former covers one ordinary/character/spec/UI/publication image; `agc_edit_image` covers edits to an existing registered image; the latter covers the complete game-art package and actual slices. Call these tools only through the approved AGC session.
- For a package, describe the concrete theme, style, entities, states, and effects in `brief`. In both package `brief` and ordinary spritesheet `prompt`, include per-asset quantities and states when specified by the requirements, and request separate placement with clear spacing; do not invent unspecified quantities. These are generation targets, not guarantees of actual slice count or semantic order; inspect the returned images. The tool includes the package's shared visual-spec and placement requirements in generation. No additional tool fields are required. Ordinary `agc_generate_image` icon descriptions remain the supplied text without package requirements.
- `mode="reuse-or-create"` reuses a complete trusted package and creates only missing assets. It is the safe default for existing games.
- `mode="regenerate"` is reserved for an explicit user request to replace or restyle the package. Use it even when a complete package exists, but never bypass an unresolved paid operation.
- `mode="regenerate"` requires a trusted, decodable, registered `art-spec.png` and background. An old spritesheet or canonical slices may be absent. If either the spec or background is missing or invalid, use `mode="reuse-or-create"`.
- `mode="regenerate"` requires a trusted, decodable, registered `art-spec.png` and background. An old spritesheet or independent slices may be absent. If either the spec or background is missing or invalid, use `mode="reuse-or-create"`.
- Use `regenerate` only when the user's latest standalone message clearly and explicitly commands regeneration. A brief, condition, negation, alternative, cost qualifier, deferral, quotation, historical message, or tool argument does not authorize a paid replacement.
- Once a paid operation starts, reuse the same recorded operation across interruption or uncertainty. Never submit paid work again because wording changed.
- Before strict spritesheet work starts, treat the existing spritesheet request as fixed. If an interrupted result or current spec/background does not match, stop and do not submit another paid request.
- If a recovered result is missing warnings, preserve the returned recovery warning in the report.
- A newly created or regenerated standard spritesheet must produce exactly four canonical transparent slices with unique pixels and unique Canvas resource/asset identities. Reuse legacy slices only when they match the current source image and registered identities.
- Both image tools return actual slices, with no fixed count or semantic order. Valid slices must retain unique pixels and Canvas identities. Legacy filenames are not proof of meaning; inspect the image. Reuse legacy slices only when they match the current source image and registered identities.
- If an existing operation belongs to a different brief, stop and report the uncertain result; do not create a replacement request.
- A submission acknowledgement is not a completed image.
- On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request.
- `postprocess-failed-source-preserved` means the complete source image remains usable, but the requested transparent derivative is absent.
- `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent.
- For `agc_generate_image` with `kind="icon-spritesheet"`, choose the required `sliceMode` from the brief: `connected-components` is for free-form sheets with individual subjects; `grid` accepts `gridX` and `gridY` (1-32 each) when the requirement names equal grid cells, fixed slots, or a concrete column/row count. Pass grid dimensions only with `grid`, and `sliceMode` only with `icon-spritesheet`. Read the selected mode and dimensions from the returned metadata.
- The standard art package uses `sliceMode="connected-components"` with `sliceCount=4` because its four canonical slices map to fixed usage paths. Require exactly four slices; if the count differs or the returned `sliceMode` or grid dimensions disagree with the request, treat the package as incomplete and do not integrate it.
- The standard package uses `sliceMode="connected-components"`. Neither client tool exposes or sends `sliceCount`; content categories are generation requirements, not strict component counts. A valid sheet with no slices is returned with a warning. Mode and grid declarations still must agree with the request.
- General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim.
- `assetPaths` contains the complete package paths. Use only slices in `slicePaths` as independent assets.
- `assetPaths` contains the complete package paths; `slicePaths` lists actual independent files. Every embedded preview is labeled by path; inspect `remainingPreviewPaths` with native image viewing. Both tools include all returned slices in `resources`. After inspecting and verifying a local crop, register it with `agc_import_account_assets.localPaths` before using its registered identity.
- Use only the safe registered identities and paths returned in `resources`; never expose or infer prompts, models, upstream routes, absolute paths, URLs, tokens, cookies, or API keys.
- A trusted complete image may be used without fixed slices. Never fabricate missing derivatives.
- `art-spec.png` constrains generation and is never evidence that runtime gameplay art was integrated.
@@ -3,26 +3,57 @@ use super::*;
const ART_SPRITESHEET_PATH: &str = "assets/art-spritesheet.png";
const ART_SLICE_MANIFEST_PATH: &str = "assets/art-spritesheet-slices/manifest.json";
const ART_CONTRACT_RECEIPT_PATH: &str = ".agent/runtime/art-spritesheet-contract.json";
const REQUIRED_SLICE_USAGES: [&str; 4] = [
"player",
"blocks-and-targets",
"obstacles-and-scene",
"feedback-effects",
/// 历史文件仅用于兼容读取和恢复,不表示已经识别过用途。
pub(in crate::agent) const LEGACY_ART_SLICE_PATHS: [&str; 4] = [
"assets/art-spritesheet-slices/player.png",
"assets/art-spritesheet-slices/blocks-and-targets.png",
"assets/art-spritesheet-slices/obstacles-and-scene.png",
"assets/art-spritesheet-slices/feedback-effects.png",
];
pub(in crate::agent) fn art_slice_directory(source_resource_id: &str) -> String {
format!(
"assets/art-spritesheet-slices/{:x}",
Sha256::digest(source_resource_id.as_bytes())
)
}
pub(in crate::agent) fn art_slice_path(source_resource_id: &str, index: usize) -> String {
format!(
"{}/{:03}.png",
art_slice_directory(source_resource_id),
index + 1
)
}
pub(in crate::agent) fn is_managed_art_slice_path(path: &str) -> bool {
if LEGACY_ART_SLICE_PATHS.contains(&path) {
return true;
}
let Some(tail) = path.strip_prefix("assets/art-spritesheet-slices/") else {
return false;
};
let Some((key, name)) = tail.split_once('/') else {
return false;
};
key.len() == 64
&& key
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& name.strip_suffix(".png").is_some_and(|n| {
n.len() == 3
&& n.bytes().all(|b| b.is_ascii_digit())
&& n.parse::<usize>().is_ok_and(|n| (1..=256).contains(&n))
})
}
pub(in crate::agent) fn art_manifest_content() -> String {
serde_json::json!({
"schemaVersion": "game-art-manifest.v1",
"schemaVersion": "game-art-manifest.v2",
"status": "generated",
"assets": [{
"path": ART_SPRITESHEET_PATH,
"kind": GameCreationAppAssetKind::IconSpritesheet.as_str(),
"usage": REQUIRED_SLICE_USAGES,
}],
"assets": [{ "path": ART_SPRITESHEET_PATH, "kind": GameCreationAppAssetKind::IconSpritesheet.as_str() }],
"sliceManifest": ART_SLICE_MANIFEST_PATH,
"requiredSliceUsages": REQUIRED_SLICE_USAGES,
})
.to_string()
}).to_string()
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -38,11 +69,12 @@ pub(in crate::agent) fn validated_art_slices(
let file = read_local_project_file_at(root, ART_SLICE_MANIFEST_PATH)?;
let manifest: serde_json::Value = serde_json::from_str(&file.content)
.map_err(|error| format!("图集切片清单不是有效 JSON:{error}"))?;
if manifest
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
!= Some("game-art-slices.v1")
|| manifest.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
if !matches!(
manifest
.get("schemaVersion")
.and_then(serde_json::Value::as_str),
Some("game-art-slices.v1" | "game-art-slices.v2")
) || manifest.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
{
return Err("图集切片清单 schema 或 source 无效".to_string());
}
@@ -83,11 +115,12 @@ pub(in crate::agent) fn validated_art_slices(
}
let receipt: serde_json::Value = serde_json::from_slice(&receipt_bytes)
.map_err(|error| format!("图集私有合同回执不是有效 JSON:{error}"))?;
if receipt
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
!= Some("game-art-spritesheet-contract.v1")
|| receipt.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
if !matches!(
receipt
.get("schemaVersion")
.and_then(serde_json::Value::as_str),
Some("game-art-spritesheet-contract.v1" | "game-art-spritesheet-contract.v2")
) || receipt.get("source").and_then(serde_json::Value::as_str) != Some(ART_SPRITESHEET_PATH)
|| receipt
.get("sliceManifest")
.and_then(serde_json::Value::as_str)
@@ -122,42 +155,63 @@ pub(in crate::agent) fn validated_art_slices(
let slices = manifest
.get("slices")
.and_then(serde_json::Value::as_array)
.filter(|slices| slices.len() == REQUIRED_SLICE_USAGES.len())
.ok_or_else(|| "图集切片清单必须恰好包含四个切片".to_string())?;
.filter(|slices| slices.len() <= 256)
.ok_or_else(|| "图集切片清单数量无效".to_string())?;
let receipt_slices = receipt
.get("slices")
.and_then(serde_json::Value::as_array)
.filter(|slices| slices.len() == REQUIRED_SLICE_USAGES.len())
.ok_or_else(|| "图集私有合同回执必须恰好包含四个切片".to_string())?;
let mut validated_slices = Vec::with_capacity(REQUIRED_SLICE_USAGES.len());
.filter(|items| items.len() == slices.len())
.ok_or_else(|| "图集切片清单与私有回执数量不一致".to_string())?;
let legacy = receipt["schemaVersion"] == "game-art-spritesheet-contract.v1";
if legacy
&& (slices.len() != LEGACY_ART_SLICE_PATHS.len()
|| manifest["schemaVersion"] != "game-art-slices.v1")
{
return Err("旧图集合同集合不完整".to_string());
}
if !legacy && manifest["schemaVersion"] != "game-art-slices.v2" {
return Err("图集清单与回执版本不一致".to_string());
}
let mut validated_slices = Vec::with_capacity(slices.len());
let mut paths = std::collections::HashSet::new();
let mut pixel_sha256s = std::collections::HashSet::new();
let mut resource_ids = std::collections::HashSet::new();
let mut asset_object_ids = std::collections::HashSet::new();
let mut total_bytes = 0usize;
for usage in REQUIRED_SLICE_USAGES {
let slice = slices
.iter()
.find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage))
.ok_or_else(|| format!("图集切片清单缺少 {usage} 素材"))?;
let receipt_slice = receipt_slices
.iter()
.find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage))
.ok_or_else(|| format!("图集私有合同回执缺少 {usage} 素材"))?;
let expected_path = format!("assets/art-spritesheet-slices/{usage}.png");
for (index, slice) in slices.iter().enumerate() {
let path = slice
.get("path")
.and_then(serde_json::Value::as_str)
.filter(|path| *path == expected_path)
.ok_or_else(|| format!("{usage} 切片路径无效"))?;
.filter(|path| {
if legacy {
LEGACY_ART_SLICE_PATHS.contains(path)
} else {
*path
== art_slice_path(
current_asset
.source
.resource_id
.as_deref()
.expect("validated identity"),
index,
)
}
})
.filter(|path| paths.insert(*path))
.ok_or_else(|| "图集切片路径无效或重复".to_string())?;
let receipt_slice = receipt_slices
.iter()
.find(|item| item.get("path").and_then(serde_json::Value::as_str) == Some(path))
.ok_or_else(|| format!("图集私有回执缺少切片:{path}"))?;
let bytes = fs::read(resolve_local_project_path(root, path)?)
.map_err(|error| format!("{usage} 切片无法读取:{error}"))?;
.map_err(|error| format!("{path} 切片无法读取:{error}"))?;
total_bytes = total_bytes
.checked_add(bytes.len())
.ok_or_else(|| "图集切片累计大小溢出".to_string())?;
if bytes.len() > 20 * 1024 * 1024 || total_bytes > 32 * 1024 * 1024 {
return Err("图集切片超过校验大小上限".to_string());
}
let validated = validate_platform_art_png_bytes_with_limits(&bytes, usage)?;
let validated = validate_platform_art_png_bytes_with_limits(&bytes, path)?;
for field in [
"name",
"usage",
@@ -170,24 +224,24 @@ pub(in crate::agent) fn validated_art_slices(
"pixelSha256",
] {
if slice.get(field) != receipt_slice.get(field) {
return Err(format!("{usage} 切片字段 {field} 与私有合同回执不一致"));
return Err(format!("{path} 切片字段 {field} 与私有合同回执不一致"));
}
}
let resource_id = slice
.get("resourceId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| format!("{usage} 切片缺少 resourceId"))?;
.ok_or_else(|| format!("{path} 切片缺少 resourceId"))?;
let asset_object_id = slice
.get("assetObjectId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| format!("{usage} 切片缺少 assetObjectId"))?;
.ok_or_else(|| format!("{path} 切片缺少 assetObjectId"))?;
if !resource_ids.insert(resource_id)
|| !asset_object_ids.insert(asset_object_id)
|| !pixel_sha256s.insert(validated.pixel_sha256.clone())
{
return Err("四类切片存在重复的身份或像素内容".to_string());
return Err("切片存在重复的身份或像素内容".to_string());
}
if slice.get("width").and_then(serde_json::Value::as_u64) != Some(validated.width.into())
|| slice.get("height").and_then(serde_json::Value::as_u64)
@@ -201,7 +255,7 @@ pub(in crate::agent) fn validated_art_slices(
|| !validated.has_visible_pixels
|| !validated.has_transparent_pixels
{
return Err(format!("{usage} 切片内容未通过校验"));
return Err(format!("{path} 切片内容未通过校验"));
}
validated_slices.push(ValidatedArtSlice {
path: path.to_string(),
@@ -1368,6 +1368,80 @@ struct ClaudeCodeStreamState {
assistant_texts: Vec<String>,
result_text: Option<String>,
result_error: Option<String>,
/// SDK `system/init` 里 `mcp_servers` 中 `agc` 的状态;`None` 表示这一版 SDK
/// 没有给出可判定的 MCP 状态(此时不做任何结论)。
///
/// 现场(2026-10-07 19:25,项目 `gameagent-63d3fda7`,安装版 0.1.230):新建项目的
/// 第一个回合里模型回答"我没有任何可调用的工具",而同一版本 19:22 在另一个项目里
/// 正常调用了 `mcp__agc__client_session_info`。宿主要能一眼分清"工具确实没连上"和
/// "模型自己说错了",就必须把 init 的工具清单与 MCP 状态落进应用日志。
init_tool_count: Option<usize>,
mcp_unavailable: Option<String>,
}
/// cc DirectProject 回合里宿主声明的 loopback MCP 服务器名(见 `claude_direct_turn_payload`)。
const CLAUDE_DIRECT_MCP_SERVER_NAME: &str = "agc";
/// MCP 状态里哪些是**明确失败**:只有这些才允许宿主判定"这一轮没有工具"。
///
/// `pending` / `connecting` / 未知状态只写日志不下结论——真按"还不是 connected 就算失败"
/// 处理,一旦 SDK 改成非阻塞连接就会把每一轮都判死。
fn claude_mcp_status_is_failure(status: &str) -> bool {
matches!(status, "failed" | "error" | "unavailable" | "disconnected")
}
/// 解析 SDK `system/init` 的工具清单与 MCP 状态,返回
/// `(可用工具数, agc 状态摘要, agc 不可用原因)`。
///
/// 不可用原因只在**能判定**时给值:这一版 SDK 没有输出 `mcp_servers` 时返回 `None`,不能把
/// "看不到状态"当成"工具没连上"。三种可判定的事实:服务器状态不是 `connected`、init 里根本
/// 没有这个服务器、服务器 `connected` 但工具清单为空。
fn claude_init_mcp_state(event: &serde_json::Value) -> (Option<usize>, String, Option<String>) {
let tool_count = event
.get("tools")
.and_then(serde_json::Value::as_array)
.map(Vec::len);
let Some(servers) = event
.get("mcp_servers")
.or_else(|| event.get("mcpServers"))
.and_then(serde_json::Value::as_array)
else {
return (tool_count, "unknown".to_string(), None);
};
let status = servers.iter().find_map(|server| {
let name = server.get("name").and_then(serde_json::Value::as_str)?;
(name == CLAUDE_DIRECT_MCP_SERVER_NAME).then(|| {
server
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string()
})
});
match status.as_deref() {
Some("connected") => match tool_count {
Some(0) => (
tool_count,
format!("{CLAUDE_DIRECT_MCP_SERVER_NAME}:connected,no-tools"),
Some("no-tools".to_string()),
),
_ => (
tool_count,
format!("{CLAUDE_DIRECT_MCP_SERVER_NAME}:connected"),
None,
),
},
Some(status) => (
tool_count,
format!("{CLAUDE_DIRECT_MCP_SERVER_NAME}:{status}"),
claude_mcp_status_is_failure(status).then(|| status.to_string()),
),
None => (
tool_count,
format!("{CLAUDE_DIRECT_MCP_SERVER_NAME}:missing"),
Some("missing".to_string()),
),
}
}
fn claude_value_text(value: &serde_json::Value) -> String {
@@ -1881,6 +1955,21 @@ impl ClaudeCodeStreamState {
claude_stream_observe(observer, TurnObservation::Activity("controlled-tool"));
}
Some("system") => match event.get("subtype").and_then(serde_json::Value::as_str) {
Some("init") => {
// 工具接线只能在这里被证明:模型回复里说自己"没有工具"既可能是真的没连上,
// 也可能是模型说错(同一版本既出现过两者)。把 init 的事实写进应用日志,
// 让"工具到底有没有下发"变成可查证据,而不是靠模型自述。
let (tool_count, mcp_summary, unavailable) = claude_init_mcp_state(event);
app_log!(
"agent.direct_codex.claude_init tools={} mcp={}",
tool_count
.map(|count| count.to_string())
.unwrap_or_else(|| "unknown".to_string()),
mcp_summary
);
self.init_tool_count = tool_count;
self.mcp_unavailable = unavailable;
}
Some("compact_boundary") => {
claude_stream_observe(
observer,
@@ -2095,8 +2184,9 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
root,
client_turn_id,
&format!(
"等待模型回执超时,正在自动重试(第 {attempt}/{} 次)",
CLAUDE_DIRECT_TURN_MAX_ATTEMPTS
"{},正在自动重试(第 {attempt}/{} 次)",
claude_direct_retry_reason(failure.kind),
CLAUDE_DIRECT_TURN_MAX_ATTEMPTS,
),
"retry",
attempt,
@@ -2117,6 +2207,10 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
"等待模型回执超时,但本轮已经请求过工具,不会自动重放;请手动重试"
.to_string()
}
ClaudeDirectFailureAction::StopMcpUnavailable => {
"客户端 MCP 工具始终没有连上,本轮模型看不到项目工具;请检查项目诊断后重试"
.to_string()
}
_ => "等待模型回执超时,已超过本轮硬上限,不再自动重试;请稍后重试".to_string(),
};
emit_claude_direct_process_notice(root, client_turn_id, &notice, "stop", attempt);
@@ -2142,6 +2236,8 @@ struct ClaudeDirectTurnFailure {
enum ClaudeDirectFailureKind {
/// sidecar 回合超时(静默超时或硬上限)。
Timeout,
/// SDK 明确报告宿主 loopback MCP 没连上:这一轮的模型看不到任何项目工具。
McpUnavailable,
/// 其它失败:sidecar 报错、非零退出、解析失败、启动失败等。
Other,
}
@@ -2168,18 +2264,34 @@ enum ClaudeDirectFailureAction {
StopToolsRequested,
/// 不可重放的超时(例如 45 分钟硬上限)。
StopNotReplayable,
/// 宿主 MCP 工具始终没连上。
StopMcpUnavailable,
/// 非超时失败:原样上报,不改写既有分类。
Report,
}
/// 重试策略是纯函数:什么情况重放、什么情况停下都能被单测逐格钉住。
fn claude_direct_retry_reason(kind: ClaudeDirectFailureKind) -> &'static str {
match kind {
ClaudeDirectFailureKind::McpUnavailable => "客户端 MCP 工具未就绪",
_ => "等待模型回执超时",
}
}
fn claude_direct_failure_action(
failure: &ClaudeDirectTurnFailure,
attempt: usize,
) -> ClaudeDirectFailureAction {
if failure.kind != ClaudeDirectFailureKind::Timeout {
if failure.kind == ClaudeDirectFailureKind::Other {
return ClaudeDirectFailureAction::Report;
}
if failure.kind == ClaudeDirectFailureKind::McpUnavailable {
return if failure.retryable && attempt < CLAUDE_DIRECT_TURN_MAX_ATTEMPTS {
ClaudeDirectFailureAction::Retry
} else {
ClaudeDirectFailureAction::StopMcpUnavailable
};
}
if failure.retryable {
return if attempt < CLAUDE_DIRECT_TURN_MAX_ATTEMPTS {
ClaudeDirectFailureAction::Retry
@@ -2340,6 +2452,9 @@ async fn claude_direct_turn_once(
}
}
direct_turn_trace("claude-parse-start");
// `finish` 会消费 stream_state,工具接线的事实必须在它之前取出来。
let tools_requested = !stream_state.tool_calls.is_empty();
let mcp_unavailable = stream_state.mcp_unavailable.clone();
let parsed = stream_state.finish(root, &sidecar.result, client_turn_id);
match &parsed {
Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())),
@@ -2353,6 +2468,19 @@ async fn claude_direct_turn_once(
direct_turn_trace("claude-parse-error");
}
}
// 工具没连上时模型只能"凭空说话"(现场:安装版 0.1.230 在新建项目里回答"我没有任何
// 可调用的工具")。宁可给一条精确错误并自动重试,也不把这种回复当成成功交付;真调用过
// 工具的一轮说明 MCP 事实上是通的,不在这里翻案。
if let Some(status) = mcp_unavailable.filter(|_| !tools_requested) {
return Err(ClaudeDirectTurnFailure {
message: format!(
"客户端 MCP 工具未就绪({CLAUDE_DIRECT_MCP_SERVER_NAME}={status}):本轮模型看不到项目工具"
),
kind: ClaudeDirectFailureKind::McpUnavailable,
retryable: true,
tools_requested: false,
});
}
parsed.map_err(ClaudeDirectTurnFailure::fatal)
}
@@ -3097,6 +3225,25 @@ mod tests {
#[test]
fn claude_direct_retry_policy_retries_only_safe_timeouts() {
// 宿主 MCP 没连上同样是可重放的(这一轮还没执行过任何操作),但它的说明与超时不同。
let no_mcp = ClaudeDirectTurnFailure {
message: "客户端 MCP 工具未就绪(agc=failed):本轮模型看不到项目工具".to_string(),
kind: ClaudeDirectFailureKind::McpUnavailable,
retryable: true,
tools_requested: false,
};
assert_eq!(
claude_direct_failure_action(&no_mcp, 1),
ClaudeDirectFailureAction::Retry
);
assert_eq!(
claude_direct_failure_action(&no_mcp, CLAUDE_DIRECT_TURN_MAX_ATTEMPTS),
ClaudeDirectFailureAction::StopMcpUnavailable
);
assert_eq!(
claude_direct_retry_reason(ClaudeDirectFailureKind::McpUnavailable),
"客户端 MCP 工具未就绪"
);
let timeout = |retryable, tools_requested| ClaudeDirectTurnFailure {
message: "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".to_string(),
kind: ClaudeDirectFailureKind::Timeout,
@@ -3179,4 +3326,69 @@ mod tests {
other => panic!("超时必须收口成 ModelCallFailed:{other:?}"),
}
}
#[test]
fn claude_init_reports_whether_the_loopback_mcp_tools_are_actually_there() {
// 正常一轮:服务器 connected 且工具清单非空 → 不做任何结论。
let ok = serde_json::json!({
"type": "system",
"subtype": "init",
"tools": ["mcp__agc__agc_write_file"],
"mcp_servers": [{ "name": "agc", "status": "connected", "source": "dynamic" }],
});
assert_eq!(
claude_init_mcp_state(&ok),
(Some(1), "agc:connected".to_string(), None)
);
// 连接失败 / 服务器缺失 / connected 却没有工具:三种都要能被判定出来,
// 否则模型回一句"我没有任何可调用的工具"就没人能证明到底是谁的问题。
let failed = serde_json::json!({
"tools": [],
"mcp_servers": [{ "name": "agc", "status": "failed" }],
});
assert_eq!(
claude_init_mcp_state(&failed),
(
Some(0),
"agc:failed".to_string(),
Some("failed".to_string())
)
);
let missing = serde_json::json!({ "tools": [], "mcp_servers": [] });
assert_eq!(
claude_init_mcp_state(&missing),
(
Some(0),
"agc:missing".to_string(),
Some("missing".to_string())
)
);
let no_tools = serde_json::json!({
"tools": [],
"mcp_servers": [{ "name": "agc", "status": "connected" }],
});
assert_eq!(
claude_init_mcp_state(&no_tools),
(
Some(0),
"agc:connected,no-tools".to_string(),
Some("no-tools".to_string())
)
);
// 这一版 SDK 不给 mcp_servers 时不能凭空判定:看不到状态 != 工具没连上。
let unknown = serde_json::json!({ "tools": ["mcp__agc__x"] });
assert_eq!(
claude_init_mcp_state(&unknown),
(Some(1), "unknown".to_string(), None)
);
// 还在连接中的状态只留证据、不下结论:否则 SDK 改成非阻塞连接会把每一轮都判死。
let pending = serde_json::json!({
"tools": [],
"mcp_servers": [{ "name": "agc", "status": "pending" }],
});
assert_eq!(
claude_init_mcp_state(&pending),
(Some(0), "agc:pending".to_string(), None)
);
}
}
File diff suppressed because it is too large Load Diff
@@ -177,7 +177,8 @@ impl DirectToolImageRequests {
options: &PlatformArtAssetGenerationOptions,
) -> Result<DirectToolImageRequestGuard, String> {
// 排队前取得原有跨桥/跨进程 durable 槽;随后交给生成器,不能重取或放宽幂等锁。
let admission = super::generation::admit_platform_art_generation_at(root, prompt, options)?;
let admission =
super::generation::admit_platform_art_generation_at(root, prompt, &[], options)?;
let capacity = super::direct_paid_submission::wait_before_dispatch(
Arc::clone(&self.capacity).acquire_owned(),
)
@@ -2639,17 +2640,41 @@ fn bridge_art_resources(
slice_paths: &[String],
) -> Result<Vec<Value>, String> {
let manifest = read_manifest_for_project(root)?;
Ok(asset_paths
asset_paths
.iter()
.chain(slice_paths.iter())
.filter_map(|path| {
.map(|path| {
manifest
.assets
.iter()
.find(|asset| asset.local_path == *path)
.and_then(bridge_art_resource)
.ok_or_else(|| format!("素材缺少可返回的登记身份:{path}"))
})
.collect())
.collect()
}
/// 有界预览与路径逐项绑定;未内嵌的文件仍通过完整路径集合交给 Agent 查看。
fn bridge_art_tool_result(root: &Path, mut payload: Value, paths: &[String]) -> Value {
let mut previews = Vec::new();
let mut remaining = Vec::new();
for path in paths {
if previews.len() < 7 {
if let Ok(data) = bridge_png_content(root, &root.join(path)) {
previews.push((path.clone(), data));
continue;
}
}
remaining.push(path.clone());
}
payload["previewPaths"] = json!(previews.iter().map(|(p, _)| p).collect::<Vec<_>>());
payload["remainingPreviewPaths"] = json!(remaining);
let mut content = vec![json!({"type":"text", "text":payload.to_string()})];
for (path, data) in previews {
content.push(json!({"type":"text", "text":format!("图片预览:{path}")}));
content.push(bridge_tool_image_block(data));
}
json!({"content":content})
}
async fn bridge_prepare_game_art_validated(
@@ -2664,14 +2689,14 @@ async fn bridge_prepare_game_art_validated(
.map_err(|cause| PrepareGameArtError::PackageGenerationFailed { cause })?;
let resources = bridge_art_resources(root, &package.asset_paths, &package.slice_paths)
.map_err(|cause| PrepareGameArtError::ArtResourcesUnreadable { cause })?;
let images = package
let paths = package
.asset_paths
.iter()
.chain(package.slice_paths.iter())
.take(7)
.filter_map(|relative| bridge_png_content(root, &root.join(relative)).ok())
.cloned()
.collect::<Vec<_>>();
Ok(bridge_tool_result(
Ok(bridge_art_tool_result(
root,
json!({
"status": "completed",
"mode": mode.as_str(),
@@ -2680,11 +2705,10 @@ async fn bridge_prepare_game_art_validated(
"warnings": bounded_remote_warnings(&package.warnings),
"sliceWarnings": bounded_remote_warnings(&package.slice_warnings),
"resources": resources,
"derivativePolicy": "新建或重生成的标准图集必须包含四张 canonical 切片;旧可信图集缺少切片时只能按 warning 使用完整图集,不得伪造衍生素材",
"next": "读取这些相对路径并把合适素材接入核心游戏画面"
})
.to_string(),
images,
"derivativePolicy": "内容要求不保证切片数量或用途顺序;查看总图及切片后识别用途。零切片时保留总图与告警;真实本地处理产物通过 agc_import_account_assets.localPaths 登记,不能伪造平台身份",
"next": "查看预览及 remainingPreviewPaths 的图片,确认实体、状态和缺失内容后再接入游戏"
}),
&paths,
))
}
@@ -2808,8 +2832,6 @@ pub(in crate::agent) const GENERATE_IMAGE_IMAGE_SIZES: &[&str] = &["0.5K", "1K",
pub(in crate::agent) const GENERATE_IMAGE_SLICE_MODES: &[&str] = &["connected-components", "grid"];
pub(in crate::agent) const GENERATE_IMAGE_GRID_AXIS_MIN: u32 = 1;
pub(in crate::agent) const GENERATE_IMAGE_GRID_AXIS_MAX: u32 = 32;
pub(in crate::agent) const GENERATE_IMAGE_SLICE_COUNT_MIN: u64 = 1;
pub(in crate::agent) const GENERATE_IMAGE_SLICE_COUNT_MAX: u64 = 256;
/// `agc_generate_image` 允许的入参字段。
const GENERATE_IMAGE_ARGUMENTS: &[&str] = &[
@@ -2822,7 +2844,6 @@ const GENERATE_IMAGE_ARGUMENTS: &[&str] = &[
"sliceMode",
"gridX",
"gridY",
"sliceCount",
"screenColor",
];
@@ -2963,18 +2984,14 @@ fn validate_generate_image_slice_declaration(
slice_mode: Option<&str>,
grid_x: Option<u32>,
grid_y: Option<u32>,
slice_count: Option<usize>,
) -> Result<(), GenerateImageError> {
if kind == GameCreationAppAssetKind::IconSpritesheet {
if slice_mode.is_none() {
return Err(GenerateImageError::SpritesheetSliceModeMissing);
}
if slice_mode == Some("grid") && slice_count.is_some() {
return Err(GenerateImageError::SpritesheetSliceCountNotAllowed);
}
return Ok(());
}
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() {
return Err(GenerateImageError::SliceDeclarationNotForKind { kind });
}
Ok(())
@@ -3032,7 +3049,6 @@ pub(in crate::agent) struct GenerateImageInput {
pub(in crate::agent) slice_mode: Option<String>,
pub(in crate::agent) grid_x: Option<u32>,
pub(in crate::agent) grid_y: Option<u32>,
pub(in crate::agent) slice_count: Option<usize>,
pub(in crate::agent) screen_color: Option<String>,
}
@@ -3079,29 +3095,7 @@ pub(in crate::agent) fn generate_image_input(
return Err(GenerateImageError::GridOutOfRange { axis, got });
}
}
let slice_count = arguments
.get("sliceCount")
.filter(|value| !value.is_null())
.map(|value| {
value
.as_u64()
.filter(|count| {
(GENERATE_IMAGE_SLICE_COUNT_MIN..=GENERATE_IMAGE_SLICE_COUNT_MAX)
.contains(count)
})
.map(|count| count as usize)
.ok_or_else(|| GenerateImageError::SliceCountInvalid {
got: not_text_repr(value),
})
})
.transpose()?;
validate_generate_image_slice_declaration(
kind,
slice_mode.as_deref(),
grid_x,
grid_y,
slice_count,
)?;
validate_generate_image_slice_declaration(kind, slice_mode.as_deref(), grid_x, grid_y)?;
let screen_color = normalize_generate_image_screen_color(arguments, kind)?;
Ok(GenerateImageInput {
prompt,
@@ -3113,7 +3107,6 @@ pub(in crate::agent) fn generate_image_input(
slice_mode,
grid_x,
grid_y,
slice_count,
screen_color,
})
}
@@ -3135,7 +3128,6 @@ async fn bridge_generate_image(
slice_mode,
grid_x,
grid_y,
slice_count,
screen_color,
} = generate_image_input(arguments)?;
let options = PlatformArtAssetGenerationOptions {
@@ -3145,7 +3137,6 @@ async fn bridge_generate_image(
asset_kind: kind,
asset_label: asset_name.clone(),
replace_existing: false,
slice_count,
slice_mode,
grid_x,
grid_y,
@@ -3165,6 +3156,7 @@ async fn bridge_generate_image(
super::generation::generate_admitted_platform_art_asset_at(
&state.root,
&prompt,
&[],
&options,
admission,
),
@@ -3172,17 +3164,22 @@ async fn bridge_generate_image(
.await
.map_err(|message| GenerateImageError::PlatformArtRequestFailed { message })?;
emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art");
let slice_paths = generated
.slices
.iter()
.map(|slice| slice.local_path.clone())
.collect::<Vec<_>>();
let resources = bridge_art_resources(
&state.root,
std::slice::from_ref(&generated.asset.local_path),
&[],
&slice_paths,
)
.map_err(|message| GenerateImageError::ArtResourcesUnreadable { message })?;
let images = bridge_png_content(&state.root, &state.root.join(&generated.asset.local_path))
.ok()
.into_iter()
let paths = std::iter::once(generated.asset.local_path.clone())
.chain(slice_paths.iter().cloned())
.collect::<Vec<_>>();
Ok(bridge_tool_result(
Ok(bridge_art_tool_result(
&state.root,
json!({
"status": "completed",
"kind": kind,
@@ -3206,14 +3203,10 @@ async fn bridge_generate_image(
"sliceMode": generated.slice_mode,
"gridX": generated.grid_x,
"gridY": generated.grid_y,
"slicePaths": generated
.slices
.iter()
.map(|slice| slice.local_path.clone())
.collect::<Vec<_>>(),
})
.to_string(),
images,
"slicePaths": slice_paths,
"next": "查看总图和实际切片,使用图片查看能力检查 remainingPreviewPaths;按内容识别用途,不按返回顺序或文件名赋义。",
}),
&paths,
))
}
@@ -4705,79 +4698,44 @@ mod tests {
}
#[test]
fn generate_image_slice_declaration_is_explicit_and_self_consistent() {
let missing = validate_generate_image_slice_declaration(
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
None,
None,
None,
None,
None
)
.expect_err("icon-spritesheet without sliceMode must fail closed")
.to_user_msg();
assert!(missing.contains("没有默认值"), "{missing}");
assert!(missing.contains("connected-components"), "{missing}");
.is_err());
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
Some("connected-components"),
None,
None,
Some(4),
None
)
.is_ok());
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
Some("grid"),
Some(3),
Some(2),
None,
Some(2)
)
.is_ok());
let grid_with_count = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet,
Some("grid"),
Some(2),
Some(2),
Some(4),
)
.expect_err("grid mode must not carry sliceCount")
.to_user_msg();
assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}");
let wrong_kind = validate_generate_image_slice_declaration(
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
Some("connected-components"),
None,
None,
None,
None
)
.expect_err("slice declaration must stay scoped to icon-spritesheet")
.to_user_msg();
assert!(
wrong_kind.contains("仅对 kind=icon-spritesheet 生效"),
"{wrong_kind}"
);
let wrong_kind_count = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
None,
None,
None,
Some(8),
)
.expect_err("sliceCount-only violation must be rejected")
.to_user_msg();
assert!(
wrong_kind_count.contains("sliceCount"),
"sliceCount-only violation must name sliceCount: {wrong_kind_count}"
);
.is_err());
assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image,
None,
None,
None,
None
)
.is_ok());
for kind in ["image", "icon-spritesheet"] {
assert!(generate_image_input(&json!({"prompt":"素材", "kind":kind, "sliceMode":"connected-components", "sliceCount":4})).is_err());
}
}
#[test]
@@ -6413,6 +6371,53 @@ mod tests {
turn_ids
}
#[test]
fn art_result_returns_all_resources_and_labels_bounded_previews() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
init_local_game_project_at(root, "art-result", "图集返回").unwrap();
let mut paths = vec!["assets/sheet.png".to_string()];
paths.extend((0..8).map(|i| art_slice_path("source", i)));
for (i, path) in paths.iter().enumerate() {
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
image::RgbaImage::from_pixel(2, 2, image::Rgba([i as u8, 20, 30, 128]))
.save(root.join(path))
.unwrap();
import_canvas_asset_at(
root,
path,
GameCreationAppAssetKind::Icon,
"image/png",
"canvas",
Some(format!("resource-{i}")),
Some(format!("object-{i}")),
Some("task".into()),
None,
None,
)
.unwrap();
}
let resources = bridge_art_resources(root, &paths[..1], &paths[1..]).unwrap();
assert_eq!(resources.len(), paths.len());
let response = bridge_art_tool_result(
root,
json!({"resources":resources, "slicePaths":paths[1..]}),
&paths,
);
let payload: Value =
serde_json::from_str(response["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(payload["previewPaths"], json!(paths[..7]));
assert_eq!(payload["remainingPreviewPaths"], json!(paths[7..]));
assert_eq!(payload["resources"].as_array().unwrap().len(), 9);
for (i, path) in paths.iter().take(7).enumerate() {
assert!(response["content"][1 + i * 2]["text"]
.as_str()
.unwrap()
.contains(path));
assert_eq!(response["content"][2 + i * 2]["type"], "image");
}
}
#[test]
fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
let asset = GameCreationAppAssetManifestEntry {
@@ -387,12 +387,6 @@ fn direct_tools_mcp_specs_for_plugins(
"maximum": 32,
"description": prompt_text!("directTools.agc_generate_image.parameters.gridY")
},
"sliceCount": {
"type": "integer",
"minimum": 1,
"maximum": 256,
"description": prompt_text!("directTools.agc_generate_image.parameters.sliceCount")
},
"screenColor": {
"type": "string",
"description": prompt_text!("directTools.agc_generate_image.parameters.screenColor")
@@ -3092,14 +3086,9 @@ mod tests {
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["minimum"],
json!(1)
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["maximum"],
json!(256)
);
assert!(image_tool["inputSchema"]["properties"]
.get("sliceCount")
.is_none());
assert!(
image_tool["inputSchema"]["properties"]["screenColor"]["description"]
.as_str()
File diff suppressed because it is too large Load Diff
@@ -879,7 +879,10 @@ fn durable_legacy_generation_result(
result: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let mut durable = durable_legacy_generation_object(result);
for field in ["spritesheetWidth", "spritesheetHeight"] {
if let Some(mode) = json_string_field(result, "sliceMode") {
durable.insert("sliceMode".into(), serde_json::Value::String(mode));
}
for field in ["spritesheetWidth", "spritesheetHeight", "gridX", "gridY"] {
if let Some(value) = result.get(field).and_then(serde_json::Value::as_u64) {
durable.insert(field.to_string(), serde_json::Value::from(value));
}
@@ -1068,6 +1071,43 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。
///
/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。
/// 只读匹配旧输出槽,返回其原身份;不改变冻结请求或已受理操作。
pub(super) fn matching_legacy_standalone_platform_art_context_at(
root: &Path,
agent_id: &str,
legacy_run_id: &str,
candidates: &[PlatformArtGenerationRuntimeContext],
) -> Result<Option<PlatformArtGenerationRuntimeContext>, String> {
let path = platform_art_generation_runtime_relative_path(agent_id, legacy_run_id);
let Some(state) =
read_agent_runtime_json_sidecar_with_max_bytes::<PlatformArtGenerationRuntimeState>(
root,
&path,
"External Editor 生成账本",
PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES,
)?
else {
return Ok(None);
};
let Some(candidate) = candidates.iter().find(|candidate| {
candidate.agent_id == state.agent_id
&& candidate.action_fingerprint == state.action_fingerprint
}) else {
return Ok(None);
};
let identity = format!("{agent_id}:{legacy_run_id}");
let context = PlatformArtGenerationRuntimeContext {
run_id: legacy_run_id.to_string(),
task_id: identity.clone(),
session_id: identity.clone(),
action_id: identity,
..candidate.clone()
};
read_platform_art_generation_runtime_state(root, &context)?
.ok_or_else(|| "旧输出槽账本在读取期间消失,已拒绝创建新请求".to_string())?;
Ok(Some(context))
}
pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at(
root: &Path,
context: &PlatformArtGenerationRuntimeContext,
@@ -4,8 +4,7 @@
use crate::agent::direct_tool_bridge::{
GENERATE_IMAGE_ASPECT_RATIOS, GENERATE_IMAGE_GRID_AXIS_MAX, GENERATE_IMAGE_GRID_AXIS_MIN,
GENERATE_IMAGE_IMAGE_SIZES, GENERATE_IMAGE_SLICE_COUNT_MAX, GENERATE_IMAGE_SLICE_COUNT_MIN,
GENERATE_IMAGE_SLICE_MODES,
GENERATE_IMAGE_IMAGE_SIZES, GENERATE_IMAGE_SLICE_MODES,
};
use crate::agent::generation::PLATFORM_ART_ASSET_GENERATION_KINDS;
use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure};
@@ -48,9 +47,7 @@ pub(crate) enum GenerateImageError {
GridAxisNotInteger { axis: &'static str },
GridAxesMissing,
GridOutOfRange { axis: &'static str, got: u32 },
SliceCountInvalid { got: String },
SpritesheetSliceModeMissing,
SpritesheetSliceCountNotAllowed,
SliceDeclarationNotForKind { kind: GameCreationAppAssetKind },
ScreenColorNotForKind { kind: GameCreationAppAssetKind },
ScreenColorMalformed { got: String },
@@ -171,21 +168,12 @@ impl ToolFailure for GenerateImageError {
"生成图片失败:{axis}={got} 超出范围,gridX/gridY 必须在 {GENERATE_IMAGE_GRID_AXIS_MIN} 到 {GENERATE_IMAGE_GRID_AXIS_MAX} 之间。"
)
}
Self::SliceCountInvalid { got } => {
format!(
"生成图片失败:sliceCount「{got}」非法,必须是 {GENERATE_IMAGE_SLICE_COUNT_MIN} 到 {GENERATE_IMAGE_SLICE_COUNT_MAX} 的整数。"
)
}
Self::SpritesheetSliceModeMissing => format!(
"生成图片失败:kind={} 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components。",
GameCreationAppAssetKind::IconSpritesheet
),
Self::SpritesheetSliceCountNotAllowed => {
"生成图片失败:sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount。"
.to_string()
}
Self::SliceDeclarationNotForKind { kind } => format!(
"生成图片失败:sliceMode/gridX/gridY/sliceCount 仅对 kind={} 生效,当前 kind={kind}。",
"生成图片失败:sliceMode/gridX/gridY 仅对 kind={} 生效,当前 kind={kind}。",
GameCreationAppAssetKind::IconSpritesheet
),
Self::ScreenColorNotForKind { kind } => format!(
@@ -26,7 +26,7 @@ async fn run_local_project_asset_generation_task(
remove_live_task_id(&task_id);
return;
}
let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &options).await;
let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await;
match outcome {
Ok(generated) => {
let _ = update_task(&root, &task_id, |task| {
@@ -971,19 +971,24 @@ fn resolve_project_command_executable(
/// 调用点全在 Linux 编译面(`#[cfg(target_os = "linux")]` 的沙箱启动路径与该平台用例),
/// Windows 生产构建没有任何消费方,所以这里跟随调用点收窄到 Linux。
#[cfg(target_os = "linux")]
pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec<String>) {
pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec<OsString>) {
#[cfg(target_os = "linux")]
{
if let Some((node, npm_cli)) = spec.node_launcher.as_ref() {
let mut arguments = Vec::with_capacity(spec.arguments.len() + 1);
arguments.push(npm_cli.to_string_lossy().into_owned());
arguments.extend(spec.arguments.iter().cloned());
// 保留原始 OsStr 字节:`npm_cli` 若含非 UTF-8,`to_string_lossy` 会变成 U+FFFD,
// 既改坏实际执行的脚本路径,也让沙箱的可信 npm_cli 比对失配。
arguments.push(npm_cli.as_os_str().to_owned());
arguments.extend(spec.arguments.iter().map(OsString::from));
return (node.clone(), arguments);
}
}
(
spec.executable.clone(),
project_command_actual_arguments(spec),
project_command_actual_arguments(spec)
.into_iter()
.map(OsString::from)
.collect(),
)
}
@@ -1684,6 +1689,9 @@ pub(crate) fn prepare_project_command_launch_spec(
&target_arguments,
&spec.cwd,
&environment,
spec.node_launcher
.as_ref()
.map(|(node, npm_cli)| (node.as_path(), npm_cli.as_path())),
)
.map_err(|error| {
ProjectCommandError::new(
@@ -2670,6 +2678,14 @@ mod tests {
fn npm_command_targets_node_plus_npm_cli_on_linux() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("game")).unwrap();
// 这条用例走真实 resolve_node_runtime:没有可用宿主 Node 的最小容器里跳过,
// 保持 cargo test 可跑;跳过时打印可见提示(配 --nocapture / --show-output 看得到)。
if let Err(error) = crate::environment_check::resolve_node_runtime(root.path()) {
eprintln!(
"skipping npm_command_targets_node_plus_npm_cli_on_linux: no host node runtime({error})"
);
return;
}
let spec = resolve_project_command_spec_at(
root.path(),
"npm",
@@ -2684,13 +2700,31 @@ mod tests {
Some("node"),
"{executable:?}"
);
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]);
assert!(
arguments[0].to_string_lossy().ends_with("npm-cli.js"),
"{arguments:?}"
);
assert_eq!(
&arguments[1..],
[OsString::from("run"), OsString::from("build")]
);
// 沙箱联网判定要求目标与解析层给出的可信 (node, npm_cli) 精确一致。
let (trusted_node, trusted_npm_cli) = spec
.node_launcher
.as_ref()
.expect("Linux npm spec must carry the trusted node launcher");
assert_eq!(trusted_node, &executable);
// 逐字节相等:argv[0] 不再经 to_string_lossy 往返。
assert_eq!(trusted_npm_cli.as_os_str(), arguments[0].as_os_str());
let bootstrap = resolve_project_bootstrap_spec_at(root.path(), 30).unwrap();
let (_, arguments) = project_command_actual_target(&bootstrap);
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
assert_eq!(arguments[1], "install");
assert!(
arguments[0].to_string_lossy().ends_with("npm-cli.js"),
"{arguments:?}"
);
assert_eq!(arguments[1], OsString::from("install"));
}
#[cfg(target_os = "linux")]
@@ -95,15 +95,26 @@ pub(crate) fn command_sandbox_platform_metadata() -> CommandSandboxMetadata {
/// Builds a fail-closed launcher for a direct executable plus structured argv.
/// It never falls back to launching the original command on the host.
///
/// `trusted_npm_install` 是解析层给出的可信 `(node, npm_cli)`;只有目标命令与它精确一致
/// 且子命令是 `install` 时才放网,避免同名项目文件冒充 npm。
#[cfg(target_os = "linux")]
pub(crate) fn prepare_command_sandbox_launch(
root: &Path,
executable: &Path,
arguments: &[String],
arguments: &[OsString],
cwd: &Path,
environment: &[(OsString, OsString)],
trusted_npm_install: Option<(&Path, &Path)>,
) -> Result<CommandSandboxLaunch, CommandSandboxError> {
prepare_linux_command_sandbox_launch(root, executable, arguments, cwd, environment)
prepare_linux_command_sandbox_launch(
root,
executable,
arguments,
cwd,
environment,
trusted_npm_install,
)
}
#[cfg(target_os = "linux")]
@@ -186,7 +197,8 @@ mod linux {
root: PathBuf,
cwd: PathBuf,
executable: PathBuf,
arguments: Vec<String>,
arguments: Vec<OsString>,
npm_install_network: bool,
target_environment: Vec<(OsString, OsString)>,
merged_usr_links: Vec<(OsString, PathBuf)>,
protected_read_only: Vec<PathBuf>,
@@ -275,12 +287,15 @@ mod linux {
pub(super) fn prepare_linux_command_sandbox_launch(
root: &Path,
executable: &Path,
arguments: &[String],
arguments: &[OsString],
cwd: &Path,
environment: &[(OsString, OsString)],
trusted_npm_install: Option<(&Path, &Path)>,
) -> Result<CommandSandboxLaunch, CommandSandboxError> {
let mut metadata = CommandSandboxMetadata::enforced_linux();
if command_sandbox_requests_npm_install(executable, arguments) {
let npm_install_network =
command_sandbox_requests_npm_install(executable, arguments, trusted_npm_install);
if npm_install_network {
metadata.network = "enabled";
}
let bwrap = find_trusted_bwrap().map_err(|error| {
@@ -357,6 +372,7 @@ mod linux {
cwd,
executable,
arguments: arguments.to_vec(),
npm_install_network,
target_environment,
merged_usr_links,
protected_read_only,
@@ -531,29 +547,24 @@ mod linux {
.collect())
}
/// `npm install` 是唯一允许联网的受控入口。Linux 以 `node <npm-cli.js> install` 启动时
/// executable 是 node,网络判定不能只看 executable 文件名。
fn command_sandbox_requests_npm_install(executable: &Path, arguments: &[String]) -> bool {
let npm_name = executable
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| {
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
});
if npm_name {
return arguments
/// `npm install` 是唯一允许联网的受控入口。Linux 上 npm 以 `node <npm-cli.js> install`
/// 启动,只看 executable 或第一个参数的 basename 会被项目里同名的文件冒充,因此必须与解析层
/// 给出的可信 `(node, npm_cli)` 精确比对;拿不到可信对时一律不放网。
fn command_sandbox_requests_npm_install(
executable: &Path,
arguments: &[OsString],
trusted_npm_install: Option<(&Path, &Path)>,
) -> bool {
let Some((trusted_node, trusted_npm_cli)) = trusted_npm_install else {
return false;
};
executable == trusted_node
&& arguments
.first()
.is_some_and(|argument| argument == "install");
}
arguments
.first()
.map(Path::new)
.and_then(Path::file_name)
.and_then(OsStr::to_str)
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"))
.is_some_and(|argument| Path::new(argument) == trusted_npm_cli)
&& arguments
.get(1)
.is_some_and(|argument| argument == "install")
.is_some_and(|argument| argument.as_os_str() == OsStr::new("install"))
}
/// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm`
@@ -698,10 +709,7 @@ mod linux {
fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch {
let mut args = Vec::<OsString>::new();
push_namespace_arguments(
&mut args,
command_sandbox_requests_npm_install(&plan.executable, &plan.arguments),
);
push_namespace_arguments(&mut args, plan.npm_install_network);
push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr"));
for (target, destination) in &plan.merged_usr_links {
push_option(
@@ -761,7 +769,7 @@ mod linux {
push_option(&mut args, "--chdir", [plan.cwd.as_os_str()]);
args.push(OsString::from("--"));
args.push(plan.executable.as_os_str().to_owned());
args.extend(plan.arguments.iter().map(OsString::from));
args.extend(plan.arguments.iter().cloned());
CommandSandboxLaunch {
executable: plan.bwrap,
@@ -1041,6 +1049,16 @@ mod linux {
})
}
#[test]
fn namespace_arguments_share_net_only_when_trusted() {
let mut shared = Vec::new();
push_namespace_arguments(&mut shared, true);
assert!(shared.contains(&OsString::from("--share-net")));
let mut isolated = Vec::new();
push_namespace_arguments(&mut isolated, false);
assert!(!isolated.contains(&OsString::from("--share-net")));
}
#[test]
fn pure_builder_constructs_empty_workspace_namespace_and_private_environment() {
let launch = build_linux_bwrap_launch(LinuxSandboxPlan {
@@ -1048,7 +1066,8 @@ mod linux {
root: PathBuf::from("/workspace/project"),
cwd: PathBuf::from("/workspace/project/game"),
executable: PathBuf::from("/opt/toolchain/bin/tool"),
arguments: vec!["check".to_string(), "--flag".to_string()],
arguments: vec![OsString::from("check"), OsString::from("--flag")],
npm_install_network: false,
target_environment: vec![
(
OsString::from("HOME"),
@@ -1170,9 +1189,10 @@ mod linux {
let error = prepare_command_sandbox_launch(
&root,
Path::new("/usr/bin/python3"),
&["-c".to_string(), script],
&[OsString::from("-c"), OsString::from(script)],
&root,
&[(OsString::from("PATH"), OsString::from("/usr/bin"))],
None,
)
.expect_err("invalid protected path must fail closed");
assert!(error.to_string().contains(".codex"));
@@ -1317,26 +1337,64 @@ mod linux {
}
#[test]
fn npm_install_network_detection_supports_node_launcher() {
fn npm_install_network_detection_requires_the_trusted_node_launcher() {
let node = Path::new("/opt/node/bin/node");
let npm_cli = Path::new("/opt/node/lib/node_modules/npm/bin/npm-cli.js");
let trusted = Some((node, npm_cli));
let install_arguments = vec![npm_cli.as_os_str().to_owned(), OsString::from("install")];
assert!(command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/node"),
&[
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
"install".to_string(),
],
node,
&install_arguments,
trusted,
));
// 项目里同名的 npm-cli.js 不能再冒充。
assert!(!command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/node"),
node,
&[
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
"run".to_string(),
"build".to_string(),
OsString::from("/workspace/game/npm-cli.js"),
OsString::from("install"),
],
trusted,
));
assert!(command_sandbox_requests_npm_install(
// executable 与可信 node 不一致时不放网。
assert!(!command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/other"),
&install_arguments,
trusted,
));
// 非 install 子命令不放网。
assert!(!command_sandbox_requests_npm_install(
node,
&[
npm_cli.as_os_str().to_owned(),
OsString::from("run"),
OsString::from("build"),
],
trusted,
));
// 没有可信 launcher(例如直接 npm shim)时不放网。
assert!(!command_sandbox_requests_npm_install(
Path::new("/usr/bin/npm"),
&["install".to_string()],
&[OsString::from("install")],
None,
));
// 非 UTF-8 的 npm_cli:argv 保留原始字节时必须逐字节命中
// (旧实现经 to_string_lossy 会变成 U+FFFD,从而误拒合法的 npm install)。
{
use std::os::unix::ffi::OsStringExt;
let raw_npm_cli = PathBuf::from(OsString::from_vec(
b"/opt/node/lib/node_modules/npm/bin/n\xffpm-cli.js".to_vec(),
));
let raw_arguments = vec![
raw_npm_cli.as_os_str().to_owned(),
OsString::from("install"),
];
assert!(command_sandbox_requests_npm_install(
node,
&raw_arguments,
Some((node, raw_npm_cli.as_path())),
));
}
}
struct TempTree(PathBuf);
@@ -1433,9 +1491,10 @@ print("SANDBOX_OK")
let launch = prepare_command_sandbox_launch(
&root,
Path::new("/usr/bin/python3"),
&["-c".to_string(), script],
&[OsString::from("-c"), OsString::from(script)],
&root,
&environment,
None,
)
.expect("prepare real Linux sandbox");
@@ -1487,13 +1546,14 @@ print("SANDBOX_OK")
(OsString::from("PATH"), runtime.safe_path.clone()),
(OsString::from("HOME"), OsString::from("/host/home")),
];
let run = |executable: &Path, arguments: &[String]| {
let run = |executable: &Path, arguments: &[OsString]| {
let launch = prepare_command_sandbox_launch(
&root,
executable,
arguments,
&root,
&environment,
None,
)
.expect("prepare node sandbox");
let output = Command::new(&launch.executable)
@@ -1514,8 +1574,8 @@ print("SANDBOX_OK")
let version = run(
&runtime.node,
&[
"-e".to_string(),
"process.stdout.write(process.version)".to_string(),
OsString::from("-e"),
OsString::from("process.stdout.write(process.version)"),
],
);
assert!(
@@ -1523,8 +1583,13 @@ print("SANDBOX_OK")
"unexpected node version: {version}"
);
let npm_cli = runtime.npm_cli.to_string_lossy().into_owned();
let npm_version = run(&runtime.node, &[npm_cli, "--version".to_string()]);
let npm_version = run(
&runtime.node,
&[
runtime.npm_cli.as_os_str().to_owned(),
OsString::from("--version"),
],
);
assert!(!npm_version.is_empty(), "npm --version returned nothing");
}
@@ -1562,10 +1627,16 @@ print("SANDBOX_OK")
(OsString::from("PATH"), prefix.join("bin").into_os_string()),
(OsString::from("HOME"), OsString::from("/host/home")),
];
let run = |arguments: &[String]| {
let launch =
prepare_command_sandbox_launch(&root, &node, arguments, &root, &environment)
.expect("prepare nvm sandbox");
let run = |arguments: &[OsString]| {
let launch = prepare_command_sandbox_launch(
&root,
&node,
arguments,
&root,
&environment,
None,
)
.expect("prepare nvm sandbox");
let output = Command::new(&launch.executable)
.args(&launch.arguments)
.current_dir(&launch.cwd)
@@ -1582,17 +1653,14 @@ print("SANDBOX_OK")
};
let version = run(&[
"-e".to_string(),
"process.stdout.write(process.version)".to_string(),
OsString::from("-e"),
OsString::from("process.stdout.write(process.version)"),
]);
assert!(
version.starts_with('v'),
"unexpected node version: {version}"
);
let npm_version = run(&[
npm_cli.to_string_lossy().into_owned(),
"--version".to_string(),
]);
let npm_version = run(&[npm_cli.as_os_str().to_owned(), OsString::from("--version")]);
assert!(!npm_version.is_empty(), "npm --version returned nothing");
}
@@ -1612,7 +1680,11 @@ print("SANDBOX_OK")
"from pathlib import Path; import os; assert os.readlink('/proc/self/fd/0') == '/dev/null'; assert all(not Path(f'/proc/self/fd/{{fd}}').exists() for fd in (3, 4, 5, 6)); Path({:?}).write_text('COMMITTED')",
marker.to_string_lossy()
);
let arguments = vec!["-c".to_string(), script, "--".to_string()];
let arguments = vec![
OsString::from("-c"),
OsString::from(script),
OsString::from("--"),
];
let environment = vec![(OsString::from("PATH"), OsString::from("/usr/bin"))];
let launch = prepare_linux_command_sandbox_launch(
&root,
@@ -1620,9 +1692,10 @@ print("SANDBOX_OK")
&arguments,
&root,
&environment,
None,
)
.expect("prepare real Linux sandbox");
let target_arguments = arguments.iter().map(OsString::from).collect::<Vec<_>>();
let target_arguments = arguments.clone();
let gate =
LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/python3"), &target_arguments)
.expect("create real Linux sandbox gate");
@@ -4161,7 +4161,6 @@ pub(crate) fn prepare_local_project_asset_generation(
)?
.unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()),
replace_existing: false,
slice_count: None,
// 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 icon-spritesheet
// 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。
slice_mode: (asset_kind == GameCreationAppAssetKind::IconSpritesheet)
@@ -4213,6 +4212,7 @@ pub(crate) async fn generate_local_project_asset(
let generated = generate_platform_art_asset_with_options_at(
&request.root,
&request.prompt,
&[],
&request.options,
)
.await?;
@@ -4273,7 +4273,6 @@ mod local_project_asset_generation_tests {
assert_eq!(request.prompt, "像素月光厨房主角");
assert_eq!(request.options.asset_kind.as_str(), kind);
assert!(!request.options.replace_existing);
assert_eq!(request.options.slice_count, None);
}
}
@@ -757,7 +757,7 @@ pub(crate) async fn generate_platform_art_asset(
) -> Result<UploadLocalAssetResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.asset_generate")?;
let generated = generate_platform_art_asset_at(root, prompt.trim()).await?;
let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?;
Ok(generated.asset)
}

Some files were not shown because too many files have changed in this diff Show More