Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/config.rs
T
Git Hooks Test c98bf4a17a
Project CI / Native shell tests (push) Failing after 2m9s
Project CI / Repository checks (push) Successful in 4m23s
Project CI / Frontend tests (push) Successful in 4m34s
Project CI / Backend tests (push) Successful in 5m59s
新增AGC受控联网搜索工具
在DirectProject的agc_tools MCP中按webSearchEnabled开关新增agc_web_search工具。

通过客户端loopback工具桥固定访问Bing RSS并返回有界公开HTTPS结果。

保持Codex原生webSearch禁用,并同步搜索开关到系统提示词与连接池隔离边界。

补充MCP目录、参数校验、结果过滤、真实搜索链路和配置回归测试,并更新技术文档。
2026-08-23 16:04:25 +08:00

1890 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
));
}
None
}
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}"
);
}
}
}