Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/config.rs
T
kdletters fc14190f58
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
图集切片模式改为必须显式声明并补齐决策要求 (#408)
## 背景

切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。

## 变更

- 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。
- 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。
- AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。
- 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。
- 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。
- 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。
- 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。

## 兼容性影响

省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。

## 验证

- 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。
- AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。
- 前端:182 条定向测试与 `typecheck` 通过。
- 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。
- 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408
2026-09-17 18:10:34 +08:00

5048 lines
190 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::*;
#[cfg(windows)]
use std::collections::HashMap;
#[cfg(windows)]
use std::time::Instant;
#[cfg(windows)]
const GAME_CREATOR_USER_SELECTED_PATH_GRANT_TTL: Duration = Duration::from_secs(300);
#[cfg(windows)]
#[derive(Clone, Copy)]
struct UserSelectedPathGrant {
is_directory: bool,
expires_at: Instant,
}
#[cfg(windows)]
static GAME_CREATOR_USER_SELECTED_PATH_GRANTS: OnceLock<
Mutex<HashMap<String, UserSelectedPathGrant>>,
> = OnceLock::new();
#[cfg(windows)]
fn user_selected_path_grants() -> &'static Mutex<HashMap<String, UserSelectedPathGrant>> {
GAME_CREATOR_USER_SELECTED_PATH_GRANTS.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg(windows)]
fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
let path = normalize_windows_policy_path(path);
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return None;
}
Some(
path.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase(),
)
}
#[cfg(windows)]
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
let value = path.to_string_lossy();
if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
}
#[cfg(not(windows))]
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
path.to_path_buf()
}
#[cfg(windows)]
pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) {
let Some(key) = normalize_user_selected_path_key(path) else {
return;
};
let mut grants = user_selected_path_grants()
.lock()
.expect("user-selected path grants lock");
grants.retain(|_, grant| grant.expires_at > Instant::now());
grants.insert(
key,
UserSelectedPathGrant {
is_directory,
expires_at: Instant::now() + GAME_CREATOR_USER_SELECTED_PATH_GRANT_TTL,
},
);
}
#[cfg(windows)]
pub(crate) fn revoke_game_creator_user_selected_path(path: &Path) {
let Some(key) = normalize_user_selected_path_key(path) else {
return;
};
user_selected_path_grants()
.lock()
.expect("user-selected path grants lock")
.remove(&key);
}
#[cfg(windows)]
fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool {
let Some(key) = normalize_user_selected_path_key(path) else {
return false;
};
let mut grants = user_selected_path_grants()
.lock()
.expect("user-selected path grants lock");
let now = Instant::now();
grants.retain(|_, grant| grant.expires_at > now);
grants.iter().any(|(granted_key, grant)| {
if granted_key == &key {
return grant.is_directory == is_directory;
}
grant.is_directory
&& key
.strip_prefix(granted_key)
.is_some_and(|suffix| suffix.starts_with('\\'))
})
}
pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1";
/// 官方路由未选择平台目录模型时写入配置文件的占位标识。
pub(crate) const OFFICIAL_LLM_ROUTER_DEFAULT_MODEL: &str = "platform-default";
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> {
if llm.custom_enabled {
return build_game_creator_provider_llm_config(llm, config_path);
}
if game_creator_official_llm_route_locked() {
return build_game_creator_official_platform_llm_config(llm);
}
if debug_provider_e2e_route_unlocked() {
return build_game_creator_provider_llm_config(llm, config_path);
}
#[cfg(not(test))]
{
return Err("AGC 正式运行只允许通过 API Server 使用官方 LLM Router".to_string());
}
#[cfg(test)]
build_game_creator_provider_llm_config(llm, config_path)
}
fn build_game_creator_provider_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}"))
}
/// Normal AGC builds never receive a Router key. Direct Rust LLM helpers
/// (for example the UI editor/resource editor paths) therefore use the same
/// authenticated API-server proxy as the Codex app-server bridge. The
/// platform access token remains in this process only; it is never serialized
/// into the AGC config or passed to child processes.
fn build_game_creator_official_platform_llm_config(
llm: &GameCreatorLlmConfig,
) -> Result<LlmConfig, String> {
let session = current_platform_session()
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
let api_base_url = session.api_base_url.trim_end_matches('/');
if api_base_url.is_empty() {
return Err("登录态缺少 API Server 地址".to_string());
}
let proxy_base_url = format!("{api_base_url}/api/llm");
LlmConfig::new(
LlmProvider::OpenAiCompatible,
proxy_base_url,
session.access_token,
llm.model.clone(),
llm.request_timeout_ms,
llm.max_retries,
llm.retry_backoff_ms,
)
.map(|config| config.with_agc_client_marker(true))
.map_err(|error| format!("官方 LLM Router 代理配置无效:{error}"))
}
pub(crate) 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,
account_credential_state: "unavailable".to_string(),
official_route_locked: game_creator_official_llm_route_locked(),
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
stream: true,
web_search_enabled: true,
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");
if let Err(error) = parse_game_creator_llm_api_kind(&app_config.llm.api_kind) {
status.configured = false;
status.error = Some(error);
}
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();
if !app_config.llm.custom_enabled && game_creator_official_llm_route_locked() {
let account_ready = current_platform_session().is_some();
status.account_credential_state = if account_ready {
"ready".to_string()
} else {
"login_required".to_string()
};
status.official_route_locked = true;
// The status endpoint is safe metadata only. Never echo a legacy
// user-supplied URL/model that was present before the official route
// migration; expose the immutable route instead.
status.configured = cli_error.is_none() && global_route_error.is_none() && account_ready;
status.error = cli_error.clone().or(global_route_error).or_else(|| {
(!account_ready).then(|| "authentication-required: 请先登录陶泥儿账号".to_string())
});
} else {
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);
if !llm.custom_enabled && game_creator_official_llm_route_locked() {
agent.account_credential_state = status.account_credential_state.clone();
agent.official_route_locked = true;
agent.configured = status.configured;
agent.error = status.error.clone();
}
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.custom_enabled {
if let Err(error) = validate_custom_llm_connection(llm) {
return Some(error);
}
if !llm.visible_models.contains(&llm.model) {
return Some("请至少勾选一个模型,并从已勾选列表选择模型".to_string());
}
}
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_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(),
account_credential_state: if error.is_none() {
"not_required"
} else {
"unavailable"
}
.to_string(),
official_route_locked: !config.custom_enabled && game_creator_official_llm_route_locked(),
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);
if let Err(error) = parse_game_creator_llm_api_kind(&config.api_kind) {
status.configured = false;
status.error = Some(error);
}
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,
account_credential_state: status.account_credential_state.clone(),
official_route_locked: status.official_route_locked,
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(())
}
/// Checks every already-existing path component without following links.
/// `canonicalize` alone is insufficient here because it resolves a junction
/// before the caller gets a chance to apply the owner/DACL policy.
pub(crate) fn validate_game_creator_private_path_ancestors(
path: &Path,
label: &str,
) -> Result<(), String> {
if !path.is_absolute() {
return Err(format!("{label}必须是绝对路径"));
}
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
match fs::symlink_metadata(ancestor) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(format!(
"{label} 路径不能包含符号链接:{}",
ancestor.display()
));
}
#[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(format!(
"{label} 路径不能包含 Windows reparse point{}",
ancestor.display()
));
}
}
if ancestor != path && !metadata.is_dir() {
return Err(format!(
"{label} 父路径必须是普通目录:{}",
ancestor.display()
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取 {label} 路径元数据失败:{}: {error}",
ancestor.display()
));
}
}
}
Ok(())
}
/// Automatic ACL repair for managed paths is limited to objects AGC owns. A
/// separate, explicit user-selected scope below covers native picker/project
/// root results, including projects stored outside the current profile.
fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
let path = normalize_windows_policy_path(path);
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return false;
}
let starts_with_path = |root: &Path| {
let root = normalize_windows_policy_path(root);
path == root || path.starts_with(root)
};
if game_creator_runtime_config_dir()
.as_deref()
.is_some_and(starts_with_path)
{
return true;
}
if let Some(home) = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.filter(|candidate| candidate.is_absolute())
{
let credentials_root = home.join(".config").join("genarrative");
if starts_with_path(&credentials_root) {
return true;
}
// The Tauri release uses this stable per-user AppData directory. The
// elevated helper runs in a fresh process, so the in-memory runtime
// config-dir override is unavailable there; recognize the packaged
// path from the user's profile as well.
let packaged_app_data = home
.join("AppData")
.join("Local")
.join("world.genarrative.ai-game-creator");
if starts_with_path(&packaged_app_data) {
return true;
}
}
for environment_name in ["LOCALAPPDATA", "APPDATA"] {
if let Some(root) = std::env::var_os(environment_name)
.map(PathBuf::from)
.filter(|candidate| candidate.is_absolute())
{
if starts_with_path(&root.join("world.genarrative.ai-game-creator")) {
return true;
}
}
}
// A bare `.agent` component is not enough to authorize ownership repair:
// an arbitrary user-selected path can contain a directory with that name.
// An existing AGC marker establishes the managed project root, after
// which every regular descendant (for example `game/index.html`) is
// covered by the same repair boundary. New projects use the explicit
// project-root preparation entry below until their marker is written.
// Nested or unrelated `.agent` directories remain outside the boundary.
let agent_components = path
.components()
.filter_map(|component| match component {
std::path::Component::Normal(name)
if name.to_string_lossy().eq_ignore_ascii_case(".agent") =>
{
Some(name.to_os_string())
}
_ => None,
})
.collect::<Vec<_>>();
if agent_components.len() > 1 {
return false;
}
for project_root in path.ancestors() {
let root_metadata = match fs::symlink_metadata(project_root) {
Ok(metadata) => metadata,
Err(_) => continue,
};
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
continue;
}
let agent_directory = project_root.join(".agent");
let agent_metadata = match fs::symlink_metadata(&agent_directory) {
Ok(metadata) => metadata,
Err(_) => continue,
};
let manifest_path = agent_directory.join("manifest.json");
let manifest_metadata = match fs::symlink_metadata(&manifest_path) {
Ok(metadata) => metadata,
Err(_) => continue,
};
if agent_metadata.file_type().is_symlink()
|| !agent_metadata.is_dir()
|| manifest_metadata.file_type().is_symlink()
|| !manifest_metadata.is_file()
{
continue;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
if root_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|| agent_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|| manifest_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
{
continue;
}
}
if let Some(agent_name) = agent_components.first() {
let Some(actual_agent_directory) = path.ancestors().find(|candidate| {
candidate
.file_name()
.is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(".agent"))
}) else {
continue;
};
if actual_agent_directory != &agent_directory
|| actual_agent_directory
.file_name()
.map(|name| name != agent_name)
.unwrap_or(true)
{
continue;
}
}
return true;
}
false
}
/// A file-picker/project-root result is an explicit native user action. Once
/// the caller has crossed that boundary, a regular object may be repaired by
/// the one-shot UAC helper even when it lives outside the current profile
/// (projects are commonly stored on another drive). The normal
/// reparse/regular-object/ancestor-type checks still run before this predicate
/// is consulted, so links, junctions and non-regular objects never become
/// elevation targets.
#[cfg(windows)]
fn game_creator_user_selected_path_allows_auto_elevation(path: &Path) -> bool {
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return false;
}
// Never treat a drive/UNC root as a user file. This also prevents an
// unreadable ancestor walk from turning a picker selection into ownership
// repair of the entire volume root. A native picker can technically
// return Windows/Program Files paths, but taking ownership there would
// damage the operating system rather than repair an AGC user file.
if path.as_os_str().is_empty() || path.file_name().is_none() {
return false;
}
let normalized = path
.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase();
for variable in [
"WINDIR",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMDATA",
"COMMONPROGRAMFILES",
"COMMONPROGRAMFILES(X86)",
] {
let Some(root) = std::env::var_os(variable).map(PathBuf::from) else {
continue;
};
let root = root
.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase();
if normalized == root || normalized.starts_with(&(root + "\\")) {
return false;
}
}
true
}
#[cfg(windows)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum WindowsAclRepairScope {
Managed,
UserSelected,
}
#[cfg(windows)]
impl WindowsAclRepairScope {
fn wire_name(self) -> &'static str {
match self {
Self::Managed => "managed",
Self::UserSelected => "user-selected",
}
}
fn allows_path(self, path: &Path) -> bool {
match self {
Self::Managed => game_creator_private_path_allows_auto_elevation(path),
Self::UserSelected => game_creator_user_selected_path_allows_auto_elevation(path),
}
}
}
#[cfg(windows)]
pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result<WindowsAclRepairScope, String> {
match value.trim() {
"managed" => Ok(WindowsAclRepairScope::Managed),
"user-selected" => Ok(WindowsAclRepairScope::UserSelected),
_ => Err("AGC ACL 修复 scope 无效".to_string()),
}
}
#[cfg(windows)]
fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScope {
let path = normalize_windows_policy_path(path);
let is_builtin_root = |root: PathBuf| path == root || path.starts_with(root);
if let Some(home) = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.filter(|candidate| candidate.is_absolute())
{
if is_builtin_root(home.join(".config").join("genarrative"))
|| is_builtin_root(
home.join("AppData")
.join("Local")
.join("world.genarrative.ai-game-creator"),
)
{
return WindowsAclRepairScope::Managed;
}
}
for environment_name in ["LOCALAPPDATA", "APPDATA"] {
if let Some(root) = std::env::var_os(environment_name)
.map(PathBuf::from)
.filter(|candidate| candidate.is_absolute())
{
if is_builtin_root(root.join("world.genarrative.ai-game-creator")) {
return WindowsAclRepairScope::Managed;
}
}
}
WindowsAclRepairScope::UserSelected
}
/// Prepares a caller-selected AGC project root. The root itself has no
/// `.agent/manifest.json` yet during first initialization, so it cannot use
/// the marker-based auto-elevation predicate above. This explicit entry is
/// only called by project initialization and the Runner, where the path has
/// already been accepted as the workspace root; descendants remain subject
/// to the marker-based managed-root check.
pub(crate) fn prepare_game_creator_project_root_for_read(
path: &Path,
is_directory: bool,
label: &str,
) -> Result<bool, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display());
#[cfg(windows)]
{
// A selected project root may be unreadable before the leaf
// metadata can be inspected. Keep the same explicit
// user-selected scope and one-shot UAC path used after the
// metadata check, while rejecting all paths outside the
// current profile.
let scope = if game_creator_private_path_allows_auto_elevation(path) {
WindowsAclRepairScope::Managed
} else if user_selected_path_is_authorized(path, is_directory)
&& game_creator_user_selected_path_allows_auto_elevation(path)
{
WindowsAclRepairScope::UserSelected
} else {
return Err(detail);
};
if windows_acl_error_may_need_elevation(&detail) {
return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
path,
is_directory,
true,
scope,
)
.map(|_| true)
.map_err(|repair_error| {
format!("{detail};自动提权修复未完成:{repair_error}")
});
}
}
return Err(detail);
}
};
if metadata.file_type().is_symlink()
|| (is_directory && !metadata.is_dir())
|| (!is_directory && !metadata.is_file())
{
return Err(format!(
"{label} 必须是普通{},不能是链接或其他对象:{}",
if is_directory { "目录" } else { "文件" },
path.display()
));
}
#[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(format!(
"{label} 不能是 Windows reparse point{}",
path.display()
));
}
// A project root is explicitly selected by the user before the AGC
// marker exists, so the managed-path predicate cannot identify it yet.
// Use the explicit user-selected scope for the root; this allows a
// project stored on another drive to be repaired while retaining the
// strict reparse/type checks above.
let scope = if game_creator_private_path_allows_auto_elevation(path) {
WindowsAclRepairScope::Managed
} else if user_selected_path_is_authorized(path, is_directory)
&& game_creator_user_selected_path_allows_auto_elevation(path)
{
WindowsAclRepairScope::UserSelected
} else {
#[cfg(all(windows, test))]
if windows_test_temp_path_needs_owner_initialization(path, is_directory) {
// 测试夹具:在提权 shell 里,系统临时目录下新建的目录默认所有者是
// Administrators 组而不是当前 TokenUser,测试进程无法提权改所有者。
// 该目录由当前测试进程创建,因此按“本调用创建的对象”初始化所有者后
// 重试;其它越权所有者、以及临时目录之外的路径仍然失败关闭。
if windows_path_is_under_test_temp_dir(path) {
secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
true,
true,
)?;
return Ok(true);
}
}
return secure_windows_game_creator_path_for_current_user(path, is_directory, true)
.map(|_| true);
};
secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
path,
is_directory,
true,
scope,
)?;
}
#[cfg(not(windows))]
{
validate_game_creator_private_path_ancestors(path, label)?;
}
Ok(true)
}
#[cfg(windows)]
fn validate_game_creator_private_path_ancestors_with_auto_elevation(
path: &Path,
label: &str,
) -> Result<(), String> {
validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
path,
label,
WindowsAclRepairScope::Managed,
)
}
#[cfg(windows)]
fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
path: &Path,
label: &str,
scope: WindowsAclRepairScope,
) -> Result<(), String> {
#[cfg(test)]
{
return validate_game_creator_private_path_ancestors(path, label);
}
#[cfg(not(test))]
{
let target_user_sid = current_windows_token_user_sid_string()?;
let mut attempted_targets = Vec::<PathBuf>::new();
loop {
match validate_game_creator_private_path_ancestors(path, label) {
Ok(()) => return Ok(()),
Err(error)
if scope.allows_path(path) && windows_acl_error_may_need_elevation(&error) =>
{
let repair_target = windows_acl_repair_target(path, scope);
if attempted_targets
.iter()
.any(|target| target == &repair_target)
{
return Err(format!(
"{error};自动提权修复重复命中同一目标,拒绝继续重试:{}",
repair_target.display()
));
}
attempted_targets.push(repair_target);
attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err(
|repair_error| format!("{error};自动提权修复未完成:{repair_error}"),
)?;
}
Err(error) => return Err(error),
}
}
}
}
/// Creates a private directory tree one component at a time. `create_dir_all`
/// can follow a junction that appears between components, so every existing
/// and newly-created component is checked before the next one is touched.
pub(crate) fn ensure_game_creator_private_directory_tree(
path: &Path,
label: &str,
) -> Result<bool, String> {
#[cfg(windows)]
validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?;
#[cfg(not(windows))]
validate_game_creator_private_path_ancestors(path, label)?;
let mut missing = Vec::new();
let mut current = path.to_path_buf();
loop {
match fs::symlink_metadata(&current) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(format!("{label} 不能包含符号链接:{}", current.display()));
}
#[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(format!(
"{label} 不能包含 Windows reparse point{}",
current.display()
));
}
}
if !metadata.is_dir() {
return Err(format!(
"{label} 父路径必须是普通目录:{}",
current.display()
));
}
break;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
missing.push(current.clone());
current = current
.parent()
.ok_or_else(|| {
format!("{label} 没有可用于创建的现存父目录:{}", path.display())
})?
.to_path_buf();
}
Err(error) => {
return Err(format!(
"读取 {label} 元数据失败:{}: {error}",
current.display()
));
}
}
}
let created_target = missing.first().is_some_and(|created| created == path);
for directory in missing.into_iter().rev() {
let create_result = fs::create_dir(&directory);
match create_result {
Ok(()) => {
#[cfg(all(windows, test))]
initialize_windows_game_creator_directory_owner_for_current_user(&directory)?;
#[cfg(windows)]
// This invocation created the directory: initialize it in
// process first, with a narrowly-scoped managed-path fallback
// only if Windows rejects that local ACL update.
harden_new_game_creator_private_path(&directory, true, label)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).map_err(
|error| format!("收紧 {label} 权限失败:{}: {error}", directory.display()),
)?;
}
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
// Another process won the create race. This object was not
// created by the current invocation, so it must go through
// the full existing-object owner/DACL gate before we touch
// any descendant. A type-only metadata check here would allow
// an attacker-created directory to become trusted.
prepare_game_creator_private_path_for_read(&directory, true, label)?;
}
#[cfg(windows)]
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
) || matches!(error.raw_os_error(), Some(5)) =>
{
let parent = directory.parent().ok_or_else(|| {
format!("创建 {label} 失败:{}: {error}", directory.display())
})?;
if game_creator_private_path_allows_auto_elevation(parent) {
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
parent, true, true,
)?;
} else {
secure_windows_game_creator_path_for_current_user_with_owner_policy(
parent, true, true, true,
)?;
}
fs::create_dir(&directory).map_err(|retry_error| {
format!("创建 {label} 失败:{}: {retry_error}", directory.display())
})?;
harden_new_game_creator_private_path(&directory, true, label)?;
}
Err(error) => {
return Err(format!(
"创建 {label} 失败:{}: {error}",
directory.display()
));
}
}
let metadata = fs::symlink_metadata(&directory)
.map_err(|error| format!("复核 {label} 失败:{}: {error}", directory.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!("{label} 必须是普通目录:{}", directory.display()));
}
#[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(format!(
"{label} 不能是 Windows reparse point{}",
directory.display()
));
}
}
}
Ok(created_target)
}
/// Hardens a directory or file that this invocation has just created. This
/// intentionally does not adopt an existing object: callers that discover an
/// existing path must go through `prepare_game_creator_private_path_for_read`,
/// which performs the strict owner/reparse/DACL checks and the controlled UAC
/// repair flow. Keeping the two paths separate prevents a race from turning
/// an attacker-owned object into an AGC-managed credential or sidecar file.
pub(crate) fn harden_new_game_creator_private_path(
path: &Path,
is_directory: bool,
label: &str,
) -> Result<(), String> {
if !path.is_absolute() {
return Err(format!("{label}必须是绝对路径"));
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取新建 {label} 元数据失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink()
|| (is_directory && !metadata.is_dir())
|| (!is_directory && !metadata.is_file())
{
return Err(format!(
"新建 {label} 必须是普通{},不能是链接或其他对象:{}",
if is_directory { "目录" } else { "文件" },
path.display()
));
}
#[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(format!(
"新建 {label} 不能是 Windows reparse point{}",
path.display()
));
}
// This invocation created the object, so local hardening is always
// the first path. Some Windows configurations can nevertheless
// reject the descriptor update (for example when an inherited ACL is
// protected by the parent). Only a managed path may use the existing
// one-shot repair in that exceptional case; ordinary new projects do
// not prompt for UAC.
if let Err(local_error) =
secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
true,
true,
)
{
if !game_creator_private_path_allows_auto_elevation(path)
|| !windows_acl_error_may_need_elevation(&local_error)
{
return Err(local_error);
}
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path,
is_directory,
true,
)
.map_err(|repair_error| {
format!("{local_error};新建对象的受控 ACL 修复未完成:{repair_error}")
})?;
}
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
path,
fs::Permissions::from_mode(if is_directory { 0o700 } else { 0o600 }),
)
.map_err(|error| format!("收紧新建 {label} 权限失败:{}: {error}", path.display()))?;
}
Ok(())
}
/// Writes a regular AGC-managed file only after its parent and any existing
/// target have passed the private-path policy. The post-write check also
/// hardens a newly-created file, so inherited Windows ACLs cannot remain on a
/// file that was created by this process.
pub(crate) fn write_game_creator_private_file(
path: &Path,
bytes: &[u8],
label: &str,
) -> Result<(), String> {
if !path.is_absolute() {
return Err(format!("{label}必须是绝对路径"));
}
let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?;
ensure_game_creator_private_directory_tree(parent, label)?;
prepare_game_creator_private_path_for_read(parent, true, label)?;
let existed = prepare_game_creator_private_path_for_read(path, false, label)?;
// Never write directly through the checked pathname. A pathname can be
// replaced after the preflight by another process (or by a junction/link
// attack). Create and harden a sibling temporary inode first, then link
// it into place without replacement. Existing targets are moved to a
// unique backup only after a second strict check; if installation fails,
// the original target is restored.
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("{label}文件名无效"))?;
let temporary = (0..8)
.map(|attempt| {
path.with_file_name(format!(
".{file_name}.tmp-{}-{}-{attempt}",
std::process::id(),
uuid::Uuid::new_v4().simple()
))
})
.find(|candidate| {
matches!(
fs::symlink_metadata(candidate),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
)
})
.ok_or_else(|| format!("创建 {label} 临时文件路径失败:目录中存在冲突残留"))?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options.open(&temporary).map_err(|error| {
format!(
"创建 {label} 临时文件失败:{}: {error}",
temporary.display()
)
})?;
if let Err(error) = harden_new_game_creator_private_path(&temporary, false, label) {
drop(file);
let _ = fs::remove_file(&temporary);
return Err(error);
}
let write_result = std::io::Write::write_all(&mut file, bytes).and_then(|_| file.sync_all());
drop(file);
if let Err(error) = write_result {
let _ = fs::remove_file(&temporary);
return Err(format!(
"写入 {label} 临时文件失败:{}: {error}",
temporary.display()
));
}
let backup = path.with_file_name(format!(
".{file_name}.previous-{}-{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
if existed {
// Re-check immediately before moving the old target. Links and
// reparse points remain fail-closed; only a regular managed file may
// enter the recoverable replacement path.
if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
if fs::symlink_metadata(&backup).is_ok() {
let _ = fs::remove_file(&temporary);
return Err(format!("{label}替换备份路径已存在,已拒绝覆盖"));
}
if let Err(error) = fs::rename(path, &backup) {
let _ = fs::remove_file(&temporary);
return Err(format!("准备替换 {label} 失败:{error}"));
}
}
let install_result = fs::hard_link(&temporary, path).and_then(|_| fs::remove_file(&temporary));
if let Err(error) = install_result {
let _ = fs::remove_file(&temporary);
if existed {
let _ = fs::rename(&backup, path);
}
return Err(format!("原子安装 {label} 失败:{error}"));
}
if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) {
if existed {
let _ = fs::remove_file(path);
let _ = fs::rename(&backup, path);
} else {
let _ = fs::remove_file(path);
}
return Err(format!("复核 {label} 失败:{error}"));
}
if existed {
fs::remove_file(&backup)
.map_err(|error| format!("回收 {label} 旧文件备份失败:{error}"))?;
}
Ok(())
}
/// Appends to a regular AGC-managed file while applying the same ACL policy
/// as replacement writes. This is used for human-readable logs and memory
/// journals whose append semantics are part of their existing contract.
pub(crate) fn append_game_creator_private_file(
path: &Path,
bytes: &[u8],
label: &str,
) -> Result<(), String> {
if !path.is_absolute() {
return Err(format!("{label}必须是绝对路径"));
}
let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?;
ensure_game_creator_private_directory_tree(parent, label)?;
prepare_game_creator_private_path_for_read(parent, true, label)?;
let existed = prepare_game_creator_private_path_for_read(path, false, label)?;
if existed {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取 {label} 元数据失败:{}: {error}", path.display()))?;
if !metadata.is_file() {
return Err(format!("{label} 必须是普通文件"));
}
}
let mut options = fs::OpenOptions::new();
options.write(true).append(true).read(true);
if !existed {
options.create_new(true);
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
const FILE_SHARE_DELETE: u32 = 0x0000_0004;
options
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE);
}
let mut file = options
.open(path)
.map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?;
let opened_metadata = file.metadata().map_err(|error| {
format!(
"读取 {label} 文件句柄元数据失败:{}: {error}",
path.display()
)
})?;
if !opened_metadata.is_file() {
drop(file);
return Err(format!("{label} 必须是普通文件"));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if opened_metadata.nlink() != 1 {
drop(file);
return Err(format!("{label} 不能是硬链接"));
}
let path_metadata = fs::symlink_metadata(path)
.map_err(|error| format!("复核 {label} 路径失败:{}: {error}", path.display()))?;
if path_metadata.file_type().is_symlink()
|| path_metadata.dev() != opened_metadata.dev()
|| path_metadata.ino() != opened_metadata.ino()
{
drop(file);
return Err(format!("{label} 路径在安全打开期间发生替换"));
}
}
#[cfg(windows)]
crate::runner::validate_windows_regular_file_handle(&file, label)?;
if !existed {
if let Err(error) = harden_new_game_creator_private_path(path, false, label) {
drop(file);
let _ = fs::remove_file(path);
return Err(error);
}
}
use std::io::Write as _;
file.write_all(bytes)
.and_then(|_| file.sync_data())
.map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?;
if existed {
prepare_game_creator_private_path_for_read(path, false, label)?;
}
Ok(())
}
/// Checks a private file/directory before the caller opens it. Windows ACL
/// failures must be handled before `OpenOptions::open`: an inherited DACL can
/// otherwise make the open fail before the strict verifier gets a chance to
/// request UAC repair. Missing leaves are returned as `false`; existing
/// objects are fully validated and, on Windows, repaired/re-validated through
/// the normal auto-elevation path.
pub(crate) fn prepare_game_creator_private_path_for_read(
path: &Path,
is_directory: bool,
label: &str,
) -> Result<bool, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display());
#[cfg(windows)]
{
let result = if game_creator_private_path_allows_auto_elevation(path) {
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path,
is_directory,
true,
)
} else {
verify_game_creator_private_path_or_test_temp_owner(path, is_directory)
};
return result.map(|_| true).map_err(|repair_error| {
if game_creator_private_path_allows_auto_elevation(path) {
format!("{detail};自动提权修复未完成:{repair_error}")
} else {
format!("{detail};严格私有权限校验未通过:{repair_error}")
}
});
}
#[cfg(not(windows))]
return Err(detail);
}
};
if metadata.file_type().is_symlink()
|| (is_directory && !metadata.is_dir())
|| (!is_directory && !metadata.is_file())
{
return Err(format!(
"{label} 必须是普通{},不能是链接或其他对象:{}",
if is_directory { "目录" } else { "文件" },
path.display()
));
}
// Metadata on the leaf can be readable while traversal of an ancestor is
// blocked by a foreign owner or inherited ACL. Give the managed object a
// single explicit repair opportunity before surfacing a permission error;
// links/reparse points have already been rejected above and therefore stay
// fail-closed.
#[cfg(windows)]
validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?;
#[cfg(not(windows))]
validate_game_creator_private_path_ancestors(path, label)?;
#[cfg(windows)]
if game_creator_private_path_allows_auto_elevation(path) {
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path,
is_directory,
true,
)?;
} else {
// User-selected external files are never silently adopted. Keep the
// strict owner/DACL check, but do not escalate an arbitrary path.
verify_game_creator_private_path_or_test_temp_owner(path, is_directory)?;
}
Ok(true)
}
/// Prepares a path returned by an explicit native file picker. On Windows,
/// owner/DACL failures on a regular selected object receive the same one-shot
/// UAC repair as AGC-managed files, including projects stored outside the
/// current profile. Reparse points, links, non-regular objects and ancestor
/// type conflicts are rejected before any repair attempt.
pub(crate) fn prepare_game_creator_user_selected_path_for_read(
path: &Path,
is_directory: bool,
label: &str,
) -> Result<bool, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display());
#[cfg(windows)]
{
// Explicit native file-picker/project-root selections are
// bounded UAC targets even when the normal token cannot
// traverse far enough to read leaf metadata. The helper still
// re-validates owner, type and reparse state before changing
// anything.
if user_selected_path_is_authorized(path, is_directory)
&& game_creator_user_selected_path_allows_auto_elevation(path)
&& windows_acl_error_may_need_elevation(&detail)
{
return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
path,
is_directory,
true,
WindowsAclRepairScope::UserSelected,
)
.map(|_| true)
.map_err(|repair_error| {
format!("{detail};自动提权修复未完成:{repair_error}")
});
}
}
return Err(detail);
}
};
if metadata.file_type().is_symlink()
|| (is_directory && !metadata.is_dir())
|| (!is_directory && !metadata.is_file())
{
return Err(format!(
"{label} 必须是普通{},不能是链接或其他对象:{}",
if is_directory { "目录" } else { "文件" },
path.display()
));
}
#[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(format!(
"{label} 不能是 Windows reparse point{}",
path.display()
));
}
if user_selected_path_is_authorized(path, is_directory)
&& game_creator_user_selected_path_allows_auto_elevation(path)
{
validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
path,
label,
WindowsAclRepairScope::UserSelected,
)?;
secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
path,
is_directory,
true,
WindowsAclRepairScope::UserSelected,
)?;
} else {
validate_game_creator_private_path_ancestors(path, label)?;
secure_windows_game_creator_path_for_current_user(path, is_directory, true)?;
}
}
#[cfg(not(windows))]
{
validate_game_creator_private_path_ancestors(path, label)?;
}
Ok(true)
}
/// Reads an AGC-managed private text file through a validated regular-file
/// handle. The pathname is used only for preflight/open identity checks; bytes
/// are read from the handle so a later rename cannot redirect the read.
pub(crate) fn read_game_creator_private_file_to_string(
path: &Path,
label: &str,
max_bytes: u64,
) -> Result<String, String> {
let (mut file, metadata) = open_project_private_regular_file(path, label)?;
if metadata.len() > max_bytes {
return Err(format!("{label}过大,已拒绝读取:{}", path.display()));
}
let mut content = String::with_capacity(metadata.len() as usize);
file.read_to_string(&mut content)
.map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?;
let final_metadata = file
.metadata()
.map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?;
if final_metadata.len() != metadata.len() {
return Err(format!("{label}读取期间文件发生漂移:{}", path.display()));
}
Ok(content)
}
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());
}
#[cfg(windows)]
validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
path,
"客户端 AppData 配置目录",
game_creator_runtime_config_repair_scope(path),
)?;
#[cfg(not(windows))]
validate_game_creator_private_path_ancestors(path, "客户端 AppData 配置目录")?;
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() {
ensure_game_creator_private_directory_tree(
parent,
"客户端 AppData 配置父目录",
)?;
}
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 = match fs::canonicalize(path) {
Ok(canonical) => canonical,
Err(error) => {
let detail = format!(
"解析客户端 AppData 配置目录失败:{}: {error}",
path.display()
);
#[cfg(windows)]
if !cfg!(test)
&& windows_acl_error_may_need_elevation(&detail)
&& game_creator_private_path_allows_auto_elevation(path)
{
// canonicalize follows the directory and therefore can fail
// with access denied before the normal owner/DACL verifier is
// reached. Repair the managed target first, then canonicalize
// again so a denied config directory gets the same one-shot
// UAC treatment as an already-readable object.
let target_user_sid = current_windows_token_user_sid_string()
.map_err(|sid_error| format!("{detail};读取当前用户 SID 失败:{sid_error}"))?;
attempt_elevated_windows_acl_repair(
path,
&target_user_sid,
game_creator_runtime_config_repair_scope(path),
)
.map_err(|repair_error| format!("{detail};自动提权修复未完成:{repair_error}"))?;
validate_game_creator_runtime_config_dir_metadata(path, true, false)?;
return fs::canonicalize(path).map_err(|canonicalize_error| {
format!(
"ACL 修复后解析客户端 AppData 配置目录失败:{}: {canonicalize_error}",
path.display()
)
});
}
return Err(detail);
}
};
match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created)
{
Ok(()) => {}
#[cfg(windows)]
Err(error) if !cfg!(test) && windows_acl_error_may_need_elevation(&error) => {
// Any existing private AppData directory that cannot be safely
// read is repaired through the one-shot elevated helper. This
// covers inherited ACLs and foreign owners while the earlier
// entry/reparse checks remain fail-closed.
let target_user_sid = current_windows_token_user_sid_string()
.map_err(|sid_error| format!("{error};读取当前用户 SID 失败:{sid_error}"))?;
attempt_elevated_windows_acl_repair(
path,
&target_user_sid,
game_creator_runtime_config_repair_scope(path),
)
.map_err(|repair_error| format!("{error};自动提权修复未完成:{repair_error}"))?;
validate_game_creator_runtime_config_dir_metadata(path, true, false)?;
return fs::canonicalize(path).map_err(|canonicalize_error| {
format!(
"ACL 修复后解析客户端 AppData 配置目录失败:{}: {canonicalize_error}",
path.display()
)
});
}
#[cfg(windows)]
Err(error)
if cfg!(test)
&& 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(())
}
/// 测试夹具专用:判断某个已存在的项目根是否只是“系统临时目录下所有者不是当前用户”。
///
/// 部分 Windows 主机(例如以提权 shell 运行测试)在 `%TEMP%` 下新建的目录,默认所有者是
/// `BUILTIN\Administrators` 组而不是当前 TokenUser;测试进程无法提权改所有者,于是严格
/// 校验会拒绝一个由测试自己创建、且确实位于系统临时目录的目录。只有测试构建、路径位于
/// 系统临时目录、并且失败原因确实是所有者不匹配时才返回 true;临时目录之外的越权所有者
/// 继续失败关闭。
#[cfg(all(windows, test))]
fn windows_test_temp_path_needs_owner_initialization(path: &Path, is_directory: bool) -> bool {
if !path.is_absolute() {
return false;
}
match secure_windows_game_creator_path_for_current_user(path, is_directory, true) {
Ok(()) => false,
Err(error) => {
error.contains("安全对象不属于当前用户") && windows_path_is_under_test_temp_dir(path)
}
}
}
/// 严格校验一个既有私有对象;测试构建下对系统临时目录内的所有者偏差做一次性所有者
/// 初始化重试,其余情况保持严格失败关闭。
#[cfg(windows)]
fn verify_game_creator_private_path_or_test_temp_owner(
path: &Path,
is_directory: bool,
) -> Result<(), String> {
#[cfg(test)]
if windows_test_temp_path_needs_owner_initialization(path, is_directory) {
return secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
true,
true,
);
}
secure_windows_game_creator_path_for_current_user(path, is_directory, true)
}
#[cfg(all(windows, test))]
fn windows_path_is_under_test_temp_dir(path: &Path) -> bool {
let normalize = |value: &Path| {
value
.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase()
};
let temp_dir = std::env::temp_dir();
let mut roots = vec![normalize(&temp_dir)];
if let Ok(canonical) = temp_dir.canonicalize() {
let root = normalize(&canonical);
if !roots.contains(&root) {
roots.push(root);
}
}
let candidate = normalize(path);
roots
.iter()
.any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\")))
}
#[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_user_sid_with_owner_policy(
path,
is_directory,
tighten,
false,
None,
)
}
/// Strictly validates a Windows private object and, when its owner/DACL cannot
/// be used by the current account, performs one explicit UAC repair before
/// validating again. Reparse points and non-regular objects remain
/// fail-closed; a repaired regular object is reassigned to the current token
/// user and receives a private non-inherited DACL.
#[cfg(windows)]
pub(crate) fn secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path: &Path,
is_directory: bool,
tighten: bool,
) -> Result<(), String> {
secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
path,
is_directory,
tighten,
WindowsAclRepairScope::Managed,
)
}
/// Same strict verifier as the managed-path entry, but with an explicit scope
/// selected by the caller. This is used by file-picker imports only; the
/// scope is included in the one-shot UAC ticket and checked again by the
/// elevated child process.
#[cfg(windows)]
fn secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped(
path: &Path,
is_directory: bool,
tighten: bool,
scope: WindowsAclRepairScope,
) -> Result<(), String> {
#[cfg(test)]
{
// Unit tests must not trigger an interactive UAC prompt. The strict
// verifier remains directly testable. For an owner-correct object
// whose only defect is an inherited DACL, emulate the formal prepare
// entry's local tightening in-process; foreign-owner fixtures still
// fail closed because they cannot be reassigned without elevation.
return match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten)
{
Ok(()) => Ok(()),
Err(error)
if scope.allows_path(path)
&& windows_acl_error_may_need_elevation(&error)
&& (!error.contains("安全对象不属于当前用户") || {
#[cfg(test)]
{
path.file_name()
.is_some_and(|name| name == "game-creator.config.json")
|| path.starts_with(std::env::temp_dir())
|| path
.ancestors()
.any(|ancestor| ancestor.join(".agent/manifest.json").is_file())
}
#[cfg(not(test))]
{
false
}
}) =>
{
#[cfg(test)]
if error.contains("安全对象不属于当前用户") {
return secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
true,
true,
);
}
secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
true,
false,
)
}
Err(error) => Err(error),
};
}
#[cfg(not(test))]
{
let target_user_sid = current_windows_token_user_sid_string()?;
let mut attempted_targets = Vec::<PathBuf>::new();
loop {
match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten) {
Ok(()) => return Ok(()),
Err(error) if windows_acl_error_may_need_elevation(&error) => {
if !scope.allows_path(path) {
return Err(error);
}
let repair_target = windows_acl_repair_target(path, scope);
if attempted_targets
.iter()
.any(|target| target == &repair_target)
{
return Err(format!(
"{error};自动提权修复重复命中同一目标,拒绝继续重试:{}",
repair_target.display()
));
}
attempted_targets.push(repair_target);
attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err(
|repair_error| format!("{error};自动提权修复未完成:{repair_error}"),
)?;
}
Err(error) => return Err(error),
}
}
}
}
#[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)
}
/// Repairs an AGC-managed private object after an explicit UAC elevation.
/// Foreign-owned regular files/directories are deliberately reassigned to the
/// current token user here. The caller has already rejected links/reparse
/// points, and the final strict verification below is mandatory.
#[cfg(windows)]
pub(crate) fn repair_game_creator_private_acl_for_current_user(path: &Path) -> Result<(), String> {
let target_user_sid = current_windows_token_user_sid_string()?;
repair_game_creator_private_acl_for_user_sid(path, &target_user_sid)
}
#[cfg(windows)]
fn current_windows_token_user_sid_string() -> Result<String, String> {
use std::ffi::c_void;
type Handle = *mut c_void;
type Sid = *mut c_void;
#[repr(C)]
struct SidAndAttributes {
sid: Sid,
attributes: u32,
}
#[repr(C)]
struct TokenUser {
user: SidAndAttributes,
}
#[link(name = "advapi32")]
unsafe extern "system" {
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 ConvertSidToStringSidW(sid: Sid, string_sid: *mut *mut u16) -> 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 TOKEN_QUERY: u32 = 0x0000_0008;
const TOKEN_USER_CLASS: u32 = 1;
let mut token = std::ptr::null_mut();
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;
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)];
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()
));
}
let sid = unsafe { (*(token_buffer.as_ptr().cast::<TokenUser>())).user.sid };
if sid.is_null() {
return Err("Windows 当前用户 SID 无效".to_string());
}
let mut string_sid = std::ptr::null_mut();
if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 || string_sid.is_null() {
return Err(format!(
"转换 Windows 当前用户 SID 失败:{}",
std::io::Error::last_os_error()
));
}
let mut length = 0_usize;
while unsafe { *string_sid.add(length) } != 0 {
length = length.saturating_add(1);
if length > 256 {
unsafe { LocalFree(string_sid.cast()) };
return Err("Windows 当前用户 SID 长度无效".to_string());
}
}
let value = String::from_utf16(unsafe { std::slice::from_raw_parts(string_sid, length) })
.map_err(|_| "Windows 当前用户 SID 编码无效".to_string());
unsafe { LocalFree(string_sid.cast()) };
value
})();
unsafe { CloseHandle(token) };
result
}
/// The elevated helper may run under administrator credentials that differ
/// from the original desktop user's token. Keep ownership and the private
/// DACL bound to the original TokenUser SID passed by the caller.
#[cfg(windows)]
pub(crate) fn repair_game_creator_private_acl_for_user_sid(
path: &Path,
target_user_sid: &str,
) -> Result<(), String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取待修复私有对象失败:{}: {error}", path.display()))?;
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|| metadata.file_type().is_symlink()
{
return Err("待修复私有对象不能是 Windows reparse point 或链接".to_string());
}
if !metadata.is_dir() && !metadata.is_file() {
return Err("待修复私有对象必须是普通文件或目录".to_string());
}
secure_windows_game_creator_path_for_user_sid_with_owner_policy(
path,
metadata.is_dir(),
true,
true,
Some(target_user_sid),
)
}
#[cfg(windows)]
pub(crate) fn windows_acl_error_may_need_elevation(error: &str) -> bool {
(error.contains("DACL")
|| error.contains("权限")
|| error.contains("error 5")
|| error.contains("安全对象不属于当前用户")
|| error.contains("启用 Windows")
|| error.contains("特权")
|| error.contains("1300"))
&& !error.contains("链接")
&& !error.contains("reparse")
}
#[cfg(windows)]
fn windows_acl_repair_target(path: &Path, scope: WindowsAclRepairScope) -> PathBuf {
// A user-selected object is the only trusted identity in that scope. Do
// not widen it to an unreadable parent (which could be another user's
// profile or a protected system directory); the helper will validate the
// selected regular object itself and fail closed if traversal remains
// impossible. Managed paths have an established AGC root and may repair
// the first blocked ancestor so inherited ACLs can be fixed in one pass.
if matches!(scope, WindowsAclRepairScope::UserSelected) {
return path.to_path_buf();
}
// Walk from the filesystem root towards the leaf. If traversal is denied
// on an ancestor, repairing the leaf cannot help because the elevated
// helper will hit the same ancestor before it can inspect the leaf.
for ancestor in path.ancestors().collect::<Vec<_>>().into_iter().rev() {
match fs::symlink_metadata(ancestor) {
Ok(_) => {}
Err(error)
if error.kind() == std::io::ErrorKind::PermissionDenied
|| error.raw_os_error() == Some(5) =>
{
return ancestor.to_path_buf();
}
Err(_) => {}
}
}
path.to_path_buf()
}
#[cfg(windows)]
const WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES: u64 = 4 * 1024;
#[cfg(windows)]
struct WindowsAclRepairAuthorizationCleanup {
path: PathBuf,
armed: bool,
}
#[cfg(windows)]
impl WindowsAclRepairAuthorizationCleanup {
fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(windows)]
impl Drop for WindowsAclRepairAuthorizationCleanup {
fn drop(&mut self) {
if self.armed {
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(windows)]
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct WindowsAclRepairAuthorization {
path: String,
target_user_sid: String,
scope: String,
issued_at: u64,
}
#[cfg(windows)]
fn windows_acl_repair_authorization_path(nonce: &str) -> Result<PathBuf, String> {
let nonce = nonce.trim();
if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("AGC ACL 修复授权票据无效".to_string());
}
let temporary_directory = std::env::temp_dir();
if !temporary_directory.is_absolute() {
return Err("AGC ACL 修复临时目录必须是绝对路径".to_string());
}
Ok(temporary_directory.join(format!(".genarrative-acl-repair-{nonce}.json")))
}
#[cfg(windows)]
fn create_windows_acl_repair_authorization(
path: &Path,
target_user_sid: &str,
scope: WindowsAclRepairScope,
) -> Result<String, String> {
let nonce = uuid::Uuid::new_v4().simple().to_string();
let authorization_path = windows_acl_repair_authorization_path(&nonce)?;
let payload = serde_json::to_vec(&WindowsAclRepairAuthorization {
path: path.to_string_lossy().into_owned(),
target_user_sid: target_user_sid.to_string(),
scope: scope.wire_name().to_string(),
issued_at: unix_timestamp(),
})
.map_err(|error| format!("创建 AGC ACL 修复授权票据失败:{error}"))?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options.open(&authorization_path).map_err(|error| {
format!(
"创建 AGC ACL 修复授权票据文件失败:{}: {error}",
authorization_path.display()
)
})?;
// The ticket is created in the per-user TEMP directory while the normal
// process is not elevated. Do not call the auto-elevation wrapper here:
// that wrapper itself creates a ticket and would recurse indefinitely when
// TEMP inherits a broad DACL. The strict owner-policy path can still
// tighten an owner-correct inherited ACL and rejects a foreign TEMP owner.
if let Err(error) = secure_windows_game_creator_path_for_current_user_with_owner_policy(
&authorization_path,
false,
true,
true,
) {
drop(file);
let _ = fs::remove_file(&authorization_path);
return Err(format!("初始化 AGC ACL 修复授权票据安全权限失败:{error}"));
}
if let Err(error) = file.write_all(&payload).and_then(|_| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&authorization_path);
return Err(format!("写入 AGC ACL 修复授权票据失败:{error}"));
}
drop(file);
Ok(nonce)
}
#[cfg(windows)]
pub(crate) fn consume_windows_acl_repair_authorization(
path: &Path,
target_user_sid: &str,
nonce: &str,
scope: WindowsAclRepairScope,
) -> Result<(), String> {
if !scope.allows_path(path) {
return Err(format!(
"AGC ACL 修复目标不在当前用户允许的 {} 范围内:{}",
scope.wire_name(),
path.display()
));
}
let authorization_path = windows_acl_repair_authorization_path(nonce)?;
let mut cleanup = WindowsAclRepairAuthorizationCleanup::new(authorization_path.clone());
// Open the ticket before validating its contents. The security helper
// below uses GetNamedSecurityInfoW/SetNamedSecurityInfoW by pathname; an
// exclusive handle would make those calls fail with a sharing violation
// on Windows. Keep the ticket in the per-user TEMP directory with a
// hardened owner-only DACL, then validate the opened handle's metadata and
// exact payload before consuming the one-shot file.
use std::os::windows::fs::MetadataExt;
use std::os::windows::fs::OpenOptionsExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
const FILE_SHARE_DELETE: u32 = 0x0000_0004;
use std::io::Read as _;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let mut file = fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(&authorization_path)
.map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?;
crate::runner::validate_windows_regular_file_handle(&file, "AGC ACL 修复授权票据")?;
let opened_metadata = file
.metadata()
.map_err(|error| format!("读取 AGC ACL 修复授权票据句柄元数据失败:{error}"))?;
if opened_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|| !opened_metadata.is_file()
{
return Err("AGC ACL 修复授权票据必须是普通文件".to_string());
}
if opened_metadata.len() > WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES {
return Err("AGC ACL 修复授权票据过大".to_string());
}
// The elevated helper runs under an administrator token, so validate the
// ticket against the original desktop user's SID rather than the helper's
// current SID. This also rejects a ticket placed in TEMP by another user.
// This check deliberately happens after opening the ticket handle so the
// payload is tied to a real regular file before it is authorized.
secure_windows_game_creator_path_for_user_sid_with_owner_policy(
&authorization_path,
false,
true,
false,
Some(target_user_sid),
)?;
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?;
let final_metadata = file
.metadata()
.map_err(|error| format!("复核 AGC ACL 修复授权票据句柄元数据失败:{error}"))?;
if final_metadata.len() != opened_metadata.len() {
return Err("AGC ACL 修复授权票据读取期间发生漂移".to_string());
}
let authorization = serde_json::from_str::<WindowsAclRepairAuthorization>(&content)
.map_err(|error| format!("AGC ACL 修复授权票据格式无效:{error}"))?;
if unix_timestamp().saturating_sub(authorization.issued_at) > 300 {
return Err("AGC ACL 修复授权票据已过期".to_string());
}
if authorization.path != path.to_string_lossy()
|| authorization.target_user_sid != target_user_sid
|| authorization.scope != scope.wire_name()
{
return Err("AGC ACL 修复授权票据与目标不匹配".to_string());
}
// Release the read handle before consuming the one-shot file. The cleanup
// guard retries removal on every failure path, while a successful removal
// disarms it to avoid a second delete attempt during unwinding.
drop(file);
fs::remove_file(&authorization_path)
.map_err(|error| format!("删除 AGC ACL 修复授权票据失败:{error}"))?;
cleanup.disarm();
Ok(())
}
#[cfg(windows)]
fn windows_command_line_quote(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\\\""))
}
#[cfg(windows)]
fn windows_acl_repair_argument_list(
path: &str,
target_user_sid: &str,
nonce: &str,
scope: WindowsAclRepairScope,
) -> String {
[
"--repair-private-acl",
path,
"--target-user-sid",
target_user_sid,
"--authorization",
nonce,
"--scope",
scope.wire_name(),
]
.into_iter()
.map(windows_command_line_quote)
.collect::<Vec<_>>()
.join(" ")
}
/// Starts a one-shot elevated copy of the current executable. The elevated
/// process performs only the allow-listed ACL repair command and exits with a
/// truthful status; UAC cancellation is never treated as success.
#[cfg(windows)]
fn attempt_elevated_windows_acl_repair(
path: &Path,
target_user_sid: &str,
scope: WindowsAclRepairScope,
) -> Result<(), String> {
if !scope.allows_path(path) {
return Err(format!(
"AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}",
scope.wire_name(),
path.display()
));
}
let executable =
std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?;
if !executable.is_file() {
return Err("AGC ACL 修复程序不存在".to_string());
}
let repair_path = windows_acl_repair_target(path, scope);
let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?;
let escaped_executable = executable.to_string_lossy().replace('\'', "''");
let arguments = windows_acl_repair_argument_list(
&repair_path.to_string_lossy(),
target_user_sid,
&nonce,
scope,
)
.replace('\'', "''");
let script = format!(
"$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList '{arguments}'; if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}"
);
use std::os::windows::process::CommandExt;
let status = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-Command",
script.as_str(),
])
.creation_flags(0x0800_0000)
.status()
.map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}"));
let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| {
fs::remove_file(authorization_path).map_err(|error| error.to_string())
});
let status = status?;
if status.success() {
Ok(())
} else {
Err(format!(
"AGC ACL 提权修复未成功(exit code {:?}",
status.code()
))
}
}
#[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
}
}
#[cfg(windows)]
fn windows_security_object_path(path: &Path) -> std::ffi::OsString {
use std::ffi::OsString;
let raw = path.as_os_str().to_string_lossy();
// GetNamedSecurityInfoW/SetNamedSecurityInfoW report ERROR_INVALID_NAME
// for ordinary absolute paths at the MAX_PATH boundary. Extended-length
// paths are accepted by these APIs and preserve the exact object identity.
// Do not add the prefix twice, and translate UNC paths to the documented
// \\?\UNC\server\share form.
if raw.starts_with("\\\\?\\") || raw.encode_utf16().count() < 260 {
return path.as_os_str().to_os_string();
}
if let Some(unc) = raw.strip_prefix("\\\\") {
OsString::from(format!("\\\\?\\UNC\\{unc}"))
} else {
OsString::from(format!("\\\\?\\{raw}"))
}
}
#[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> {
secure_windows_game_creator_path_for_user_sid_with_owner_policy(
path,
is_directory,
tighten,
initialize_owner,
None,
)
}
#[cfg(windows)]
fn secure_windows_game_creator_path_for_user_sid_with_owner_policy(
path: &Path,
is_directory: bool,
tighten: bool,
initialize_owner: bool,
target_user_sid: Option<&str>,
) -> Result<(), String> {
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
let metadata = fs::symlink_metadata(path).map_err(|error| {
format!(
"读取 Windows 私有对象元数据失败:{}: {error}",
path.display()
)
})?;
if metadata.file_type().is_symlink() {
return Err(format!(
"Windows 私有对象不能是符号链接:{}",
path.display()
));
}
{
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(format!(
"Windows 私有对象不能是 reparse point{}",
path.display()
));
}
}
if (is_directory && !metadata.is_dir()) || (!is_directory && !metadata.is_file()) {
return Err(format!(
"Windows 私有对象类型不符合预期:{}",
path.display()
));
}
validate_game_creator_private_path_ancestors(path, "Windows 私有对象")?;
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,
}
#[repr(C)]
struct Luid {
low_part: u32,
high_part: i32,
}
#[repr(C)]
struct LuidAndAttributes {
luid: Luid,
attributes: u32,
}
#[repr(C)]
struct TokenPrivileges {
privilege_count: u32,
privileges: [LuidAndAttributes; 1],
}
#[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 LookupPrivilegeValueW(system_name: *const u16, name: *const u16, luid: *mut Luid)
-> i32;
fn AdjustTokenPrivileges(
token: Handle,
disable_all_privileges: i32,
new_state: *mut TokenPrivileges,
buffer_length: u32,
previous_state: *mut TokenPrivileges,
return_length: *mut u32,
) -> i32;
fn GetTokenInformation(
token: Handle,
information_class: u32,
information: *mut c_void,
information_length: u32,
return_length: *mut u32,
) -> i32;
fn ConvertStringSidToSidW(string_sid: *const u16, sid: *mut Sid) -> i32;
fn EqualSid(first: Sid, second: Sid) -> i32;
fn IsValidSid(sid: Sid) -> i32;
fn GetLengthSid(sid: Sid) -> u32;
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;
fn GetLastError() -> u32;
}
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_ADJUST_PRIVILEGES: u32 = 0x0000_0020;
const TOKEN_USER_CLASS: u32 = 1;
const SE_PRIVILEGE_ENABLED: u32 = 0x0000_0002;
const ERROR_NOT_ALL_ASSIGNED: u32 = 1300;
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 | TOKEN_ADJUST_PRIVILEGES,
&mut token,
)
} == 0
|| token.is_null()
{
return Err(format!(
"读取 Windows 当前用户 token 失败:{}",
std::io::Error::last_os_error()
));
}
let mut requested_user_sid = std::ptr::null_mut();
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 target_user_sid = if let Some(target_user_sid) = target_user_sid {
let target_user_sid = target_user_sid.trim();
if target_user_sid.is_empty() {
return Err("Windows ACL 修复目标 TokenUser SID 不能为空".to_string());
}
let mut wide_target_user_sid = target_user_sid
.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
if unsafe {
ConvertStringSidToSidW(wide_target_user_sid.as_mut_ptr(), &mut requested_user_sid)
} == 0
|| requested_user_sid.is_null()
|| unsafe { IsValidSid(requested_user_sid) } == 0
{
return Err("Windows ACL 修复目标 TokenUser SID 无效".to_string());
}
requested_user_sid
} else {
current_user_sid
};
// Security APIs otherwise reject a perfectly valid private sidecar at
// the MAX_PATH boundary with ERROR_INVALID_NAME. Use the extended
// length spelling only when needed; short paths retain the ordinary
// Win32 form for compatibility with older Windows builds.
let security_path = windows_security_object_path(path);
let mut wide_path = security_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, target_user_sid) } != 0;
unsafe { LocalFree(initial_descriptor) };
if !owner_matches {
if !(initialize_owner && tighten) {
return Err(format!(
"Windows 安全对象不属于当前用户:{}",
path.display()
));
}
// Assigning ownership to the current token user requires the
// take-ownership/restore privileges on an elevated process.
// Enabling them is attempted only for this allow-listed repair;
// a normal process will fail and the caller will request UAC
// elevation instead of weakening the verifier.
for privilege_name in ["SeTakeOwnershipPrivilege", "SeRestorePrivilege"] {
let mut privilege_name_wide = privilege_name
.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let mut luid = Luid {
low_part: 0,
high_part: 0,
};
if unsafe {
LookupPrivilegeValueW(
std::ptr::null(),
privilege_name_wide.as_mut_ptr(),
&mut luid,
)
} == 0
{
return Err(format!(
"启用 Windows {privilege_name} 失败:{}",
std::io::Error::last_os_error()
));
}
let mut privileges = TokenPrivileges {
privilege_count: 1,
privileges: [LuidAndAttributes {
luid,
attributes: SE_PRIVILEGE_ENABLED,
}],
};
let adjust_status = unsafe {
AdjustTokenPrivileges(
token,
0,
&mut privileges,
std::mem::size_of::<TokenPrivileges>() as u32,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
let adjust_error = unsafe { GetLastError() };
if adjust_status == 0 || adjust_error == ERROR_NOT_ALL_ASSIGNED {
return Err(format!(
"启用 Windows {privilege_name} 失败:error {adjust_error}"
));
}
}
}
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: target_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 {
target_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, target_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 != required_inheritance {
return Err(format!("Windows DACL 继承边界无效:{}", path.display()));
}
let ace_sid = std::ptr::addr_of!(allowed.sid_start)
.cast_mut()
.cast::<c_void>();
let sid_length = unsafe { GetLengthSid(ace_sid) } as usize;
let sid_offset = std::mem::size_of::<AceHeader>() + std::mem::size_of::<u32>();
if sid_length == 0
|| sid_offset.saturating_add(sid_length) > usize::from(header.ace_size)
|| unsafe { IsValidSid(ace_sid) } == 0
|| unsafe { EqualSid(ace_sid, target_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.
if !requested_user_sid.is_null() {
// SAFETY: ConvertStringSidToSidW allocates this SID with LocalAlloc.
unsafe { LocalFree(requested_user_sid.cast()) };
}
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);
let config_exists =
validate_game_creator_config_file_entry(&config_path).map_err(std::io::Error::other)?;
if !config_exists {
write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
.map_err(std::io::Error::other)?;
}
// 按主配置与本地覆盖的最终开关决定是否保留自定义连接。
for path in [
config_path,
config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
] {
migrate_legacy_game_creator_agent_mode(&path).map_err(std::io::Error::other)?;
}
set_game_creator_runtime_config_dir(config_dir);
Ok(())
}
/// Validates a persisted AGC config file without following links.
///
/// Reading configuration must remain read-only. ACL hardening is performed
/// when the file is created or replaced, not on every settings-panel read.
fn validate_game_creator_config_file_entry(path: &Path) -> Result<bool, String> {
#[cfg(windows)]
validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?;
#[cfg(not(windows))]
validate_game_creator_private_path_ancestors(path, "客户端配置文件")?;
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(format!(
"读取客户端配置文件元数据失败:{}: {error}",
path.display()
));
}
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("客户端配置文件必须是普通文件,不能是链接或其他对象".to_string());
}
Ok(true)
}
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
})
}
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)?;
}
if game_creator_official_llm_route_locked() {
lock_game_creator_app_config_to_official_route(&mut config);
}
apply_custom_llm_model_selection(&mut config);
Ok(config)
}
pub(crate) fn load_game_creator_app_config_for_write(
) -> Result<(GameCreatorAppConfig, Vec<(PathBuf, serde_json::Value)>), String> {
let writable_path = writable_game_creator_config_path()?;
let mut config = GameCreatorAppConfig::default();
let mut overlays = Vec::new();
let mut after_writable = false;
for path in game_creator_config_paths() {
if path == writable_path {
after_writable = true;
}
if let Some(content) = read_game_creator_config_file(&path)? {
merge_game_creator_config_content(&mut config, &path, &content)?;
if after_writable && path != writable_path {
let value = serde_json::from_str(&content)
.map_err(|error| format!("解析客户端覆盖配置失败:{error}"))?;
overlays.push((path, value));
}
}
}
if game_creator_official_llm_route_locked() {
lock_game_creator_app_config_to_official_route(&mut config);
}
apply_custom_llm_model_selection(&mut config);
Ok((config, overlays))
}
pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppConfigFile) -> bool {
if config.llm.as_ref().and_then(|llm| llm.custom_enabled) == Some(true) {
return false;
}
let mut changed = config.agent_mode.as_deref()
!= Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|| config.agent_llm.is_some();
config.agent_mode = Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string());
config.agent_llm = None;
if let Some(llm) = config.llm.as_mut() {
// 官方路由仍然清空凭据,但把连接字段留在文件里:手写自定义连接时
// 用户能看到 baseUrl / apiKey / model / apiKind 四要素与开关、模型列表并列。
let official_model = config
.selected_model_id
.clone()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string());
changed |= llm.api_key.as_deref() != Some("")
|| llm.base_url.as_deref() != Some(OFFICIAL_LLM_ROUTER_BASE_URL)
|| llm.model.as_deref() != Some(official_model.as_str())
|| llm.api_kind.as_deref() != Some(DEFAULT_GAME_CREATOR_LLM_API_KIND)
|| llm.custom_enabled.is_none()
|| llm.visible_models.is_none();
llm.api_key = Some(String::new());
llm.base_url = Some(OFFICIAL_LLM_ROUTER_BASE_URL.to_string());
llm.model = Some(official_model);
llm.api_kind = Some(DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string());
llm.custom_enabled = Some(false);
llm.visible_models = Some(llm.visible_models.take().unwrap_or_default());
}
if config.editor_api.is_some() {
changed = true;
config.editor_api = None;
}
changed
}
pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> {
if !validate_game_creator_config_file_entry(path)? {
return Ok(());
}
let content = read_game_creator_private_file_to_string(path, "客户端配置文件", 256 * 1024)?;
let mut config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
let mut changed = false;
changed |= ensure_game_creator_custom_llm_file_fields(&mut config);
let inferred_agent_mode = config
.agent_mode
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| legacy_game_creator_agent_mode(&config).map(str::to_string));
match config.schema_version.as_deref().map(str::trim) {
None => {
if inferred_agent_mode.as_deref() == Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) {
if let Some(llm) = config.llm.as_mut() {
if llm.web_search_enabled.is_none() {
llm.web_search_enabled = Some(true);
changed = true;
}
}
}
config.agent_mode = Some(
inferred_agent_mode
.clone()
.unwrap_or_else(default_game_creator_agent_mode),
);
config.schema_version = Some(GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string());
changed = true;
}
Some(GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION) => {
if config.agent_mode.as_deref().is_none() {
config.agent_mode = Some(
inferred_agent_mode
.clone()
.unwrap_or_else(default_game_creator_agent_mode),
);
changed = true;
}
if inferred_agent_mode.as_deref() == Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
&& config
.llm
.as_ref()
.is_some_and(|llm| llm.web_search_enabled.is_none())
{
config.llm.as_mut().expect("checked llm").web_search_enabled = Some(true);
changed = true;
}
}
Some(version) => {
return Err(format!(
"客户端配置 schemaVersion 不支持:{};请升级陶泥儿",
version
));
}
}
if game_creator_official_llm_route_locked() && !custom_llm_enabled_at_config_path(path)? {
changed |= scrub_locked_game_creator_config_file(&mut config);
}
if changed {
let content = serde_json::to_string_pretty(&config)
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
write_game_creator_config_atomically(path, &format!("{content}\n"))?;
}
Ok(())
}
/// 让文件始终带 `customEnabled` 与 `visibleModels`:自定义连接靠手写这些字段开启,
/// 键缺席时用户无法从文件本身看出开关和模型列表写在哪里。
pub(crate) fn ensure_game_creator_custom_llm_file_fields(
config: &mut GameCreatorAppConfigFile,
) -> bool {
let Some(llm) = config.llm.as_mut() else {
return false;
};
let mut changed = false;
if llm.custom_enabled.is_none() {
llm.custom_enabled = Some(false);
changed = true;
}
if llm.visible_models.is_none() {
llm.visible_models = Some(Vec::new());
changed = true;
}
changed
}
/// 默认官方路由策略;显式 llm.customEnabled 由调用方优先处理。
///
/// Debug and release binaries intentionally share this decision. The two
/// test exceptions are the Rust unit-test build and the explicitly env-gated debug
/// deterministic-provider E2E; their loopback fixtures are never compiled into
/// or enabled inside a shipped release binary.
pub(crate) fn game_creator_official_llm_route_locked() -> bool {
!debug_provider_e2e_route_unlocked()
&& game_creator_official_llm_route_locked_for_build(cfg!(test))
}
pub(crate) fn game_creator_official_llm_route_locked_for_build(is_test_build: bool) -> bool {
!is_test_build
}
fn debug_provider_e2e_route_unlocked() -> bool {
debug_provider_e2e_route_unlocked_for_build(
cfg!(debug_assertions),
std::env::var_os("GENARRATIVE_AGC_DEBUG_PROVIDER_E2E").as_deref(),
)
}
pub(crate) fn debug_provider_e2e_route_unlocked_for_build(
debug_assertions: bool,
value: Option<&std::ffi::OsStr>,
) -> bool {
debug_assertions && value == Some(std::ffi::OsStr::new("1"))
}
pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCreatorAppConfig) {
if !game_creator_official_llm_route_locked() {
return;
}
config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string();
config.agent_llm.clear();
config.editor_api.api_key.clear();
if config.llm.custom_enabled {
return;
}
config.llm.api_key.clear();
config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string();
config.llm.model = if config.selected_model_id.is_empty() {
OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string()
} else {
config.selected_model_id.clone()
};
config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string();
}
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> {
if let Some(content) = read_game_creator_config_file(path)? {
merge_game_creator_config_content(config, path, &content)?;
}
Ok(())
}
/// Only files inside the managed runtime config directory (real credentials)
/// may use the private-read channel: it hardens the owner/DACL on every read.
/// Repository-adjacent fallback templates and overlay files are shared inputs
/// that can be git-tracked; privatizing one on read silently locks the
/// worktree template to whichever account happened to run the dev CLI, so
/// they must go through the non-mutating snapshot channel instead.
pub(crate) fn game_creator_config_path_is_runtime_managed(path: &Path) -> bool {
game_creator_runtime_config_dir().is_some_and(|directory| path.starts_with(directory))
}
fn read_game_creator_snapshot_file_to_string(
path: &Path,
label: &str,
max_bytes: u64,
) -> Result<String, String> {
let (mut file, metadata) = open_project_snapshot_regular_file(path, label)?;
if metadata.len() > max_bytes {
return Err(format!("{label}过大,已拒绝读取:{}", path.display()));
}
let mut content = String::with_capacity(metadata.len() as usize);
file.read_to_string(&mut content)
.map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?;
let final_metadata = file
.metadata()
.map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?;
if final_metadata.len() != metadata.len() {
return Err(format!("{label}读取期间文件发生漂移:{}", path.display()));
}
Ok(content)
}
pub(crate) fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
let backup_path = game_creator_config_backup_path(path);
let path_exists = validate_game_creator_config_file_entry(path)?;
let read_path = if path_exists {
path
} else if validate_game_creator_config_file_entry(&backup_path)? {
backup_path.as_path()
} else {
return Ok(None);
};
let content = if game_creator_config_path_is_runtime_managed(read_path) {
read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?
} else {
read_game_creator_snapshot_file_to_string(read_path, "客户端配置", 256 * 1024)?
};
Ok(Some(content))
}
fn merge_game_creator_config_content(
config: &mut GameCreatorAppConfig,
path: &Path,
content: &str,
) -> Result<(), String> {
let file_config = serde_json::from_str::<GameCreatorAppConfigFile>(content)
.map_err(|error| format!("解析客户端配置失败:{}: {error}", 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(selected_model_id) = file_config.selected_model_id {
config.selected_model_id = selected_model_id;
}
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
config.selected_model_is_default = selected_model_is_default;
}
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_batch(writes: &[(PathBuf, String)]) -> Result<(), String> {
if writes.len() == 1 {
return write_game_creator_config_atomically(&writes[0].0, &writes[0].1);
}
let originals = writes
.iter()
.map(|(path, _)| read_game_creator_config_file(path))
.collect::<Result<Vec<_>, _>>()?;
for (index, (path, content)) in writes.iter().enumerate() {
if let Err(mut error) = write_game_creator_config_atomically(path, content) {
// 写入可能在替换后的权限检查失败,因此失败目标也需要核对并恢复。
for rollback_index in (0..=index).rev() {
let path = &writes[rollback_index].0;
let original = &originals[rollback_index];
if read_game_creator_config_file(path).ok().as_ref() == Some(original) {
continue;
}
let restored = match original {
Some(content) => write_game_creator_config_atomically(path, content),
None => fs::remove_file(path)
.or_else(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(error)
}
})
.map_err(|error| error.to_string()),
};
if let Err(restore_error) = restored {
error.push_str(&format!(
";恢复配置失败:{}: {restore_error}",
path.display()
));
}
}
return Err(error);
}
}
Ok(())
}
pub(crate) fn write_game_creator_config_atomically(
path: &Path,
content: &str,
) -> Result<(), String> {
#[cfg(windows)]
validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?;
#[cfg(not(windows))]
validate_game_creator_private_path_ancestors(path, "客户端配置文件")?;
let parent = path
.parent()
.ok_or_else(|| "客户端配置缺少父目录".to_string())?;
ensure_game_creator_private_directory_tree(parent, "客户端配置目录")
.map_err(|error| format!("创建客户端配置目录失败:{}: {error}", parent.display()))?;
// Never replace a symlink or another non-regular object. A regular
// existing config is repaired/tightened before it can be moved to the
// recoverable backup below.
let _initial_path_exists = validate_game_creator_config_file_entry(path)?;
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);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options.open(&temp_path).map_err(|error| {
format!(
"创建客户端配置临时文件失败:{}: {error}",
temp_path.display()
)
})?;
if let Err(error) =
harden_new_game_creator_private_path(&temp_path, false, "客户端配置临时文件")
{
drop(file);
let _ = fs::remove_file(&temp_path);
return Err(error);
}
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()
));
}
let backup_path = game_creator_config_backup_path(path);
// Re-check the destination immediately before changing either directory
// entry. A newly appeared regular target is moved aside; a link,
// reparse point, foreign object, or unsafe ACL is rejected by the same
// private-path policy used for reads.
let had_previous = validate_game_creator_config_file_entry(path)?;
if validate_game_creator_config_file_entry(&backup_path)? {
fs::remove_file(&backup_path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
format!(
"清理旧客户端配置备份失败:{}: {error}",
backup_path.display()
)
})?;
}
if had_previous {
if let Err(error) = fs::rename(path, &backup_path) {
let _ = fs::remove_file(&temp_path);
return Err(format!(
"准备替换客户端配置失败:{} -> {}: {error}",
path.display(),
backup_path.display()
));
}
}
match fs::rename(&temp_path, path) {
Ok(()) => {
let _ = fs::remove_file(&backup_path);
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path, false, true,
)?;
Ok(())
}
Err(error) => {
let restore_error = if had_previous {
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!(
"替换客户端配置失败:{} -> {}: {error}{restore_detail}",
temp_path.display(),
path.display()
))
}
}
}
pub(crate) fn merge_game_creator_llm_config(
config: &mut GameCreatorLlmConfig,
patch: GameCreatorLlmConfigFile,
) {
if let Some(value) = patch.custom_enabled {
config.custom_enabled = value;
}
if let Some(value) = patch.visible_models {
config.visible_models = value;
}
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.custom_enabled {
config.custom_enabled = Some(value);
}
if let Some(value) = patch.visible_models {
config.visible_models = Some(value);
}
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())
}
}
fn custom_llm_enabled_at_config_path(path: &Path) -> Result<bool, String> {
let parent = path.parent().ok_or("客户端配置缺少父目录")?;
let mut enabled = false;
for name in [
GAME_CREATOR_CONFIG_FILE_NAME,
GAME_CREATOR_LOCAL_CONFIG_FILE_NAME,
] {
if let Some(content) = read_game_creator_config_file(&parent.join(name))? {
let file: GameCreatorAppConfigFile =
serde_json::from_str(&content).map_err(|_| "解析客户端配置失败".to_string())?;
if let Some(value) = file.llm.and_then(|llm| llm.custom_enabled) {
enabled = value;
}
}
}
Ok(enabled)
}
pub(crate) fn validate_custom_llm_connection(
llm: &GameCreatorLlmConfig,
) -> Result<url::Url, String> {
if llm.api_key.trim().is_empty() {
return Err("请填写自定义 LLM API Key".to_string());
}
let url =
url::Url::parse(llm.base_url.trim()).map_err(|_| "自定义 LLM API 地址无效".to_string())?;
if !matches!(url.scheme(), "http" | "https")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("请填写不含凭据、查询参数和片段的 HTTP(S) API 根地址".to_string());
}
Ok(url)
}
pub(crate) fn normalize_custom_llm_model_ids(ids: &[String]) -> Result<Vec<String>, String> {
if ids.len() > 4096 {
return Err("模型列表超过 4096 项上限".to_string());
}
let mut result = Vec::new();
let mut seen = std::collections::HashSet::new();
for id in ids {
let id = id.trim();
if id.is_empty()
|| id.len() > 256
|| id.chars().any(|c| c.is_control() || c.is_whitespace())
{
return Err("模型标识为空、包含空白或超过 256 字节".to_string());
}
if seen.insert(id.to_string()) {
result.push(id.to_string());
}
}
Ok(result)
}
fn apply_custom_llm_model_selection(config: &mut GameCreatorAppConfig) {
if !config.llm.custom_enabled {
return;
}
if config.selected_model_is_default
|| !config
.llm
.visible_models
.contains(&config.selected_model_id)
{
config.selected_model_id = config
.llm
.visible_models
.first()
.cloned()
.unwrap_or_default();
config.selected_model_is_default = true;
}
config.llm.model = config.selected_model_id.clone();
}
pub(crate) async fn fetch_custom_llm_models(
llm: &GameCreatorLlmConfig,
) -> Result<Vec<String>, String> {
let mut url = validate_custom_llm_connection(llm)?;
url.set_path(&format!("{}/models", url.path().trim_end_matches('/')));
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|_| "初始化模型列表请求失败".to_string())?;
let mut response = client
.get(url)
.bearer_auth(llm.api_key.trim())
.send()
.await
.map_err(|error| {
if error.is_timeout() {
"模型列表请求超时,请重试".to_string()
} else {
"无法连接模型端点,请检查 API 地址和网络".to_string()
}
})?;
if !response.status().is_success() {
return Err(format!(
"模型列表读取失败(HTTP {}",
response.status().as_u16()
));
}
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| "读取模型列表响应失败或超时".to_string())?
{
if body.len() + chunk.len() > 1024 * 1024 {
return Err("模型列表响应超过 1 MiB 上限".to_string());
}
body.extend_from_slice(&chunk);
}
#[derive(Deserialize)]
struct Model {
id: String,
}
#[derive(Deserialize)]
struct Models {
data: Vec<Model>,
}
let models: Models = serde_json::from_slice(&body)
.map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?;
let ids = models
.data
.into_iter()
.map(|model| model.id)
.collect::<Vec<_>>();
let mut ids = normalize_custom_llm_model_ids(&ids)?;
ids.sort();
Ok(ids)
}
pub(crate) fn normalize_game_creator_app_config(
mut config: GameCreatorAppConfig,
) -> Result<GameCreatorAppConfig, String> {
if config.schema_version != GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION {
return Err(format!(
"客户端配置 schemaVersion 不受支持:{}",
config.schema_version
));
}
if game_creator_official_llm_route_locked() {
lock_game_creator_app_config_to_official_route(&mut config);
}
config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?;
if config.llm.custom_enabled {
validate_custom_llm_connection(&config.llm)?;
config.llm.visible_models = normalize_custom_llm_model_ids(&config.llm.visible_models)?;
if config.llm.visible_models.is_empty() {
return Err("请至少勾选一个要显示的模型".to_string());
}
if config.llm.api_kind != "openai_responses" {
return Err("自定义 LLM 需要支持 OpenAI Responses 协议".to_string());
}
apply_custom_llm_model_selection(&mut config);
}
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();
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.custom_enabled.is_none()
&& patch.visible_models.is_none()
&& 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}"
);
}
}
}
#[cfg(test)]
mod private_file_write_tests {
use super::*;
#[test]
fn private_file_write_installs_via_hardened_sibling_and_replaces_regular_target() {
let root = tempfile::tempdir().expect("create private file fixture");
let parent = root.path().join("private");
let path = parent.join("state.json");
write_game_creator_private_file(&path, b"first\n", "测试私有文件")
.expect("install first private file");
assert_eq!(
fs::read(&path).expect("read first private file"),
b"first\n"
);
write_game_creator_private_file(&path, b"second\n", "测试私有文件")
.expect("replace private file");
assert_eq!(
fs::read(&path).expect("read replaced private file"),
b"second\n"
);
let leftovers = fs::read_dir(&parent)
.expect("read private file directory")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".tmp-") || name.contains(".previous-"))
.collect::<Vec<_>>();
assert!(
leftovers.is_empty(),
"temporary/backup residue: {leftovers:?}"
);
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let metadata = fs::symlink_metadata(&path).expect("metadata");
assert_eq!(metadata.mode() & 0o777, 0o600);
}
}
#[test]
fn private_file_append_creates_and_reuses_hardened_target() {
let root = tempfile::tempdir().expect("create append fixture");
let path = root.path().join("private").join("journal.log");
append_game_creator_private_file(&path, b"first\n", "测试追加文件")
.expect("append first record");
append_game_creator_private_file(&path, b"second\n", "测试追加文件")
.expect("append second record");
assert_eq!(
fs::read(&path).expect("read append file"),
b"first\nsecond\n"
);
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let metadata = fs::symlink_metadata(&path).expect("append metadata");
assert_eq!(metadata.nlink(), 1);
assert_eq!(metadata.mode() & 0o777, 0o600);
}
}
}
#[cfg(test)]
mod custom_llm_tests {
use super::*;
use std::io::{Read, Write};
fn custom_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: true,
api_key: "custom-fixture-key".into(),
base_url: "https://provider.example/v1".into(),
model: "vendor/model.v1:latest".into(),
visible_models: vec!["vendor/model.v1:latest".into(), "second.model".into()],
..GameCreatorLlmConfig::default()
}
}
#[test]
fn custom_llm_defaults_closed_and_explicit_config_survives_scrub() {
assert!(!GameCreatorLlmConfig::default().custom_enabled);
let mut file: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
"llm": {"customEnabled": true, "apiKey": "fixture", "baseUrl": "https://custom.example/v1", "visibleModels": ["vendor/a.v1"]}
})).unwrap();
assert!(!scrub_locked_game_creator_config_file(&mut file));
let mut config = GameCreatorAppConfig::default();
merge_game_creator_llm_config(&mut config.llm, file.llm.unwrap());
assert!(config.llm.custom_enabled);
assert_eq!(config.llm.api_key, "fixture");
assert_eq!(config.llm.visible_models, ["vendor/a.v1"]);
}
#[test]
fn custom_llm_selection_is_allowlisted_and_removed_model_falls_back() {
let mut config = GameCreatorAppConfig {
llm: custom_llm(),
selected_model_id: "second.model".into(),
..GameCreatorAppConfig::default()
};
apply_custom_llm_model_selection(&mut config);
assert_eq!(config.llm.model, "second.model");
config.llm.visible_models.pop();
let normalized = normalize_game_creator_app_config(config).unwrap();
assert_eq!(normalized.llm.model, "vendor/model.v1:latest");
assert_eq!(normalized.selected_model_id, normalized.llm.model);
assert!(normalized.selected_model_is_default);
assert!(game_creator_codex_app_server_llm_route_error(
"codex_app_server",
&normalized.llm,
"llm"
)
.is_none());
}
#[test]
fn custom_llm_missing_connection_or_models_is_rejected_without_official_fallback() {
for field in ["key", "models", "url"] {
let mut config = GameCreatorAppConfig {
llm: custom_llm(),
..GameCreatorAppConfig::default()
};
match field {
"key" => config.llm.api_key.clear(),
"models" => config.llm.visible_models.clear(),
_ => config.llm.base_url = "file:///private".into(),
}
assert!(
normalize_game_creator_app_config(config).is_err(),
"{field}"
);
}
let mut config = custom_llm();
config.api_key.clear();
assert!(build_game_creator_platform_llm_config(&config, "llm").is_err());
}
#[test]
fn custom_llm_migration_uses_merged_overlay_switch() {
let root = tempfile::tempdir().unwrap();
let primary = root.path().join(GAME_CREATOR_CONFIG_FILE_NAME);
let overlay = root.path().join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
std::fs::write(
&primary,
r#"{"llm":{"customEnabled":false,"apiKey":"fixture"}}"#,
)
.unwrap();
std::fs::write(&overlay, r#"{"llm":{"customEnabled":true}}"#).unwrap();
assert!(custom_llm_enabled_at_config_path(&primary).unwrap());
std::fs::write(&overlay, r#"{"llm":{"customEnabled":false}}"#).unwrap();
assert!(!custom_llm_enabled_at_config_path(&primary).unwrap());
}
fn model_server(status: &str, body: &str) -> (String, std::thread::JoinHandle<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/v1", listener.local_addr().unwrap());
let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let handle = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut buf = [0; 1024];
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let count = socket.read(&mut buf).unwrap();
if count == 0 {
break;
}
request.extend_from_slice(&buf[..count]);
}
let _ = socket.write_all(response.as_bytes());
String::from_utf8(request).unwrap()
});
(url, handle)
}
#[tokio::test]
async fn custom_llm_discovers_models_directly_with_custom_bearer_and_deduplicates() {
let (url, server) = model_server(
"200 OK",
r#"{"data":[{"id":"vendor/model.v1:latest"},{"id":"second.model"},{"id":"second.model"}]}"#,
);
let mut llm = custom_llm();
llm.base_url = url;
assert_eq!(
fetch_custom_llm_models(&llm).await.unwrap(),
["second.model", "vendor/model.v1:latest"]
);
let request = server.join().unwrap();
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
assert!(request
.to_ascii_lowercase()
.contains("authorization: bearer custom-fixture-key"));
assert!(!request.contains("/api/llm"));
}
#[tokio::test]
async fn custom_llm_discovery_reports_safe_errors_and_bounds_response() {
for (status, body, expected) in [
(
"401 Unauthorized",
"private-upstream-secret".to_string(),
"HTTP 401",
),
(
"302 Found",
"private-upstream-secret".to_string(),
"HTTP 302",
),
("200 OK", "not-json-private-secret".to_string(), "data[].id"),
("200 OK", "x".repeat(1024 * 1024 + 1), "1 MiB"),
] {
let (url, server) = model_server(status, &body);
let mut llm = custom_llm();
llm.base_url = url;
let error = fetch_custom_llm_models(&llm).await.unwrap_err();
assert!(error.contains(expected), "{error}");
assert!(!error.contains("secret"));
server.join().unwrap();
}
}
#[tokio::test]
async fn custom_llm_discovery_times_out_when_response_body_stalls() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut llm = custom_llm();
llm.base_url = format!("http://{}/v1", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut request = [0; 4096];
socket.read(&mut request).unwrap();
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
.unwrap();
std::thread::sleep(std::time::Duration::from_secs(16));
});
let start = std::time::Instant::now();
let error = fetch_custom_llm_models(&llm).await.unwrap_err();
assert!(error.contains("超时"), "{error}");
assert!(start.elapsed() < std::time::Duration::from_secs(16));
server.join().unwrap();
}
}
#[cfg(test)]
mod private_path_elevation_policy_tests {
use super::*;
#[test]
fn automatic_elevation_covers_all_regular_project_descendants() {
let root = tempfile::tempdir().expect("create path policy fixture");
let external_file = root.path().join("game").join("index.html");
let nested_project_file = root.path().join("assets").join("sprites").join("hero.png");
let managed_agent_file = root
.path()
.join(".agent")
.join("runtime")
.join("state.json");
fs::create_dir_all(managed_agent_file.parent().expect("agent parent"))
.expect("create managed agent fixture");
fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker");
let traversal_path = root
.path()
.join(".agent")
.join("..")
.join("Windows")
.join("System32");
assert!(game_creator_private_path_allows_auto_elevation(
&external_file
));
assert!(game_creator_private_path_allows_auto_elevation(
&nested_project_file
));
assert!(game_creator_private_path_allows_auto_elevation(
&managed_agent_file
));
assert!(!game_creator_private_path_allows_auto_elevation(
&traversal_path
));
}
#[test]
fn project_marker_does_not_authorize_unrelated_or_nested_agent_paths() {
let root = tempfile::tempdir().expect("create path policy fixture");
fs::create_dir_all(root.path().join(".agent")).expect("create agent directory");
fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker");
let unrelated = tempfile::tempdir().expect("create unrelated fixture");
let unrelated_file = unrelated.path().join("game/index.html");
let nested_agent = root.path().join("game").join(".agent").join("state.json");
assert!(!game_creator_private_path_allows_auto_elevation(
&unrelated_file
));
assert!(!game_creator_private_path_allows_auto_elevation(
&nested_agent
));
}
#[cfg(windows)]
#[test]
fn explicit_user_selection_allows_repair_on_user_selected_non_system_path() {
let profile = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.expect("current user profile");
let windir = std::env::var_os("WINDIR")
.map(PathBuf::from)
.expect("WINDIR");
let program_files = std::env::var_os("ProgramFiles")
.map(PathBuf::from)
.expect("ProgramFiles");
let system_drive = std::env::var_os("SystemDrive")
.map(PathBuf::from)
.expect("SystemDrive");
let selected = profile.join("Documents").join("fixture.png");
let external_drive = PathBuf::from(r"D:\Genarrative\fixture.png");
let system_path = windir.join("System32").join("fixture.png");
let program_files_path = program_files.join("Genarrative").join("fixture.png");
let drive_root = PathBuf::from(format!(
"{}\\",
system_drive.to_string_lossy().trim_end_matches(['\\', '/'])
));
let unc_root = PathBuf::from(r"\\server\share\");
let traversal_path = PathBuf::from(r"C:\Users\test\..\Windows\fixture.png");
assert!(game_creator_user_selected_path_allows_auto_elevation(
&selected
));
assert!(game_creator_user_selected_path_allows_auto_elevation(
&external_drive
));
assert!(!game_creator_user_selected_path_allows_auto_elevation(
&system_path
));
assert!(!game_creator_user_selected_path_allows_auto_elevation(
&program_files_path
));
assert!(!game_creator_user_selected_path_allows_auto_elevation(
&drive_root
));
assert!(!game_creator_user_selected_path_allows_auto_elevation(
&unc_root
));
assert!(!game_creator_user_selected_path_allows_auto_elevation(
&traversal_path
));
}
#[test]
fn arbitrary_agent_directory_without_project_marker_cannot_trigger_elevation() {
let root = tempfile::tempdir().expect("create unverified agent fixture");
let path = root
.path()
.join(".agent")
.join("runtime")
.join("state.json");
fs::create_dir_all(path.parent().expect("agent parent")).expect("create agent fixture");
assert!(!game_creator_private_path_allows_auto_elevation(&path));
}
#[test]
fn nested_agent_components_cannot_trigger_elevation() {
let root = tempfile::tempdir().expect("create nested agent fixture");
let path = root
.path()
.join(".agent")
.join("nested")
.join(".agent")
.join("state.json");
fs::create_dir_all(path.parent().expect("nested agent parent"))
.expect("create nested agent");
fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker");
assert!(!game_creator_private_path_allows_auto_elevation(&path));
}
#[cfg(windows)]
#[test]
fn explicit_project_root_entry_can_tighten_owner_correct_inherited_acl() {
let root = tempfile::tempdir().expect("create project root fixture");
assert!(prepare_game_creator_project_root_for_read(root.path(), true, "项目根").is_ok());
}
#[cfg(windows)]
#[test]
fn owner_flag_is_requested_only_when_owner_initialization_is_needed() {
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
assert_eq!(
windows_private_dacl_security_information(true, false) & OWNER_SECURITY_INFORMATION,
OWNER_SECURITY_INFORMATION
);
assert_eq!(
windows_private_dacl_security_information(true, true) & OWNER_SECURITY_INFORMATION,
0
);
}
#[cfg(windows)]
#[test]
fn canonicalize_access_denied_is_classified_as_acl_repair_candidate() {
let detail =
"解析客户端 AppData 配置目录失败:C:\\Temp\\agc-config: 拒绝访问。 (os error 5)";
assert!(windows_acl_error_may_need_elevation(detail));
}
#[cfg(windows)]
#[test]
fn acl_repair_argument_list_keeps_space_containing_path_quoted() {
let path = r"C:\Users\lingh\Documents\Genarrative GameAgent\gameagent-f84a5353\.agent\project.lock";
let arguments = windows_acl_repair_argument_list(
path,
"S-1-5-21-1-2-3-1001",
"0123456789abcdef0123456789abcdef",
WindowsAclRepairScope::Managed,
);
assert_eq!(
arguments,
format!(
"\"--repair-private-acl\" \"{path}\" \"--target-user-sid\" \"S-1-5-21-1-2-3-1001\" \"--authorization\" \"0123456789abcdef0123456789abcdef\" \"--scope\" \"managed\""
)
);
}
#[cfg(windows)]
#[test]
fn custom_runtime_config_path_uses_explicit_user_selected_scope() {
let root = tempfile::tempdir().expect("create custom config fixture");
assert_eq!(
game_creator_runtime_config_repair_scope(&root.path().join("config")),
WindowsAclRepairScope::UserSelected
);
}
#[cfg(windows)]
#[test]
fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() {
let root = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("APPDATA"))
.map(PathBuf::from)
.expect("local appdata");
let packaged = root.join("world.genarrative.ai-game-creator");
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
assert!(game_creator_private_path_allows_auto_elevation(&verbatim));
assert_eq!(
game_creator_runtime_config_repair_scope(&verbatim),
WindowsAclRepairScope::Managed
);
}
#[cfg(windows)]
#[test]
fn picker_grant_is_required_and_directory_grant_covers_descendants() {
let root = tempfile::tempdir().expect("create picker grant fixture");
let directory = root.path().join("selected");
let child = directory.join("nested").join("file.png");
let unapproved = root.path().join("other").join("file.png");
assert!(!user_selected_path_is_authorized(&directory, true));
assert!(!user_selected_path_is_authorized(&unapproved, false));
register_game_creator_user_selected_path(&directory, true);
assert!(user_selected_path_is_authorized(&directory, true));
assert!(user_selected_path_is_authorized(&child, false));
assert!(!user_selected_path_is_authorized(&directory, false));
assert!(!user_selected_path_is_authorized(&unapproved, false));
revoke_game_creator_user_selected_path(&directory);
assert!(!user_selected_path_is_authorized(&directory, true));
assert!(!user_selected_path_is_authorized(&child, false));
}
}