bdf7ed288a
- 将固定四切片与固定 drawImage 降为推荐路径 - 在导航前注入 Canvas/WebGL 平台素材运行时观测 - 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改 - 补充直连验收、浏览器脚本与真实 Chrome fixture 测试 - 同步 AGC 直连 Runtime 实施计划
1894 lines
68 KiB
Rust
1894 lines
68 KiB
Rust
use super::*;
|
||
|
||
pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [
|
||
(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"),
|
||
("planner", "high"),
|
||
("orchestrator", "medium"),
|
||
("generator", "high"),
|
||
("evaluator", "high"),
|
||
("design-director", "medium"),
|
||
("design-foundation", "high"),
|
||
("balance-director", "medium"),
|
||
("balance-seed", "medium"),
|
||
("art-director", "high"),
|
||
("art-asset-plan", "high"),
|
||
("art-polish", "medium"),
|
||
("audio-director", "low"),
|
||
("audio-asset-plan", "medium"),
|
||
("code-director", "medium"),
|
||
("code-prototype", "high"),
|
||
("quality-review", "high"),
|
||
("preview-readiness", "low"),
|
||
("preview-playtest", "low"),
|
||
("publish-strategy", "low"),
|
||
("publish-package", "medium"),
|
||
];
|
||
|
||
pub(crate) fn game_creator_llm_agent_default_reasoning_effort(
|
||
agent_id: &str,
|
||
) -> Option<&'static str> {
|
||
GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS
|
||
.iter()
|
||
.find_map(|(candidate, effort)| (*candidate == agent_id).then_some(*effort))
|
||
}
|
||
|
||
pub(crate) fn build_game_creator_llm_client_from_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmClient, String> {
|
||
let config = build_game_creator_platform_llm_config(llm, config_path)?;
|
||
LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn build_game_creator_llm_client_without_redirects_from_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmClient, String> {
|
||
let config = build_game_creator_platform_llm_config(llm, config_path)?;
|
||
LlmClient::new_without_redirects(config)
|
||
.map_err(|error| format!("LLM client 初始化失败:{error}"))
|
||
}
|
||
|
||
fn build_game_creator_platform_llm_config(
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmConfig, String> {
|
||
let api_kind = validate_game_creator_llm_web_search_config(llm, config_path)?;
|
||
let api_key =
|
||
trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?;
|
||
let base_url =
|
||
trim_config_string(&llm.base_url).ok_or_else(|| llm_base_url_config_error(config_path))?;
|
||
let model =
|
||
trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?;
|
||
validate_game_creator_llm_timing_config(llm, config_path)?;
|
||
let anthropic_strict_tool_support =
|
||
game_creator_supports_anthropic_strict_tools(api_kind, &base_url, &model);
|
||
LlmConfig::new(
|
||
LlmProvider::OpenAiCompatible,
|
||
base_url,
|
||
api_key,
|
||
model,
|
||
llm.request_timeout_ms,
|
||
llm.max_retries,
|
||
llm.retry_backoff_ms,
|
||
)
|
||
.map(|config| config.with_anthropic_strict_tool_support(anthropic_strict_tool_support))
|
||
.map_err(|error| format!("LLM 配置无效:{error}"))
|
||
}
|
||
|
||
fn game_creator_supports_anthropic_strict_tools(
|
||
api_kind: LlmApiKind,
|
||
base_url: &str,
|
||
model: &str,
|
||
) -> bool {
|
||
if api_kind != LlmApiKind::Anthropic {
|
||
return false;
|
||
}
|
||
|
||
// 兼容网关即使复用了 Anthropic messages 协议,也不能据此推断 structured
|
||
// outputs 能力。只对无凭据、无自定义端口/路径的官方 HTTPS endpoint 开启。
|
||
let Ok(endpoint) = url::Url::parse(base_url) else {
|
||
return false;
|
||
};
|
||
if endpoint.scheme() != "https"
|
||
|| endpoint.host_str() != Some("api.anthropic.com")
|
||
|| endpoint.port().is_some()
|
||
|| !endpoint.username().is_empty()
|
||
|| endpoint.password().is_some()
|
||
|| endpoint.path() != "/"
|
||
|| endpoint.query().is_some()
|
||
|| endpoint.fragment().is_some()
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Claude API 的 structured outputs 从 Claude 4.5 起可用。仅识别官方 Claude
|
||
// family 的版本化 model id;不凭 `latest`、第三方别名或未知产品名猜能力。
|
||
let normalized = model.trim().to_ascii_lowercase();
|
||
let mut parts = normalized.split('-');
|
||
if parts.next() != Some("claude") || !matches!(parts.next(), Some("opus" | "sonnet" | "haiku"))
|
||
{
|
||
return false;
|
||
}
|
||
let Some(major) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
|
||
return false;
|
||
};
|
||
let minor = parts
|
||
.next()
|
||
.and_then(|value| value.parse::<u32>().ok())
|
||
.unwrap_or(0);
|
||
major > 4 || (major == 4 && minor >= 5)
|
||
}
|
||
|
||
pub(crate) fn build_game_creator_llm_client_from_config() -> Result<LlmClient, String> {
|
||
let app_config = load_game_creator_app_config()?;
|
||
build_game_creator_llm_client_from_llm_config(&app_config.llm, "llm")
|
||
}
|
||
|
||
pub(crate) fn parse_game_creator_llm_api_kind(value: &str) -> Result<LlmApiKind, String> {
|
||
let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
|
||
let normalized = if normalized.is_empty() {
|
||
DEFAULT_GAME_CREATOR_LLM_API_KIND
|
||
} else {
|
||
normalized.as_str()
|
||
};
|
||
match normalized {
|
||
"openai_responses" => Ok(LlmApiKind::OpenAiResponses),
|
||
"openai_chat" => Ok(LlmApiKind::OpenAiChat),
|
||
"anthropic" => Ok(LlmApiKind::Anthropic),
|
||
value => Err(format!(
|
||
"LLM api_kind 无效:{value},请使用 openai_responses、openai_chat 或 anthropic"
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn parse_game_creator_llm_reasoning_effort(
|
||
value: &str,
|
||
) -> Result<Option<platform_llm::LlmResponseReasoningEffort>, String> {
|
||
match value.trim().to_ascii_lowercase().as_str() {
|
||
"default" => Ok(None),
|
||
"low" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Low)),
|
||
"medium" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium)),
|
||
"high" => Ok(Some(platform_llm::LlmResponseReasoningEffort::High)),
|
||
"max" => Ok(Some(platform_llm::LlmResponseReasoningEffort::Max)),
|
||
value => Err(format!(
|
||
"LLM reasoning_effort 无效:{value},请使用 default、low、medium、high 或 max"
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn apply_game_creator_llm_reasoning_effort(
|
||
request: LlmRunRequest,
|
||
llm: &GameCreatorLlmConfig,
|
||
) -> Result<LlmRunRequest, String> {
|
||
Ok(
|
||
match parse_game_creator_llm_reasoning_effort(&llm.reasoning_effort)? {
|
||
Some(effort) => request.with_response_reasoning_effort(effort),
|
||
None => request,
|
||
},
|
||
)
|
||
}
|
||
|
||
pub(crate) fn apply_game_creator_llm_web_search(
|
||
request: LlmRunRequest,
|
||
llm: &GameCreatorLlmConfig,
|
||
allowed: bool,
|
||
) -> Result<LlmRunRequest, String> {
|
||
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
||
if llm.web_search_enabled && api_kind == LlmApiKind::Anthropic {
|
||
return Err(
|
||
"LLM 配置不兼容:apiKind=anthropic 时 webSearchEnabled 必须为 false".to_string(),
|
||
);
|
||
}
|
||
Ok(if allowed && llm.web_search_enabled {
|
||
request.with_web_search(true)
|
||
} else {
|
||
request
|
||
})
|
||
}
|
||
|
||
fn validate_game_creator_llm_web_search_config(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<LlmApiKind, String> {
|
||
let api_kind = parse_game_creator_llm_api_kind(&config.api_kind)
|
||
.map_err(|error| format!("配置项 {config_path}.apiKind 无效:{error}"))?;
|
||
if config.web_search_enabled && api_kind == LlmApiKind::Anthropic {
|
||
return Err(format!(
|
||
"配置项 {config_path}.webSearchEnabled 在 apiKind=anthropic 时必须为 false"
|
||
));
|
||
}
|
||
Ok(api_kind)
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus {
|
||
let app_config = match load_game_creator_app_config() {
|
||
Ok(config) => config,
|
||
Err(error) => {
|
||
return GameCreatorLlmConfigStatus {
|
||
agent_mode: default_game_creator_agent_mode(),
|
||
configured: false,
|
||
api_key_present: false,
|
||
base_url: None,
|
||
model: None,
|
||
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
|
||
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
|
||
stream: true,
|
||
web_search_enabled: false,
|
||
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
|
||
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
|
||
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
|
||
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
|
||
max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES,
|
||
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||
error: Some(error),
|
||
agents: Vec::new(),
|
||
};
|
||
}
|
||
};
|
||
if app_config.agent_mode != GAME_CREATOR_AGENT_MODE_PROVIDER {
|
||
return check_game_creator_codex_config(&app_config);
|
||
}
|
||
let global_route_shape_error =
|
||
validate_game_creator_llm_web_search_config(&app_config.llm, "llm").err();
|
||
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
|
||
status.api_kind = parse_game_creator_llm_api_kind(&app_config.llm.api_kind)
|
||
.map(game_creator_llm_api_kind_name)
|
||
.unwrap_or_else(|error| {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()
|
||
});
|
||
if status.configured {
|
||
if let Err(error) = build_game_creator_llm_client_from_config() {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
}
|
||
}
|
||
status.agents = game_creator_llm_agent_status_definitions()
|
||
.iter()
|
||
.map(|definition| {
|
||
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &definition.agent_id);
|
||
check_game_creator_agent_llm_config_values(
|
||
&app_config.agent_mode,
|
||
&definition.agent_id,
|
||
&definition.label,
|
||
&llm,
|
||
)
|
||
})
|
||
.collect();
|
||
let mut errors = global_route_shape_error.into_iter().collect::<Vec<_>>();
|
||
let agent_errors = status
|
||
.agents
|
||
.iter()
|
||
.filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str()))
|
||
.filter(|agent| !agent.configured)
|
||
.map(|agent| {
|
||
format!(
|
||
"{}:{}",
|
||
agent.label,
|
||
agent.error.as_deref().unwrap_or("配置不完整")
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
for error in agent_errors {
|
||
if !errors.contains(&error) {
|
||
errors.push(error);
|
||
}
|
||
}
|
||
status.configured = errors.is_empty();
|
||
status.error = if errors.is_empty() {
|
||
None
|
||
} else {
|
||
Some(errors.join(";"))
|
||
};
|
||
status
|
||
}
|
||
|
||
fn check_game_creator_codex_config(
|
||
app_config: &GameCreatorAppConfig,
|
||
) -> GameCreatorLlmConfigStatus {
|
||
let cli_error = check_game_creator_codex_cli_available()
|
||
.and_then(|()| {
|
||
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||
check_game_creator_codex_app_server_available()
|
||
} else {
|
||
Ok(())
|
||
}
|
||
})
|
||
.err();
|
||
let global_route_error = game_creator_codex_app_server_llm_route_error(
|
||
&app_config.agent_mode,
|
||
&app_config.llm,
|
||
"llm",
|
||
);
|
||
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
|
||
status.agent_mode = app_config.agent_mode.clone();
|
||
status.configured = cli_error.is_none() && global_route_error.is_none();
|
||
status.error = cli_error.clone().or(global_route_error);
|
||
status.agents = game_creator_llm_agent_status_definitions()
|
||
.iter()
|
||
.map(|definition| {
|
||
let llm = resolve_game_creator_llm_config_for_agent(app_config, &definition.agent_id);
|
||
let mut agent = check_game_creator_agent_llm_config_values(
|
||
&app_config.agent_mode,
|
||
&definition.agent_id,
|
||
&definition.label,
|
||
&llm,
|
||
);
|
||
let route_error = game_creator_codex_app_server_llm_route_error(
|
||
&app_config.agent_mode,
|
||
&llm,
|
||
&format!("agentLlm.{}", definition.agent_id),
|
||
);
|
||
agent.configured = cli_error.is_none() && route_error.is_none();
|
||
agent.error = cli_error.clone().or(route_error);
|
||
agent
|
||
})
|
||
.collect();
|
||
let required_errors = status
|
||
.agents
|
||
.iter()
|
||
.filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str()))
|
||
.filter_map(|agent| {
|
||
agent
|
||
.error
|
||
.as_ref()
|
||
.map(|error| format!("{}:{error}", agent.label))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if !required_errors.is_empty() {
|
||
status.configured = false;
|
||
let mut errors = status.error.take().into_iter().collect::<Vec<_>>();
|
||
errors.extend(required_errors);
|
||
errors.dedup();
|
||
status.error = Some(errors.join(";"));
|
||
}
|
||
status
|
||
}
|
||
|
||
pub(crate) fn game_creator_codex_app_server_llm_route_error(
|
||
agent_mode: &str,
|
||
llm: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Option<String> {
|
||
if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||
return None;
|
||
}
|
||
if llm.api_kind != "openai_responses" {
|
||
return Some(format!(
|
||
"配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式",
|
||
llm.api_kind
|
||
));
|
||
}
|
||
llm.web_search_enabled.then(|| {
|
||
format!(
|
||
"配置项 {config_path}.webSearchEnabled 在 codex_app_server 模式下必须为 false;该模式由 AGC Runtime 独占工具执行,不能启用 Codex 原生联网工具"
|
||
)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> {
|
||
crate::agent::game_creator_codex_cli_version_identity().map(|_| ())
|
||
}
|
||
|
||
fn check_game_creator_codex_app_server_available() -> Result<(), String> {
|
||
let executable = crate::agent::game_creator_codex_cli_executable_path()?;
|
||
let mut command = crate::new_windows_background_std_command(executable);
|
||
let output = command
|
||
.args(["app-server", "--help"])
|
||
.stdin(std::process::Stdio::null())
|
||
.stderr(std::process::Stdio::null())
|
||
.output()
|
||
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
|
||
if !output.status.success()
|
||
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
|
||
{
|
||
return Err("当前 Codex CLI 不支持 app-server 子命令,请升级 Codex CLI".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_llm_config_values(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> GameCreatorLlmConfigStatus {
|
||
let api_key = trim_config_string(&config.api_key);
|
||
let base_url = trim_config_string(&config.base_url);
|
||
let model = trim_config_string(&config.model);
|
||
let api_key_present = api_key
|
||
.as_ref()
|
||
.is_some_and(|value| !value.trim().is_empty());
|
||
let api_kind = validate_game_creator_llm_web_search_config(config, config_path);
|
||
let error = api_kind.as_ref().err().cloned().or_else(|| {
|
||
match (api_key.as_deref(), base_url.as_deref(), model.as_deref()) {
|
||
(None, _, _) => Some(llm_api_key_config_error(config_path)),
|
||
(_, None, _) => Some(llm_base_url_config_error(config_path)),
|
||
(_, _, None) => Some(llm_model_config_error(config_path)),
|
||
(Some(api_key), Some(base_url), Some(model)) => {
|
||
validate_game_creator_llm_timing_config(config, config_path)
|
||
.err()
|
||
.or_else(|| {
|
||
LlmConfig::new(
|
||
LlmProvider::OpenAiCompatible,
|
||
base_url.to_string(),
|
||
api_key.to_string(),
|
||
model.to_string(),
|
||
config.request_timeout_ms,
|
||
config.max_retries,
|
||
config.retry_backoff_ms,
|
||
)
|
||
.and_then(LlmClient::new)
|
||
.err()
|
||
.map(|error| format!("LLM 配置无效:{error}"))
|
||
})
|
||
}
|
||
}
|
||
});
|
||
|
||
GameCreatorLlmConfigStatus {
|
||
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
||
configured: error.is_none(),
|
||
api_key_present,
|
||
base_url,
|
||
model,
|
||
api_kind: api_kind
|
||
.map(game_creator_llm_api_kind_name)
|
||
.unwrap_or_else(|_| DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()),
|
||
reasoning_effort: config.reasoning_effort.clone(),
|
||
stream: config.stream,
|
||
web_search_enabled: config.web_search_enabled,
|
||
context_window_tokens: config.context_window_tokens,
|
||
auto_compact_token_limit: config.auto_compact_token_limit,
|
||
tool_output_token_limit: config.tool_output_token_limit,
|
||
request_timeout_ms: config.request_timeout_ms,
|
||
max_retries: config.max_retries,
|
||
retry_backoff_ms: config.retry_backoff_ms,
|
||
error,
|
||
agents: Vec::new(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn check_game_creator_agent_llm_config_values(
|
||
agent_mode: &str,
|
||
agent_id: &str,
|
||
label: &str,
|
||
config: &GameCreatorLlmConfig,
|
||
) -> GameCreatorAgentLlmConfigStatus {
|
||
let config_path = format!("agentLlm.{agent_id}");
|
||
let mut status = check_game_creator_llm_config_values(config, &config_path);
|
||
status.api_kind = parse_game_creator_llm_api_kind(&config.api_kind)
|
||
.map(game_creator_llm_api_kind_name)
|
||
.unwrap_or_else(|error| {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()
|
||
});
|
||
if status.configured {
|
||
if let Err(error) = build_game_creator_llm_client_from_llm_config(config, &config_path) {
|
||
status.configured = false;
|
||
status.error = Some(error);
|
||
}
|
||
}
|
||
GameCreatorAgentLlmConfigStatus {
|
||
agent_mode: agent_mode.to_string(),
|
||
agent_id: agent_id.to_string(),
|
||
label: label.to_string(),
|
||
configured: status.configured,
|
||
api_key_present: status.api_key_present,
|
||
base_url: status.base_url,
|
||
model: status.model,
|
||
api_kind: status.api_kind,
|
||
reasoning_effort: config.reasoning_effort.clone(),
|
||
stream: config.stream,
|
||
web_search_enabled: config.web_search_enabled,
|
||
context_window_tokens: config.context_window_tokens,
|
||
auto_compact_token_limit: config.auto_compact_token_limit,
|
||
tool_output_token_limit: config.tool_output_token_limit,
|
||
request_timeout_ms: config.request_timeout_ms,
|
||
max_retries: config.max_retries,
|
||
retry_backoff_ms: config.retry_backoff_ms,
|
||
error: status.error,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn validate_game_creator_llm_timing_config(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<(), String> {
|
||
if config.request_timeout_ms < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS {
|
||
return Err(format!(
|
||
"配置项 {config_path}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}"
|
||
));
|
||
}
|
||
if config.retry_backoff_ms == 0 {
|
||
return Err(format!("配置项 {config_path}.retryBackoffMs 必须大于 0"));
|
||
}
|
||
parse_game_creator_llm_reasoning_effort(&config.reasoning_effort)
|
||
.map_err(|error| format!("配置项 {config_path}.reasoningEffort 无效:{error}"))?;
|
||
validate_game_creator_llm_context_config(config, config_path)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn validate_game_creator_llm_context_config(
|
||
config: &GameCreatorLlmConfig,
|
||
config_path: &str,
|
||
) -> Result<(), String> {
|
||
const CONTEXT_SAFETY_MARGIN_TOKENS: u64 = 4_096;
|
||
if config.context_window_tokens == 0 {
|
||
return Err(format!(
|
||
"配置项 {config_path}.contextWindowTokens 必须大于 0"
|
||
));
|
||
}
|
||
if config.auto_compact_token_limit == 0 {
|
||
return Err(format!(
|
||
"配置项 {config_path}.autoCompactTokenLimit 必须大于 0"
|
||
));
|
||
}
|
||
if config.tool_output_token_limit == 0 {
|
||
return Err(format!(
|
||
"配置项 {config_path}.toolOutputTokenLimit 必须大于 0"
|
||
));
|
||
}
|
||
if config
|
||
.auto_compact_token_limit
|
||
.saturating_add(CONTEXT_SAFETY_MARGIN_TOKENS)
|
||
>= config.context_window_tokens
|
||
{
|
||
return Err(format!(
|
||
"配置项 {config_path}.autoCompactTokenLimit 必须至少为 contextWindowTokens 预留 {CONTEXT_SAFETY_MARGIN_TOKENS} tokens"
|
||
));
|
||
}
|
||
if config.tool_output_token_limit > config.auto_compact_token_limit {
|
||
return Err(format!(
|
||
"配置项 {config_path}.toolOutputTokenLimit 不能大于 autoCompactTokenLimit"
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String {
|
||
match api_kind {
|
||
LlmApiKind::OpenAiChat => "openai_chat",
|
||
LlmApiKind::OpenAiResponses => "openai_responses",
|
||
LlmApiKind::Anthropic => "anthropic",
|
||
}
|
||
.to_string()
|
||
}
|
||
|
||
pub(crate) fn game_creator_llm_reasoning_effort_name(
|
||
value: &str,
|
||
config_path: &str,
|
||
) -> Result<String, String> {
|
||
let normalized = value.trim().to_ascii_lowercase();
|
||
parse_game_creator_llm_reasoning_effort(&normalized)
|
||
.map_err(|error| format!("配置项 {config_path} 无效:{error}"))?;
|
||
Ok(normalized)
|
||
}
|
||
|
||
fn validate_game_creator_runtime_config_dir_metadata(
|
||
path: &Path,
|
||
tighten: bool,
|
||
initialize_windows_owner: bool,
|
||
) -> Result<(), String> {
|
||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||
format!(
|
||
"读取客户端 AppData 配置目录元数据失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||
let _ = initialize_windows_owner;
|
||
|
||
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
|
||
let effective_user_id = unsafe { libc::geteuid() };
|
||
if metadata.uid() != effective_user_id {
|
||
return Err("客户端 AppData 配置目录不属于当前用户".to_string());
|
||
}
|
||
if tighten {
|
||
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
|
||
format!(
|
||
"收紧客户端 AppData 配置目录权限失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
}
|
||
let verified = fs::symlink_metadata(path).map_err(|error| {
|
||
format!(
|
||
"复核客户端 AppData 配置目录失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
let mode = verified.permissions().mode() & 0o777;
|
||
if verified.uid() != effective_user_id || mode != 0o700 {
|
||
return Err(format!(
|
||
"客户端 AppData 配置目录必须由当前用户持有且权限为 0700,当前权限为 {mode:04o}"
|
||
));
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
|
||
}
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
true,
|
||
tighten,
|
||
initialize_windows_owner,
|
||
)?;
|
||
}
|
||
|
||
#[cfg(not(any(unix, windows)))]
|
||
{
|
||
let _ = tighten;
|
||
return Err("当前平台无法安全验证客户端 AppData 配置目录权限与 owner".to_string());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn resolve_game_creator_runtime_config_dir(
|
||
path: &Path,
|
||
create_and_tighten: bool,
|
||
) -> Result<PathBuf, String> {
|
||
if !path.is_absolute() {
|
||
return Err("客户端 AppData 配置目录必须是绝对路径".to_string());
|
||
}
|
||
let mut created = false;
|
||
if create_and_tighten {
|
||
match fs::symlink_metadata(path) {
|
||
Ok(_) => {}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).map_err(|create_error| {
|
||
format!(
|
||
"创建客户端 AppData 配置父目录失败:{}: {create_error}",
|
||
parent.display()
|
||
)
|
||
})?;
|
||
}
|
||
match fs::create_dir(path) {
|
||
Ok(()) => created = true,
|
||
// 与其他启动进程竞争时,不把对方创建的目录误判为本进程的新对象。
|
||
Err(create_error)
|
||
if create_error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||
Err(create_error) => {
|
||
return Err(format!(
|
||
"创建客户端 AppData 配置目录失败:{}: {create_error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"检查客户端 AppData 配置目录失败:{}: {error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
// canonicalize 会跟随目录链接,因此必须先检查用户给出的目录项本身。
|
||
validate_game_creator_runtime_config_dir_entry_type(path)?;
|
||
let canonical = fs::canonicalize(path).map_err(|error| {
|
||
format!(
|
||
"解析客户端 AppData 配置目录失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created)
|
||
{
|
||
Ok(()) => {}
|
||
#[cfg(windows)]
|
||
Err(error)
|
||
if create_and_tighten
|
||
&& !created
|
||
&& error.starts_with("Windows 安全对象不属于当前用户:") =>
|
||
{
|
||
let backup = migrate_windows_foreign_owner_config_dir(path)?;
|
||
fs::create_dir(path).map_err(|create_error| {
|
||
format!(
|
||
"旧 AppData 配置已安全保留在 {},但重新创建当前用户配置目录失败:{}: {create_error}",
|
||
backup.display(),
|
||
path.display()
|
||
)
|
||
})?;
|
||
validate_game_creator_runtime_config_dir_metadata(path, true, true).map_err(
|
||
|validation_error| {
|
||
format!(
|
||
"旧 AppData 配置已安全保留在 {},但新配置目录安全初始化失败:{validation_error}",
|
||
backup.display()
|
||
)
|
||
},
|
||
)?;
|
||
return fs::canonicalize(path).map_err(|canonicalize_error| {
|
||
format!(
|
||
"旧 AppData 配置已安全保留在 {},但解析新配置目录失败:{}: {canonicalize_error}",
|
||
backup.display(),
|
||
path.display()
|
||
)
|
||
});
|
||
}
|
||
Err(error) => return Err(error),
|
||
}
|
||
Ok(canonical)
|
||
}
|
||
|
||
fn validate_game_creator_runtime_config_dir_entry_type(path: &Path) -> Result<(), String> {
|
||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||
format!(
|
||
"读取客户端 AppData 配置目录元数据失败:{}: {error}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||
return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string());
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::MetadataExt;
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn migrate_windows_foreign_owner_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||
validate_game_creator_runtime_config_dir_entry_type(path)?;
|
||
let parent = path.parent().ok_or_else(|| {
|
||
format!(
|
||
"AppData 配置目录没有可用于安全迁移的父目录:{}",
|
||
path.display()
|
||
)
|
||
})?;
|
||
let name = path
|
||
.file_name()
|
||
.ok_or_else(|| format!("AppData 配置目录名称无效,无法安全迁移:{}", path.display()))?;
|
||
let timestamp = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_millis();
|
||
for attempt in 0..100_u32 {
|
||
let backup = parent.join(format!(
|
||
"{}.owner-mismatch-backup-{timestamp}-{}-{attempt}",
|
||
name.to_string_lossy(),
|
||
std::process::id()
|
||
));
|
||
match fs::symlink_metadata(&backup) {
|
||
Ok(_) => continue,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"检查旧 AppData 配置备份路径失败:{}: {error}",
|
||
backup.display()
|
||
));
|
||
}
|
||
}
|
||
// 同一父目录内 rename 是原子目录项替换;目标已确认不存在,旧配置不会被覆盖。
|
||
fs::rename(path, &backup).map_err(|error| {
|
||
format!(
|
||
"AppData 配置目录 owner 不匹配,无法安全迁移。请保留并手动恢复 {};计划备份路径为 {}:{error}",
|
||
path.display(),
|
||
backup.display()
|
||
)
|
||
})?;
|
||
return Ok(backup);
|
||
}
|
||
Err(format!(
|
||
"AppData 配置目录 owner 不匹配,但无法找到不冲突的备份路径;请手动保留并恢复 {}",
|
||
path.display()
|
||
))
|
||
}
|
||
|
||
pub(crate) fn prepare_game_creator_runtime_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||
resolve_game_creator_runtime_config_dir(path, true)
|
||
}
|
||
|
||
pub(crate) fn inspect_game_creator_runtime_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||
resolve_game_creator_runtime_config_dir(path, false)
|
||
}
|
||
|
||
pub(crate) fn validate_game_creator_runtime_config_dir_outside_project(
|
||
config_dir: &Path,
|
||
project_root: &Path,
|
||
) -> Result<(), String> {
|
||
if config_dir == project_root || config_dir.starts_with(project_root) {
|
||
return Err(
|
||
"--config-dir 必须是项目目录外的 AppData,不能等于项目或位于项目内".to_string(),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
tighten: bool,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path,
|
||
is_directory,
|
||
tighten,
|
||
false,
|
||
)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user(
|
||
path: &Path,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true)
|
||
}
|
||
|
||
/// Initialize ownership only for a directory that was created by the current
|
||
/// operation. Existing directories must use the strict verifier instead, so a
|
||
/// foreign-owned path is never silently adopted.
|
||
#[cfg(windows)]
|
||
pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user(
|
||
path: &Path,
|
||
) -> Result<(), String> {
|
||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true)
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
pub(crate) fn windows_private_dacl_security_information(
|
||
initialize_owner: bool,
|
||
owner_matches: bool,
|
||
) -> u32 {
|
||
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
||
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
|
||
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
|
||
|
||
DACL_SECURITY_INFORMATION
|
||
| PROTECTED_DACL_SECURITY_INFORMATION
|
||
| if initialize_owner && !owner_matches {
|
||
OWNER_SECURITY_INFORMATION
|
||
} else {
|
||
0
|
||
}
|
||
| if initialize_owner && !owner_matches {
|
||
OWNER_SECURITY_INFORMATION
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||
path: &Path,
|
||
is_directory: bool,
|
||
tighten: bool,
|
||
initialize_owner: bool,
|
||
) -> Result<(), String> {
|
||
use std::ffi::c_void;
|
||
use std::os::windows::ffi::OsStrExt;
|
||
|
||
type Handle = *mut c_void;
|
||
type Sid = *mut c_void;
|
||
|
||
#[repr(C)]
|
||
struct SidAndAttributes {
|
||
sid: Sid,
|
||
attributes: u32,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct TokenUser {
|
||
user: SidAndAttributes,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct TrusteeW {
|
||
multiple_trustee: *mut TrusteeW,
|
||
multiple_trustee_operation: i32,
|
||
trustee_form: i32,
|
||
trustee_type: i32,
|
||
name: *mut u16,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct ExplicitAccessW {
|
||
access_permissions: u32,
|
||
access_mode: i32,
|
||
inheritance: u32,
|
||
trustee: TrusteeW,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct Acl {
|
||
revision: u8,
|
||
reserved: u8,
|
||
size: u16,
|
||
ace_count: u16,
|
||
reserved2: u16,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct AceHeader {
|
||
ace_type: u8,
|
||
ace_flags: u8,
|
||
ace_size: u16,
|
||
}
|
||
|
||
#[repr(C)]
|
||
struct AccessAllowedAce {
|
||
header: AceHeader,
|
||
mask: u32,
|
||
sid_start: u32,
|
||
}
|
||
|
||
#[link(name = "advapi32")]
|
||
unsafe extern "system" {
|
||
fn GetNamedSecurityInfoW(
|
||
object_name: *mut u16,
|
||
object_type: u32,
|
||
security_info: u32,
|
||
owner: *mut Sid,
|
||
group: *mut Sid,
|
||
dacl: *mut *mut c_void,
|
||
sacl: *mut *mut c_void,
|
||
descriptor: *mut *mut c_void,
|
||
) -> u32;
|
||
fn SetNamedSecurityInfoW(
|
||
object_name: *mut u16,
|
||
object_type: u32,
|
||
security_info: u32,
|
||
owner: Sid,
|
||
group: Sid,
|
||
dacl: *mut c_void,
|
||
sacl: *mut c_void,
|
||
) -> u32;
|
||
fn SetEntriesInAclW(
|
||
entry_count: u32,
|
||
entries: *mut ExplicitAccessW,
|
||
old_acl: *mut c_void,
|
||
new_acl: *mut *mut c_void,
|
||
) -> u32;
|
||
fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32;
|
||
fn GetTokenInformation(
|
||
token: Handle,
|
||
information_class: u32,
|
||
information: *mut c_void,
|
||
information_length: u32,
|
||
return_length: *mut u32,
|
||
) -> i32;
|
||
fn EqualSid(first: Sid, second: Sid) -> i32;
|
||
fn IsValidSid(sid: Sid) -> i32;
|
||
fn IsValidAcl(acl: *mut c_void) -> i32;
|
||
fn GetAce(acl: *mut c_void, index: u32, ace: *mut *mut c_void) -> i32;
|
||
fn GetSecurityDescriptorControl(
|
||
descriptor: *mut c_void,
|
||
control: *mut u16,
|
||
revision: *mut u32,
|
||
) -> i32;
|
||
}
|
||
|
||
#[link(name = "kernel32")]
|
||
unsafe extern "system" {
|
||
fn GetCurrentProcess() -> Handle;
|
||
fn CloseHandle(handle: Handle) -> i32;
|
||
fn LocalFree(memory: *mut c_void) -> *mut c_void;
|
||
}
|
||
|
||
const SE_FILE_OBJECT: u32 = 1;
|
||
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
||
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
|
||
const SE_DACL_PROTECTED: u16 = 0x1000;
|
||
const TOKEN_QUERY: u32 = 0x0000_0008;
|
||
const TOKEN_USER_CLASS: u32 = 1;
|
||
const SET_ACCESS: i32 = 2;
|
||
const TRUSTEE_IS_SID: i32 = 0;
|
||
const TRUSTEE_IS_USER: i32 = 1;
|
||
const FILE_ALL_ACCESS: u32 = 0x001f_01ff;
|
||
const OBJECT_INHERIT_ACE: u8 = 0x01;
|
||
const CONTAINER_INHERIT_ACE: u8 = 0x02;
|
||
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0x00;
|
||
|
||
let mut token = std::ptr::null_mut();
|
||
// SAFETY: GetCurrentProcess returns a valid pseudo handle and token is a valid output pointer.
|
||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0
|
||
|| token.is_null()
|
||
{
|
||
return Err(format!(
|
||
"读取 Windows 当前用户 token 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
|
||
let result = (|| {
|
||
let mut required = 0_u32;
|
||
// SAFETY: the null query buffer is the documented size-probe call.
|
||
unsafe {
|
||
GetTokenInformation(
|
||
token,
|
||
TOKEN_USER_CLASS,
|
||
std::ptr::null_mut(),
|
||
0,
|
||
&mut required,
|
||
)
|
||
};
|
||
if required == 0 {
|
||
return Err("读取 Windows 当前用户 SID 长度失败".to_string());
|
||
}
|
||
let word_size = std::mem::size_of::<usize>();
|
||
let mut token_buffer = vec![0_usize; (required as usize).div_ceil(word_size)];
|
||
// SAFETY: the aligned token buffer is at least the probed TOKEN_USER size.
|
||
if unsafe {
|
||
GetTokenInformation(
|
||
token,
|
||
TOKEN_USER_CLASS,
|
||
token_buffer.as_mut_ptr().cast(),
|
||
required,
|
||
&mut required,
|
||
)
|
||
} == 0
|
||
{
|
||
return Err(format!(
|
||
"读取 Windows 当前用户 SID 失败:{}",
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
// SAFETY: GetTokenInformation populated TOKEN_USER at the aligned buffer start.
|
||
let current_user_sid = unsafe { (*(token_buffer.as_ptr().cast::<TokenUser>())).user.sid };
|
||
if current_user_sid.is_null() || unsafe { IsValidSid(current_user_sid) } == 0 {
|
||
return Err("Windows 当前用户 SID 无效".to_string());
|
||
}
|
||
|
||
let mut wide_path = path
|
||
.as_os_str()
|
||
.encode_wide()
|
||
.chain(std::iter::once(0))
|
||
.collect::<Vec<_>>();
|
||
let mut initial_owner = std::ptr::null_mut();
|
||
let mut initial_descriptor = std::ptr::null_mut();
|
||
// 先验证 owner,再修改 DACL,避免对其他用户持有的旧配置做任何权限变更。
|
||
let owner_status = unsafe {
|
||
GetNamedSecurityInfoW(
|
||
wide_path.as_mut_ptr(),
|
||
SE_FILE_OBJECT,
|
||
OWNER_SECURITY_INFORMATION,
|
||
&mut initial_owner,
|
||
std::ptr::null_mut(),
|
||
std::ptr::null_mut(),
|
||
std::ptr::null_mut(),
|
||
&mut initial_descriptor,
|
||
)
|
||
};
|
||
if owner_status != 0 || initial_owner.is_null() || initial_descriptor.is_null() {
|
||
if !initial_descriptor.is_null() {
|
||
unsafe { LocalFree(initial_descriptor) };
|
||
}
|
||
return Err(format!(
|
||
"读取 Windows owner 失败:{}: error {owner_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
let owner_matches = unsafe { IsValidSid(initial_owner) } != 0
|
||
&& unsafe { EqualSid(initial_owner, current_user_sid) } != 0;
|
||
unsafe { LocalFree(initial_descriptor) };
|
||
if !owner_matches {
|
||
if !(initialize_owner && tighten) {
|
||
return Err(format!(
|
||
"Windows 安全对象不属于当前用户:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
if tighten {
|
||
let mut entry = ExplicitAccessW {
|
||
access_permissions: FILE_ALL_ACCESS,
|
||
access_mode: SET_ACCESS,
|
||
inheritance: if is_directory {
|
||
(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u32
|
||
} else {
|
||
0
|
||
},
|
||
trustee: TrusteeW {
|
||
multiple_trustee: std::ptr::null_mut(),
|
||
multiple_trustee_operation: 0,
|
||
trustee_form: TRUSTEE_IS_SID,
|
||
trustee_type: TRUSTEE_IS_USER,
|
||
name: current_user_sid.cast(),
|
||
},
|
||
};
|
||
let mut private_dacl = std::ptr::null_mut();
|
||
// SAFETY: entry points at the current token SID for the duration of this call.
|
||
let acl_status =
|
||
unsafe { SetEntriesInAclW(1, &mut entry, std::ptr::null_mut(), &mut private_dacl) };
|
||
if acl_status != 0 || private_dacl.is_null() {
|
||
return Err(format!(
|
||
"构造 Windows 当前用户私有 DACL 失败:{}: error {acl_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
// SAFETY: path is NUL terminated and private_dacl was allocated by SetEntriesInAclW.
|
||
let should_initialize_owner = initialize_owner && !owner_matches;
|
||
let set_status = unsafe {
|
||
SetNamedSecurityInfoW(
|
||
wide_path.as_mut_ptr(),
|
||
SE_FILE_OBJECT,
|
||
windows_private_dacl_security_information(initialize_owner, owner_matches),
|
||
if should_initialize_owner {
|
||
current_user_sid
|
||
} else {
|
||
std::ptr::null_mut()
|
||
},
|
||
std::ptr::null_mut(),
|
||
private_dacl,
|
||
std::ptr::null_mut(),
|
||
)
|
||
};
|
||
// SAFETY: private_dacl was allocated by SetEntriesInAclW.
|
||
unsafe { LocalFree(private_dacl) };
|
||
if set_status != 0 {
|
||
return Err(format!(
|
||
"初始化 Windows 当前用户 owner/私有 DACL 失败:{}: error {set_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
|
||
let mut owner = std::ptr::null_mut();
|
||
let mut dacl = std::ptr::null_mut();
|
||
let mut descriptor = std::ptr::null_mut();
|
||
// SAFETY: all output pointers are valid and wide_path remains NUL terminated.
|
||
let security_status = unsafe {
|
||
GetNamedSecurityInfoW(
|
||
wide_path.as_mut_ptr(),
|
||
SE_FILE_OBJECT,
|
||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
||
&mut owner,
|
||
std::ptr::null_mut(),
|
||
&mut dacl,
|
||
std::ptr::null_mut(),
|
||
&mut descriptor,
|
||
)
|
||
};
|
||
if security_status != 0 || owner.is_null() || dacl.is_null() || descriptor.is_null() {
|
||
if !descriptor.is_null() {
|
||
// SAFETY: descriptor was allocated by GetNamedSecurityInfoW.
|
||
unsafe { LocalFree(descriptor) };
|
||
}
|
||
return Err(format!(
|
||
"读取 Windows owner/DACL 失败:{}: error {security_status}",
|
||
path.display()
|
||
));
|
||
}
|
||
|
||
let validation = (|| {
|
||
if unsafe { IsValidSid(owner) } == 0
|
||
|| unsafe { EqualSid(owner, current_user_sid) } == 0
|
||
{
|
||
return Err(format!(
|
||
"Windows 安全对象不属于当前用户:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
if unsafe { IsValidAcl(dacl) } == 0 {
|
||
return Err(format!("Windows DACL 无效:{}", path.display()));
|
||
}
|
||
let mut control = 0_u16;
|
||
let mut revision = 0_u32;
|
||
// SAFETY: descriptor is a valid self-relative security descriptor.
|
||
if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0
|
||
|| control & SE_DACL_PROTECTED == 0
|
||
{
|
||
return Err(format!(
|
||
"Windows DACL 必须禁止继承并仅限当前用户:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
// SAFETY: IsValidAcl succeeded, so its fixed ACL header is readable.
|
||
let acl = unsafe { &*(dacl.cast::<Acl>()) };
|
||
if acl.ace_count != 1 {
|
||
return Err(format!(
|
||
"Windows DACL 必须且只能包含当前用户 ACE:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
let mut ace = std::ptr::null_mut();
|
||
// SAFETY: dacl is valid and index zero exists because ace_count is one.
|
||
if unsafe { GetAce(dacl, 0, &mut ace) } == 0 || ace.is_null() {
|
||
return Err(format!("读取 Windows DACL ACE 失败:{}", path.display()));
|
||
}
|
||
// SAFETY: GetAce returned at least a valid ACE_HEADER from the validated ACL.
|
||
let header = unsafe { &*(ace.cast::<AceHeader>()) };
|
||
if header.ace_type != ACCESS_ALLOWED_ACE_TYPE
|
||
|| usize::from(header.ace_size) < std::mem::size_of::<AccessAllowedAce>()
|
||
{
|
||
return Err(format!(
|
||
"Windows DACL 当前用户 ACE 权限无效:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
// SAFETY: the ACE type and size now prove the full ACCESS_ALLOWED_ACE header exists.
|
||
let allowed = unsafe { &*(ace.cast::<AccessAllowedAce>()) };
|
||
if allowed.mask != FILE_ALL_ACCESS {
|
||
return Err(format!(
|
||
"Windows DACL 当前用户 ACE 权限无效:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
let required_inheritance = if is_directory {
|
||
OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
|
||
} else {
|
||
0
|
||
};
|
||
if allowed.header.ace_flags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)
|
||
!= required_inheritance
|
||
{
|
||
return Err(format!("Windows DACL 继承边界无效:{}", path.display()));
|
||
}
|
||
let ace_sid = std::ptr::addr_of!(allowed.sid_start)
|
||
.cast_mut()
|
||
.cast::<c_void>();
|
||
if unsafe { IsValidSid(ace_sid) } == 0
|
||
|| unsafe { EqualSid(ace_sid, current_user_sid) } == 0
|
||
{
|
||
return Err(format!("Windows DACL 含非当前用户 ACE:{}", path.display()));
|
||
}
|
||
Ok(())
|
||
})();
|
||
// SAFETY: descriptor was allocated by GetNamedSecurityInfoW.
|
||
unsafe { LocalFree(descriptor) };
|
||
validation
|
||
})();
|
||
|
||
// SAFETY: token was opened successfully above.
|
||
unsafe { CloseHandle(token) };
|
||
result
|
||
}
|
||
|
||
pub(crate) fn configure_game_creator_runtime_config_dir(
|
||
app: &tauri::AppHandle,
|
||
) -> Result<(), Box<dyn std::error::Error>> {
|
||
let requested_config_dir = game_creator_runtime_config_dir()
|
||
.map(Ok)
|
||
.unwrap_or_else(|| app.path().app_config_dir())?;
|
||
let config_dir = prepare_game_creator_runtime_config_dir(&requested_config_dir)
|
||
.map_err(std::io::Error::other)?;
|
||
let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||
if !config_path.exists() {
|
||
write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
|
||
.map_err(std::io::Error::other)?;
|
||
} else {
|
||
migrate_legacy_game_creator_agent_mode(&config_path).map_err(std::io::Error::other)?;
|
||
}
|
||
set_game_creator_runtime_config_dir(config_dir);
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn legacy_game_creator_agent_mode(
|
||
config: &GameCreatorAppConfigFile,
|
||
) -> Option<&'static str> {
|
||
if config.agent_mode.is_some() {
|
||
return None;
|
||
}
|
||
let responses_only = config
|
||
.llm
|
||
.as_ref()
|
||
.and_then(|llm| llm.api_kind.as_deref())
|
||
.map(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses")
|
||
.unwrap_or(true)
|
||
&& config
|
||
.agent_llm
|
||
.as_ref()
|
||
.into_iter()
|
||
.flat_map(|agents| agents.values())
|
||
.filter_map(|llm| llm.api_kind.as_deref())
|
||
.all(|kind| kind.trim().to_ascii_lowercase().replace('-', "_") == "openai_responses");
|
||
Some(if responses_only {
|
||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
|
||
} else {
|
||
GAME_CREATOR_AGENT_MODE_PROVIDER
|
||
})
|
||
}
|
||
|
||
fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> {
|
||
let content = fs::read_to_string(path)
|
||
.map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?;
|
||
let mut config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
|
||
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
|
||
let Some(agent_mode) = legacy_game_creator_agent_mode(&config) else {
|
||
return Ok(());
|
||
};
|
||
config.agent_mode = Some(agent_mode.to_string());
|
||
let content = serde_json::to_string_pretty(&config)
|
||
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||
write_game_creator_config_atomically(path, &format!("{content}\n"))
|
||
}
|
||
|
||
pub(crate) fn game_creator_runtime_config_dir_lock() -> &'static Mutex<Option<PathBuf>> {
|
||
GAME_CREATOR_RUNTIME_CONFIG_DIR.get_or_init(|| Mutex::new(None))
|
||
}
|
||
|
||
pub(crate) fn set_game_creator_runtime_config_dir(path: PathBuf) {
|
||
*game_creator_runtime_config_dir_lock()
|
||
.lock()
|
||
.expect("runtime config dir lock") = Some(path);
|
||
}
|
||
|
||
pub(crate) fn game_creator_runtime_config_dir() -> Option<PathBuf> {
|
||
game_creator_runtime_config_dir_lock()
|
||
.lock()
|
||
.expect("runtime config dir lock")
|
||
.clone()
|
||
}
|
||
|
||
pub(crate) fn load_game_creator_app_config() -> Result<GameCreatorAppConfig, String> {
|
||
let mut config = GameCreatorAppConfig::default();
|
||
for path in game_creator_config_paths() {
|
||
merge_game_creator_config_file(&mut config, &path)?;
|
||
}
|
||
Ok(config)
|
||
}
|
||
|
||
pub(crate) fn game_creator_app_config_view(
|
||
mut config: GameCreatorAppConfig,
|
||
) -> Result<GameCreatorAppConfigView, String> {
|
||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||
config.editor_api = GameCreatorEditorApiConfig::default();
|
||
}
|
||
Ok(GameCreatorAppConfigView {
|
||
path: writable_game_creator_config_path()?.display().to_string(),
|
||
config,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn serialize_game_creator_app_config_for_renderer_write(
|
||
config: &GameCreatorAppConfig,
|
||
) -> Result<String, String> {
|
||
let mut value =
|
||
serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?;
|
||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||
value
|
||
.as_object_mut()
|
||
.ok_or_else(|| "客户端配置必须是 JSON object".to_string())?
|
||
.remove("editorApi");
|
||
}
|
||
serde_json::to_string_pretty(&value).map_err(|error| format!("序列化客户端配置失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn writable_game_creator_config_path() -> Result<PathBuf, String> {
|
||
if let Some(config_dir) = game_creator_runtime_config_dir() {
|
||
return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME));
|
||
}
|
||
std::env::current_dir()
|
||
.map(|directory| directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME))
|
||
.map_err(|error| format!("读取当前目录失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn game_creator_config_paths() -> Vec<PathBuf> {
|
||
if let Some(config_dir) = game_creator_runtime_config_dir() {
|
||
return vec![
|
||
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
];
|
||
}
|
||
|
||
let mut roots = Vec::new();
|
||
if let Ok(cwd) = std::env::current_dir() {
|
||
roots.push(cwd);
|
||
}
|
||
if let Ok(exe) = std::env::current_exe() {
|
||
if let Some(parent) = exe.parent() {
|
||
roots.push(parent.to_path_buf());
|
||
}
|
||
}
|
||
|
||
let mut default_paths = Vec::new();
|
||
let mut local_paths = Vec::new();
|
||
for root in roots {
|
||
let ancestors = root.ancestors().take(8).collect::<Vec<_>>();
|
||
for directory in ancestors.into_iter().rev() {
|
||
push_unique_path(
|
||
&mut default_paths,
|
||
directory.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
);
|
||
push_unique_path(
|
||
&mut default_paths,
|
||
directory
|
||
.join("apps")
|
||
.join("ai-game-creator-shell")
|
||
.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
);
|
||
push_unique_path(
|
||
&mut local_paths,
|
||
directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
);
|
||
push_unique_path(
|
||
&mut local_paths,
|
||
directory
|
||
.join("apps")
|
||
.join("ai-game-creator-shell")
|
||
.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
);
|
||
}
|
||
}
|
||
default_paths.extend(local_paths);
|
||
default_paths
|
||
}
|
||
|
||
pub(crate) fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||
if !paths.iter().any(|existing| existing == &path) {
|
||
paths.push(path);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_config_file(
|
||
config: &mut GameCreatorAppConfig,
|
||
path: &Path,
|
||
) -> Result<(), String> {
|
||
let backup_path = game_creator_config_backup_path(path);
|
||
let read_path = if path.is_file() {
|
||
path
|
||
} else if backup_path.is_file() {
|
||
backup_path.as_path()
|
||
} else {
|
||
return Ok(());
|
||
};
|
||
let content = fs::read_to_string(read_path)
|
||
.map_err(|error| format!("读取客户端配置失败:{}: {error}", read_path.display()))?;
|
||
let file_config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
|
||
.map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?;
|
||
if let Some(agent_mode) = file_config.agent_mode {
|
||
config.agent_mode = agent_mode;
|
||
}
|
||
if let Some(llm) = file_config.llm {
|
||
merge_game_creator_llm_config(&mut config.llm, llm);
|
||
}
|
||
if let Some(agent_llm) = file_config.agent_llm {
|
||
for (agent_id, patch) in agent_llm {
|
||
let entry = config.agent_llm.entry(agent_id).or_default();
|
||
merge_game_creator_llm_patch(entry, patch);
|
||
}
|
||
}
|
||
if let Some(editor_api) = file_config.editor_api {
|
||
merge_game_creator_editor_api_config(&mut config.editor_api, editor_api);
|
||
}
|
||
if let Some(mcp_servers) = file_config.mcp_servers {
|
||
for (server_id, server) in mcp_servers {
|
||
config.mcp_servers.insert(server_id, server);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn game_creator_config_backup_path(path: &Path) -> PathBuf {
|
||
path.with_file_name(format!(
|
||
".{}.previous",
|
||
path.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
))
|
||
}
|
||
|
||
pub(crate) fn write_game_creator_config_atomically(
|
||
path: &Path,
|
||
content: &str,
|
||
) -> Result<(), String> {
|
||
let parent = path
|
||
.parent()
|
||
.ok_or_else(|| "客户端配置缺少父目录".to_string())?;
|
||
fs::create_dir_all(parent)
|
||
.map_err(|error| format!("创建客户端配置目录失败:{}: {error}", parent.display()))?;
|
||
let temp_path = path.with_file_name(format!(
|
||
".{}.tmp.{}.{}",
|
||
path.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
std::process::id(),
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos()
|
||
));
|
||
let mut options = fs::OpenOptions::new();
|
||
options.create_new(true).write(true);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
options.mode(0o600);
|
||
}
|
||
let mut file = options.open(&temp_path).map_err(|error| {
|
||
format!(
|
||
"创建客户端配置临时文件失败:{}: {error}",
|
||
temp_path.display()
|
||
)
|
||
})?;
|
||
let write_result = file
|
||
.write_all(content.as_bytes())
|
||
.and_then(|_| file.sync_all());
|
||
drop(file);
|
||
if let Err(error) = write_result {
|
||
let _ = fs::remove_file(&temp_path);
|
||
return Err(format!(
|
||
"写入客户端配置临时文件失败:{}: {error}",
|
||
temp_path.display()
|
||
));
|
||
}
|
||
|
||
match fs::rename(&temp_path, path) {
|
||
Ok(()) => {
|
||
let _ = fs::remove_file(game_creator_config_backup_path(path));
|
||
Ok(())
|
||
}
|
||
Err(replace_error) => {
|
||
let backup_path = game_creator_config_backup_path(path);
|
||
if path.exists() {
|
||
let _ = fs::remove_file(&backup_path);
|
||
fs::rename(path, &backup_path).map_err(|error| {
|
||
let _ = fs::remove_file(&temp_path);
|
||
format!(
|
||
"准备替换客户端配置失败:{} -> {}: {error}",
|
||
path.display(),
|
||
backup_path.display()
|
||
)
|
||
})?;
|
||
}
|
||
match fs::rename(&temp_path, path) {
|
||
Ok(()) => {
|
||
let _ = fs::remove_file(&backup_path);
|
||
Ok(())
|
||
}
|
||
Err(error) => {
|
||
let restore_error = if backup_path.exists() {
|
||
fs::rename(&backup_path, path).err()
|
||
} else {
|
||
None
|
||
};
|
||
let _ = fs::remove_file(&temp_path);
|
||
let restore_detail = restore_error
|
||
.map(|error| format!(";恢复旧配置失败:{error}"))
|
||
.unwrap_or_default();
|
||
Err(format!(
|
||
"替换客户端配置失败:{replace_error};重试失败:{error}{restore_detail}"
|
||
))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_llm_config(
|
||
config: &mut GameCreatorLlmConfig,
|
||
patch: GameCreatorLlmConfigFile,
|
||
) {
|
||
if let Some(value) = patch.api_key {
|
||
config.api_key = value;
|
||
}
|
||
if let Some(value) = patch.base_url {
|
||
config.base_url = value;
|
||
}
|
||
if let Some(value) = patch.model {
|
||
config.model = value;
|
||
}
|
||
if let Some(value) = patch.api_kind {
|
||
config.api_kind = value;
|
||
}
|
||
if let Some(value) = patch.reasoning_effort {
|
||
config.reasoning_effort = value;
|
||
}
|
||
if let Some(value) = patch.stream {
|
||
config.stream = value;
|
||
}
|
||
if let Some(value) = patch.web_search_enabled {
|
||
config.web_search_enabled = value;
|
||
}
|
||
if let Some(value) = patch.context_window_tokens {
|
||
config.context_window_tokens = value;
|
||
}
|
||
if let Some(value) = patch.auto_compact_token_limit {
|
||
config.auto_compact_token_limit = value;
|
||
}
|
||
if let Some(value) = patch.tool_output_token_limit {
|
||
config.tool_output_token_limit = value;
|
||
}
|
||
if let Some(value) = patch.request_timeout_ms {
|
||
config.request_timeout_ms = value;
|
||
}
|
||
if let Some(value) = patch.max_retries {
|
||
config.max_retries = value;
|
||
}
|
||
if let Some(value) = patch.retry_backoff_ms {
|
||
config.retry_backoff_ms = value;
|
||
}
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_llm_patch(
|
||
config: &mut GameCreatorLlmConfigFile,
|
||
patch: GameCreatorLlmConfigFile,
|
||
) {
|
||
if let Some(value) = patch.api_key {
|
||
config.api_key = Some(value);
|
||
}
|
||
if let Some(value) = patch.base_url {
|
||
config.base_url = Some(value);
|
||
}
|
||
if let Some(value) = patch.model {
|
||
config.model = Some(value);
|
||
}
|
||
if let Some(value) = patch.api_kind {
|
||
config.api_kind = Some(value);
|
||
}
|
||
if let Some(value) = patch.reasoning_effort {
|
||
config.reasoning_effort = Some(value);
|
||
}
|
||
if let Some(value) = patch.stream {
|
||
config.stream = Some(value);
|
||
}
|
||
if let Some(value) = patch.web_search_enabled {
|
||
config.web_search_enabled = Some(value);
|
||
}
|
||
if let Some(value) = patch.context_window_tokens {
|
||
config.context_window_tokens = Some(value);
|
||
}
|
||
if let Some(value) = patch.auto_compact_token_limit {
|
||
config.auto_compact_token_limit = Some(value);
|
||
}
|
||
if let Some(value) = patch.tool_output_token_limit {
|
||
config.tool_output_token_limit = Some(value);
|
||
}
|
||
if let Some(value) = patch.request_timeout_ms {
|
||
config.request_timeout_ms = Some(value);
|
||
}
|
||
if let Some(value) = patch.max_retries {
|
||
config.max_retries = Some(value);
|
||
}
|
||
if let Some(value) = patch.retry_backoff_ms {
|
||
config.retry_backoff_ms = Some(value);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn resolve_game_creator_llm_config_for_agent(
|
||
config: &GameCreatorAppConfig,
|
||
agent_id: &str,
|
||
) -> GameCreatorLlmConfig {
|
||
let mut llm = config.llm.clone();
|
||
if let Some(reasoning_effort) = game_creator_llm_agent_default_reasoning_effort(agent_id) {
|
||
llm.reasoning_effort = reasoning_effort.to_string();
|
||
}
|
||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||
if let Some(patch) = config
|
||
.agent_llm
|
||
.get(GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID)
|
||
{
|
||
merge_game_creator_llm_config(&mut llm, patch.clone());
|
||
}
|
||
}
|
||
if let Some(patch) = config.agent_llm.get(agent_id) {
|
||
merge_game_creator_llm_config(&mut llm, patch.clone());
|
||
}
|
||
llm
|
||
}
|
||
|
||
pub(crate) fn merge_game_creator_editor_api_config(
|
||
config: &mut GameCreatorEditorApiConfig,
|
||
patch: GameCreatorEditorApiConfigFile,
|
||
) {
|
||
if let Some(value) = patch.base_url {
|
||
config.base_url = value;
|
||
}
|
||
if let Some(value) = patch.api_key {
|
||
config.api_key = value;
|
||
}
|
||
}
|
||
|
||
pub(crate) fn trim_config_string(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
None
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
}
|
||
|
||
pub(crate) fn normalize_game_creator_app_config(
|
||
mut config: GameCreatorAppConfig,
|
||
) -> Result<GameCreatorAppConfig, String> {
|
||
config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?;
|
||
config.llm.api_key = config.llm.api_key.trim().to_string();
|
||
config.llm.base_url =
|
||
trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?;
|
||
config.llm.model =
|
||
trim_config_string(&config.llm.model).ok_or_else(|| llm_model_config_error("llm"))?;
|
||
config.llm.api_kind =
|
||
game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind(&config.llm.api_kind)?);
|
||
validate_game_creator_llm_web_search_config(&config.llm, "llm")?;
|
||
config.llm.reasoning_effort = game_creator_llm_reasoning_effort_name(
|
||
&config.llm.reasoning_effort,
|
||
"llm.reasoningEffort",
|
||
)?;
|
||
validate_game_creator_llm_timing_config(&config.llm, "llm")?;
|
||
let mut agent_llm = BTreeMap::new();
|
||
for (agent_id, patch) in config.agent_llm {
|
||
let agent_id = match trim_config_string(&agent_id) {
|
||
Some(value) => value,
|
||
None => continue,
|
||
};
|
||
let patch = normalize_game_creator_llm_patch_config(&agent_id, patch)?;
|
||
if !is_empty_game_creator_llm_patch(&patch) {
|
||
agent_llm.insert(agent_id, patch);
|
||
}
|
||
}
|
||
config.agent_llm = agent_llm;
|
||
for agent_id in config.agent_llm.keys() {
|
||
let llm = resolve_game_creator_llm_config_for_agent(&config, agent_id);
|
||
validate_game_creator_llm_web_search_config(&llm, &format!("agentLlm.{agent_id}"))?;
|
||
validate_game_creator_llm_timing_config(&llm, &format!("agentLlm.{agent_id}"))?;
|
||
}
|
||
config.editor_api.base_url = trim_config_string(&config.editor_api.base_url)
|
||
.ok_or_else(|| "配置项 editorApi.baseUrl 不能为空".to_string())?;
|
||
config.editor_api.api_key = config.editor_api.api_key.trim().to_string();
|
||
config.mcp_servers = normalize_game_creator_mcp_servers(config.mcp_servers)?;
|
||
Ok(config)
|
||
}
|
||
|
||
pub(crate) fn normalize_game_creator_agent_mode(value: &str) -> Result<String, String> {
|
||
match value.trim() {
|
||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => {
|
||
Ok(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string())
|
||
}
|
||
GAME_CREATOR_AGENT_MODE_CODEX_CLI => Ok(GAME_CREATOR_AGENT_MODE_CODEX_CLI.to_string()),
|
||
GAME_CREATOR_AGENT_MODE_PROVIDER => Ok(GAME_CREATOR_AGENT_MODE_PROVIDER.to_string()),
|
||
value => Err(format!(
|
||
"配置项 agentMode 无效:{value},请使用 codex_app_server、codex_cli 或 provider"
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn normalize_game_creator_llm_patch_config(
|
||
agent_id: &str,
|
||
mut patch: GameCreatorLlmConfigFile,
|
||
) -> Result<GameCreatorLlmConfigFile, String> {
|
||
patch.api_key = patch.api_key.and_then(|value| trim_config_string(&value));
|
||
patch.base_url = patch.base_url.and_then(|value| trim_config_string(&value));
|
||
patch.model = patch.model.and_then(|value| trim_config_string(&value));
|
||
patch.api_kind = match patch.api_kind {
|
||
Some(value) => Some(game_creator_llm_api_kind_name(
|
||
parse_game_creator_llm_api_kind(&value)
|
||
.map_err(|error| format!("配置项 agentLlm.{agent_id}.apiKind 无效:{error}"))?,
|
||
)),
|
||
None => None,
|
||
};
|
||
patch.reasoning_effort = match patch.reasoning_effort {
|
||
Some(value) => Some(game_creator_llm_reasoning_effort_name(
|
||
&value,
|
||
&format!("agentLlm.{agent_id}.reasoningEffort"),
|
||
)?),
|
||
None => None,
|
||
};
|
||
if patch
|
||
.request_timeout_ms
|
||
.is_some_and(|value| value < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS)
|
||
{
|
||
return Err(format!(
|
||
"配置项 agentLlm.{agent_id}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}"
|
||
));
|
||
}
|
||
if patch.retry_backoff_ms.is_some_and(|value| value == 0) {
|
||
return Err(format!(
|
||
"配置项 agentLlm.{agent_id}.retryBackoffMs 必须大于 0"
|
||
));
|
||
}
|
||
for (field, value) in [
|
||
("contextWindowTokens", patch.context_window_tokens),
|
||
("autoCompactTokenLimit", patch.auto_compact_token_limit),
|
||
("toolOutputTokenLimit", patch.tool_output_token_limit),
|
||
] {
|
||
if value.is_some_and(|value| value == 0) {
|
||
return Err(format!("配置项 agentLlm.{agent_id}.{field} 必须大于 0"));
|
||
}
|
||
}
|
||
Ok(patch)
|
||
}
|
||
|
||
pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) -> bool {
|
||
patch.api_key.is_none()
|
||
&& patch.base_url.is_none()
|
||
&& patch.model.is_none()
|
||
&& patch.api_kind.is_none()
|
||
&& patch.reasoning_effort.is_none()
|
||
&& patch.stream.is_none()
|
||
&& patch.web_search_enabled.is_none()
|
||
&& patch.context_window_tokens.is_none()
|
||
&& patch.auto_compact_token_limit.is_none()
|
||
&& patch.tool_output_token_limit.is_none()
|
||
&& patch.request_timeout_ms.is_none()
|
||
&& patch.max_retries.is_none()
|
||
&& patch.retry_backoff_ms.is_none()
|
||
}
|
||
|
||
pub(crate) fn llm_api_key_config_error(config_path: &str) -> String {
|
||
format!(
|
||
"LLM 未配置:请在 {} 的 {config_path}.apiKey 中设置 API Key",
|
||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
)
|
||
}
|
||
|
||
pub(crate) fn llm_base_url_config_error(config_path: &str) -> String {
|
||
format!(
|
||
"LLM base_url 未配置:请在 {} 的 {config_path}.baseUrl 中设置",
|
||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
)
|
||
}
|
||
|
||
pub(crate) fn llm_model_config_error(config_path: &str) -> String {
|
||
format!(
|
||
"LLM model 未配置:请在 {} 的 {config_path}.model 中设置",
|
||
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
)
|
||
}
|
||
|
||
pub(crate) fn game_creator_config_file_label(file_name: &str) -> String {
|
||
game_creator_runtime_config_dir()
|
||
.map(|directory| directory.join(file_name).display().to_string())
|
||
.unwrap_or_else(|| file_name.to_string())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod anthropic_strict_capability_tests {
|
||
use super::*;
|
||
|
||
fn anthropic_config(base_url: &str, model: &str) -> GameCreatorLlmConfig {
|
||
GameCreatorLlmConfig {
|
||
api_key: "test-key".to_string(),
|
||
base_url: base_url.to_string(),
|
||
model: model.to_string(),
|
||
api_kind: "anthropic".to_string(),
|
||
web_search_enabled: false,
|
||
..GameCreatorLlmConfig::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn official_supported_claude_model_opts_in_to_anthropic_strict_tools() {
|
||
for model in [
|
||
"claude-sonnet-4-5-20250929",
|
||
"claude-opus-4-6",
|
||
"claude-haiku-5",
|
||
] {
|
||
let config = build_game_creator_platform_llm_config(
|
||
&anthropic_config("https://api.anthropic.com", model),
|
||
"llm",
|
||
)
|
||
.expect("supported official Anthropic config");
|
||
assert!(config.anthropic_strict_tool_support(), "model={model}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn old_models_and_compatible_or_lookalike_endpoints_keep_strict_disabled() {
|
||
for (base_url, model) in [
|
||
("https://api.anthropic.com", "claude-3-5-sonnet-latest"),
|
||
("https://api.anthropic.com", "claude-sonnet-latest"),
|
||
("https://minimax.example.com", "claude-sonnet-4-5"),
|
||
("https://api.anthropic.com.example.com", "claude-sonnet-4-5"),
|
||
("http://api.anthropic.com", "claude-sonnet-4-5"),
|
||
] {
|
||
let config =
|
||
build_game_creator_platform_llm_config(&anthropic_config(base_url, model), "llm")
|
||
.expect("non-capable Anthropic config remains usable without strict");
|
||
assert!(
|
||
!config.anthropic_strict_tool_support(),
|
||
"base_url={base_url}, model={model}"
|
||
);
|
||
}
|
||
}
|
||
}
|