工具入参对象校验升级为类型约束,解析器不再可能绕过 NotObject

- ToolArgumentsRejection::check 返回已确认的 &Map<String, Value>

- optional_text / required_text / page 改收 &Map,删掉 debug_assert 兜底

- 内部入参解析 helper 同步收 &Map;桥与 MCP 预检的 &Value 入口保持不变

- 更新直连工具桥与单测调用点
This commit is contained in:
2026-10-02 14:57:44 +08:00
parent 0dda4cd78e
commit 9c7cf2601c
4 changed files with 78 additions and 65 deletions
@@ -48,7 +48,7 @@ use axum::routing::post;
use axum::{Json, Router};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
@@ -797,7 +797,7 @@ impl AccountAssetImportField {
}
fn import_account_asset_string_array(
arguments: &Value,
arguments: &Map<String, Value>,
field: AccountAssetImportField,
) -> Result<Vec<String>, ImportAccountAssetsError> {
let Some(value) = arguments.get(field.key()).filter(|value| !value.is_null()) else {
@@ -856,7 +856,7 @@ fn import_account_asset_string_array(
pub(in crate::agent) fn account_asset_import_inputs(
arguments: &Value,
) -> Result<(Vec<String>, Vec<String>), ImportAccountAssetsError> {
ToolArgumentsRejection::check(arguments, &["assetIds", "localPaths"])?;
let arguments = ToolArgumentsRejection::check(arguments, &["assetIds", "localPaths"])?;
let asset_ids =
import_account_asset_string_array(arguments, AccountAssetImportField::AssetIds)?;
let local_paths =
@@ -962,7 +962,7 @@ impl ToolTextField<CreateOrDeriveResourceError> for ResourceGenerationTextField
pub(in crate::agent) fn resource_generation_input(
arguments: &Value,
) -> Result<DirectResourceGenerationInput, CreateOrDeriveResourceError> {
ToolArgumentsRejection::check(
let arguments = ToolArgumentsRejection::check(
arguments,
&["kind", "mode", "sourceLocalAssetId", "prompt", "assetName"],
)?;
@@ -1199,7 +1199,7 @@ pub(in crate::agent) struct ListRegisteredAssetsInput {
pub(in crate::agent) fn list_registered_assets_input(
arguments: &Value,
) -> Result<ListRegisteredAssetsInput, ListRegisteredAssetsError> {
ToolArgumentsRejection::check(
let arguments = ToolArgumentsRejection::check(
arguments,
&[
"kind",
@@ -1555,7 +1555,8 @@ pub(in crate::agent) struct ListProjectFilesInput {
pub(in crate::agent) fn list_project_files_input(
arguments: &Value,
) -> Result<ListProjectFilesInput, ListProjectFilesError> {
ToolArgumentsRejection::check(arguments, &["path", "query", "kind", "offset", "limit"])?;
let arguments =
ToolArgumentsRejection::check(arguments, &["path", "query", "kind", "offset", "limit"])?;
let path = optional_text(arguments, ListProjectFilesTextField::Path)?;
if let Some(raw) = path.as_deref() {
let slash_normalized = raw.replace('\\', "/");
@@ -1763,7 +1764,7 @@ fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Optio
pub(in crate::agent) fn write_file_input(
arguments: &Value,
) -> Result<(String, String), WriteFileError> {
ToolArgumentsRejection::check(arguments, &["path", "content"])?;
let arguments = ToolArgumentsRejection::check(arguments, &["path", "content"])?;
let raw_path = arguments
.get("path")
.and_then(Value::as_str)
@@ -1999,7 +2000,8 @@ pub(in crate::agent) struct ListAccountAssetsInput {
pub(in crate::agent) fn list_account_assets_input(
arguments: &Value,
) -> Result<ListAccountAssetsInput, ListAccountAssetsError> {
ToolArgumentsRejection::check(arguments, &["folderId", "query", "offset", "limit"])?;
let arguments =
ToolArgumentsRejection::check(arguments, &["folderId", "query", "offset", "limit"])?;
let folder_id = optional_text(arguments, AccountAssetTextField::FolderId)?;
let query =
optional_text(arguments, AccountAssetTextField::Query)?.map(|value| value.to_lowercase());
@@ -2387,7 +2389,7 @@ pub(in crate::agent) struct RemoveBackgroundInput {
pub(in crate::agent) fn remove_background_input(
arguments: &Value,
) -> Result<RemoveBackgroundInput, RemoveBackgroundError> {
ToolArgumentsRejection::check(
let arguments = ToolArgumentsRejection::check(
arguments,
&[
"sourceLocalAssetId",
@@ -2721,14 +2723,14 @@ fn prepare_game_art_request(
pub(in crate::agent) fn prepare_game_art_input(
arguments: &Value,
) -> Result<(String, DirectTaonierArtPreparationMode), PrepareGameArtError> {
ToolArgumentsRejection::check(arguments, &["brief", "mode"])?;
let arguments = ToolArgumentsRejection::check(arguments, &["brief", "mode"])?;
let brief = required_text(arguments, PrepareGameArtTextField::Brief)?;
Ok((brief, art_preparation_mode(arguments)?))
}
/// `mode` 缺省为 `reuse-or-create`;给了就必须是受支持的模式名。
fn art_preparation_mode(
arguments: &Value,
arguments: &Map<String, Value>,
) -> Result<DirectTaonierArtPreparationMode, PrepareGameArtError> {
match arguments.get("mode").filter(|value| !value.is_null()) {
None => Ok(DirectTaonierArtPreparationMode::ReuseOrCreate),
@@ -2894,7 +2896,7 @@ impl ToolTextField<GenerateImageError> for GenerateImageTextField {
}
fn generate_image_grid_axis(
arguments: &Value,
arguments: &Map<String, Value>,
axis: &'static str,
) -> Result<Option<u32>, GenerateImageError> {
let Some(value) = arguments.get(axis) else {
@@ -2908,7 +2910,7 @@ fn generate_image_grid_axis(
}
fn bridge_image_generation_kind(
arguments: &Value,
arguments: &Map<String, Value>,
) -> Result<GameCreationAppAssetKind, GenerateImageError> {
// 缺省与显式 null 用 schema 声明的默认值;给了别的类型不许静默当 image。
let kind = match arguments.get("kind") {
@@ -2955,7 +2957,7 @@ fn validate_generate_image_slice_declaration(
/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝,
/// 避免服务端静默忽略造成“已生效”的误解。
fn normalize_generate_image_screen_color(
arguments: &Value,
arguments: &Map<String, Value>,
kind: GameCreationAppAssetKind,
) -> Result<Option<String>, GenerateImageError> {
let Some(value) = arguments.get("screenColor") else {
@@ -3012,7 +3014,7 @@ pub(in crate::agent) struct GenerateImageInput {
pub(in crate::agent) fn generate_image_input(
arguments: &Value,
) -> Result<GenerateImageInput, GenerateImageError> {
ToolArgumentsRejection::check(arguments, GENERATE_IMAGE_ARGUMENTS)?;
let arguments = ToolArgumentsRejection::check(arguments, GENERATE_IMAGE_ARGUMENTS)?;
let prompt = required_text(arguments, GenerateImageTextField::Prompt)?;
let kind = bridge_image_generation_kind(arguments)?;
let aspect_ratio = optional_text(arguments, GenerateImageTextField::AspectRatio)?
@@ -3254,7 +3256,8 @@ impl ToolTextField<EditImageError> for EditImageTextField {
///
/// 工具桥与 MCP 预检共用这一份入参规则;失败文案只写在 [`EditImageError`] 上。
pub(in crate::agent) fn edit_image_arguments(arguments: &Value) -> Result<Value, EditImageError> {
ToolArgumentsRejection::check(arguments, &["sourceLocalAssetId", "prompt", "assetName"])?;
let arguments =
ToolArgumentsRejection::check(arguments, &["sourceLocalAssetId", "prompt", "assetName"])?;
let source = required_text(arguments, EditImageTextField::SourceLocalAssetId)?;
let prompt = required_text(arguments, EditImageTextField::Prompt)?;
let asset_name = required_text(arguments, EditImageTextField::AssetName)?;
@@ -3324,7 +3327,7 @@ async fn bridge_environment_check(
root: &Path,
arguments: &Value,
) -> Result<Value, EnvironmentCheckError> {
ToolArgumentsRejection::check(arguments, &[])?;
let arguments = ToolArgumentsRejection::check(arguments, &[])?;
enforce_project_permission_policy_rejection(root, "game.run_local")?;
let result = crate::environment_check::check_environment(root).await;
if result["status"] != "ready" {
@@ -3480,7 +3483,7 @@ impl ToolTextField<WebSearchError> for WebSearchTextField {
pub(in crate::agent) fn web_search_input(
arguments: &Value,
) -> Result<(String, usize), WebSearchError> {
ToolArgumentsRejection::check(arguments, &["query", "maxResults"])?;
let arguments = ToolArgumentsRejection::check(arguments, &["query", "maxResults"])?;
let query = required_text(arguments, WebSearchTextField::Query)?;
let max_results = match arguments.get("maxResults") {
None => 3u64,
@@ -3652,7 +3655,7 @@ pub(in crate::agent) fn editor_execute_code_input(
arguments: &Value,
editor: EditorKind,
) -> Result<String, EditorExecuteError> {
ToolArgumentsRejection::check(arguments, &["code"])?;
let arguments = ToolArgumentsRejection::check(arguments, &["code"])?;
let code = arguments
.get("code")
.and_then(Value::as_str)
@@ -3698,7 +3701,7 @@ fn prepare_editor_execute(
pub(in crate::agent) fn cocos_execute_code_input(
arguments: &Value,
) -> Result<String, CocosExecuteError> {
ToolArgumentsRejection::check(arguments, &["code"])?;
let arguments = ToolArgumentsRejection::check(arguments, &["code"])?;
let code = arguments
.get("code")
.and_then(Value::as_str)
@@ -4611,13 +4614,13 @@ mod tests {
fn generate_image_screen_color_is_normalized_and_kind_gated() {
// 省略与显式 null 等价,且不触发 kind 门禁。
assert_eq!(
normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image)
normalize_generate_image_screen_color(obj(&json!({})), GameCreationAppAssetKind::Image)
.expect("omitted"),
None
);
assert_eq!(
normalize_generate_image_screen_color(
&json!({"screenColor": null}),
obj(&json!({"screenColor": null})),
GameCreationAppAssetKind::Image
)
.expect("null"),
@@ -4627,7 +4630,7 @@ mod tests {
for raw in ["auto", "AUTO", " auto ", ""] {
assert_eq!(
normalize_generate_image_screen_color(
&json!({"screenColor": raw}),
obj(&json!({"screenColor": raw})),
GameCreationAppAssetKind::Character
)
.expect("auto variants"),
@@ -4638,7 +4641,7 @@ mod tests {
// hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。
assert_eq!(
normalize_generate_image_screen_color(
&json!({"screenColor": "#cfefff"}),
obj(&json!({"screenColor": "#cfefff"})),
GameCreationAppAssetKind::Character
)
.expect("lowercase hex"),
@@ -4646,7 +4649,7 @@ mod tests {
);
assert_eq!(
normalize_generate_image_screen_color(
&json!({"screenColor": " #A0BBA0 "}),
obj(&json!({"screenColor": " #A0BBA0 "})),
GameCreationAppAssetKind::IconSpritesheet
)
.expect("padded hex"),
@@ -4656,7 +4659,7 @@ mod tests {
for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] {
assert!(
normalize_generate_image_screen_color(
&json!({"screenColor": bad}),
obj(&json!({"screenColor": bad})),
GameCreationAppAssetKind::Character
)
.is_err(),
@@ -4665,14 +4668,14 @@ mod tests {
}
// 其它 kind 携带该字段直接拒绝,即使取值合法。
let gated = normalize_generate_image_screen_color(
&json!({"screenColor": "#CFEFFF"}),
obj(&json!({"screenColor": "#CFEFFF"})),
GameCreationAppAssetKind::Image,
)
.expect_err("screenColor must stay scoped to character/icon-spritesheet")
.to_user_msg();
assert!(gated.contains("kind=character"), "{gated}");
assert!(normalize_generate_image_screen_color(
&json!({"screenColor": "auto"}),
obj(&json!({"screenColor": "auto"})),
GameCreationAppAssetKind::UiDesign
)
.is_err());
@@ -4702,6 +4705,10 @@ mod tests {
}
}
use super::*;
fn obj(value: &Value) -> &Map<String, Value> {
value.as_object().expect("测试入参必须是对象")
}
use std::io::{Cursor, Read, Write};
#[tokio::test]
@@ -4802,9 +4809,11 @@ mod tests {
#[test]
fn bridge_argument_bounds_are_deterministic() {
assert!(required_text(&json!({ "brief": "" }), PrepareGameArtTextField::Brief).is_err());
assert!(
required_text(obj(&json!({ "brief": "" })), PrepareGameArtTextField::Brief).is_err()
);
assert!(required_text(
&json!({ "brief": "字".repeat(DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS + 1) }),
obj(&json!({ "brief": "字".repeat(DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS + 1) })),
PrepareGameArtTextField::Brief
)
.is_err());
@@ -4819,7 +4828,7 @@ mod tests {
DirectTaonierArtPreparationMode::Regenerate
);
assert!(DirectTaonierArtPreparationMode::from_tool_value(Some("force")).is_err());
assert!(art_preparation_mode(&json!({ "mode": 1 })).is_err());
assert!(art_preparation_mode(obj(&json!({ "mode": 1 }))).is_err());
assert!(resource_generation_input(&json!({
"kind": "video",
"mode": "create",
@@ -4929,10 +4938,12 @@ mod tests {
"{interior_newline}"
);
let whitespace_only =
required_text(&json!({ "brief": "\n\t" }), PrepareGameArtTextField::Brief)
.expect_err("纯空白仍然按空值处理")
.to_user_msg();
let whitespace_only = required_text(
obj(&json!({ "brief": "\n\t" })),
PrepareGameArtTextField::Brief,
)
.expect_err("纯空白仍然按空值处理")
.to_user_msg();
assert!(
whitespace_only.contains("brief 不能为空"),
"{whitespace_only}"
@@ -4940,7 +4951,7 @@ mod tests {
assert_eq!(
required_text(
&json!({ "brief": " 美术包 " }),
obj(&json!({ "brief": " 美术包 " })),
PrepareGameArtTextField::Brief
)
.expect("首尾普通空格仍正常裁剪"),
@@ -4985,9 +4996,9 @@ mod tests {
oversized_not_text.chars().count()
);
let inline_not_text = art_preparation_mode(&json!({
let inline_not_text = art_preparation_mode(obj(&json!({
"mode": vec!["需要回显但必须截断的模式值"; 40]
}))
})))
.expect_err("mode 不是字符串时必须被拒绝")
.to_user_msg();
assert!(
@@ -4999,18 +5010,20 @@ mod tests {
"内联判类型的字段回显也要限长:{inline_not_text}"
);
let brief_not_text = required_text(&json!({ "brief": 5 }), PrepareGameArtTextField::Brief)
.expect_err("brief 不是字符串时必须被拒绝")
.to_user_msg();
let brief_not_text =
required_text(obj(&json!({ "brief": 5 })), PrepareGameArtTextField::Brief)
.expect_err("brief 不是字符串时必须被拒绝")
.to_user_msg();
assert!(
brief_not_text.contains("brief 必须是字符串"),
"{brief_not_text}"
);
assert!(!brief_not_text.contains("不能为空"), "{brief_not_text}");
let empty_brief = required_text(&json!({ "brief": "" }), PrepareGameArtTextField::Brief)
.expect_err("空 brief 仍然按空值处理")
.to_user_msg();
let empty_brief =
required_text(obj(&json!({ "brief": "" })), PrepareGameArtTextField::Brief)
.expect_err("空 brief 仍然按空值处理")
.to_user_msg();
assert!(empty_brief.contains("brief 不能为空"), "{empty_brief}");
let aspect_ratio_not_text = match generate_image_input(&json!({
@@ -825,7 +825,9 @@ fn bounded_tool_string(arguments: &Value, field: &str, max_chars: usize) -> Resu
fn validate_tool_object_fields(arguments: &Value, allowed: &[&str]) -> Result<(), String> {
// 「入参不是对象 / 带了没声明的字段」是内置工具共用的两个 case:文案只写在
// `ToolArgumentsRejection` 上,这里只负责渲染成 MCP 结果文本。
ToolArgumentsRejection::check(arguments, allowed).map_err(|rejection| rejection.to_user_msg())
ToolArgumentsRejection::check(arguments, allowed)
.map(|_| ())
.map_err(|rejection| rejection.to_user_msg())
}
/// 与工具桥共用同一份 `agc_list_registered_assets` 入参规则。
@@ -3,7 +3,7 @@
//! 每个工具为自己的文本字段定义一个 `enum` 并实现 [`ToolTextField`],把「空值/超长/
//! 控制字符」三个变体告诉解析器;解析逻辑只有这一份,各工具的变体名与文案仍是自己的。
use serde_json::Value;
use serde_json::{Map, Value};
use super::error::{bounded_echo, ToolPageRejection};
@@ -34,9 +34,9 @@ pub(crate) trait ToolTextField<E>: Copy {
/// 必填文本字段:缺省、`null`、空串都算空值。
///
/// 契约同 [`optional_text`]:调用方必须先确认入参是对象。
/// 契约同 [`optional_text`]:调用方必须先拿到 [`ToolArgumentsRejection::check`] 返回的对象。
pub(crate) fn required_text<F: ToolTextField<E>, E>(
arguments: &Value,
arguments: &Map<String, Value>,
field: F,
) -> Result<String, E> {
optional_text(arguments, field)?.ok_or_else(|| field.empty())
@@ -44,17 +44,12 @@ pub(crate) fn required_text<F: ToolTextField<E>, E>(
/// 可选文本字段:缺省或 `null` 视为未提供;给了就必须在该字段自己的边界内。
///
/// 调用方必须先用 `ToolArgumentsRejection::check` 确认入参是 JSON 对象:非对象时
/// `Value::get` 恒为 `None`,这里会静默当成「没给这个字段」,把 `NotObject` 判成 `empty`。
/// 所有调用点现在都先跑 `check`,`debug_assert` 只保证以后不会有人绕过。
/// 收 [`Map`] 而不是 [`Value`]:「入参是对象」由 [`ToolArgumentsRejection::check`] 在类型上
/// 保证,解析器不可能拿到非对象后把 `NotObject` 静默当成「没给这个字段」。
pub(crate) fn optional_text<F: ToolTextField<E>, E>(
arguments: &Value,
arguments: &Map<String, Value>,
field: F,
) -> Result<Option<String>, E> {
debug_assert!(
arguments.is_object(),
"optional_text 的调用方必须先跑 ToolArgumentsRejection::check"
);
let Some(value) = arguments.get(field.key()).filter(|value| !value.is_null()) else {
return Ok(None);
};
@@ -93,18 +88,14 @@ pub(crate) const MAX_PAGE_OFFSET: usize = 1_000_000;
/// 调用点必须保证 `default_limit <= max_limit`:否则省略 `limit` 的请求会拿一个本身就越界的
/// 默认值去撞上限、每次都失败。这里用 `debug_assert` 让配错在测试期就炸出来。
///
/// 同样要求调用方先确认入参是对象:非对象时 `offset`/`limit` 都会被当成没给,静默回落到默认
/// 分页,把 `NotObject` 掩盖掉。
/// 收 [`Map`] 而不是 [`Value`]:同 [`optional_text`],对象前提由
/// [`ToolArgumentsRejection::check`] 在类型上保证。
pub(crate) fn page(
arguments: &Value,
arguments: &Map<String, Value>,
default_limit: usize,
max_limit: usize,
max_offset: Option<usize>,
) -> Result<ToolPage, ToolPageRejection> {
debug_assert!(
arguments.is_object(),
"page 的调用方必须先跑 ToolArgumentsRejection::check"
);
debug_assert!(default_limit <= max_limit);
let offset = arguments
.get("offset")
@@ -55,8 +55,15 @@ pub(crate) enum ToolArgumentsRejection {
}
impl ToolArgumentsRejection {
/// 按该工具声明的字段表校验入参形状。
pub(crate) fn check(arguments: &Value, allowed: &[&str]) -> Result<(), Self> {
/// 按该工具声明的字段表校验入参形状,并返回已确认的对象。
///
/// 返回值就是「入参是对象且字段表通过」的凭证:[`crate::agent::tool::arguments::optional_text`]
/// / `required_text` / `page` 只接受这里的 `&Map`,release 下也不存在「漏跑 check 就把
/// `NotObject` 当成字段缺失」的路径。
pub(crate) fn check<'a>(
arguments: &'a Value,
allowed: &[&str],
) -> Result<&'a serde_json::Map<String, Value>, Self> {
let object = arguments.as_object().ok_or(Self::NotObject)?;
match object
.keys()