1828 lines
62 KiB
Rust
1828 lines
62 KiB
Rust
use super::*;
|
|
|
|
#[test]
|
|
fn config_file_overrides_defaults_without_env() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("test config dir");
|
|
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
|
fs::write(
|
|
&config_path,
|
|
r#"{
|
|
"llm": {
|
|
"apiKey": "file-key",
|
|
"baseUrl": "https://example.test/v1",
|
|
"model": "model-from-file",
|
|
"apiKind": "openai_chat",
|
|
"reasoningEffort": "medium",
|
|
"stream": true,
|
|
"webSearchEnabled": true,
|
|
"requestTimeoutMs": 42000,
|
|
"maxRetries": 2,
|
|
"retryBackoffMs": 700
|
|
},
|
|
"agentLlm": {
|
|
"planner": {
|
|
"model": "planner-model",
|
|
"apiKind": "anthropic",
|
|
"reasoningEffort": "default",
|
|
"stream": false,
|
|
"webSearchEnabled": false
|
|
},
|
|
"generator": {
|
|
"baseUrl": "https://generator.example.test/v1",
|
|
"model": "generator-model"
|
|
}
|
|
},
|
|
"editorApi": {
|
|
"baseUrl": "http://127.0.0.1:8099",
|
|
"apiKey": "editor-key"
|
|
}
|
|
}
|
|
|
|
"#,
|
|
)
|
|
.expect("write local config");
|
|
|
|
let mut config = GameCreatorAppConfig::default();
|
|
merge_game_creator_config_file(&mut config, &config_path).expect("merge config");
|
|
|
|
assert_eq!(config.llm.api_key, "file-key");
|
|
assert_eq!(config.llm.base_url, "https://example.test/v1");
|
|
assert_eq!(config.llm.model, "model-from-file");
|
|
assert_eq!(config.llm.api_kind, "openai_chat");
|
|
assert_eq!(config.llm.reasoning_effort, "medium");
|
|
assert!(config.llm.stream);
|
|
assert!(config.llm.web_search_enabled);
|
|
assert_eq!(
|
|
config.llm.context_window_tokens,
|
|
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
|
|
);
|
|
assert_eq!(
|
|
config.llm.auto_compact_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
|
|
);
|
|
assert_eq!(
|
|
config.llm.tool_output_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
|
);
|
|
assert_eq!(config.llm.request_timeout_ms, 42_000);
|
|
assert_eq!(config.llm.max_retries, 2);
|
|
assert_eq!(config.llm.retry_backoff_ms, 700);
|
|
let planner_llm = resolve_game_creator_llm_config_for_agent(&config, "planner");
|
|
assert_eq!(planner_llm.api_key, "file-key");
|
|
assert_eq!(planner_llm.base_url, "https://example.test/v1");
|
|
assert_eq!(planner_llm.model, "planner-model");
|
|
assert_eq!(planner_llm.api_kind, "anthropic");
|
|
assert_eq!(planner_llm.reasoning_effort, "default");
|
|
assert!(!planner_llm.stream);
|
|
assert!(!planner_llm.web_search_enabled);
|
|
assert_eq!(
|
|
planner_llm.context_window_tokens,
|
|
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
|
|
);
|
|
assert_eq!(
|
|
planner_llm.auto_compact_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
|
|
);
|
|
assert_eq!(
|
|
planner_llm.tool_output_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
|
);
|
|
let generator_llm = resolve_game_creator_llm_config_for_agent(&config, "generator");
|
|
assert_eq!(generator_llm.api_key, "file-key");
|
|
assert_eq!(generator_llm.base_url, "https://generator.example.test/v1");
|
|
assert_eq!(generator_llm.model, "generator-model");
|
|
assert_eq!(generator_llm.api_kind, "openai_chat");
|
|
assert_eq!(generator_llm.reasoning_effort, "high");
|
|
assert!(generator_llm.web_search_enabled);
|
|
assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099");
|
|
assert_eq!(config.editor_api.api_key, "editor-key");
|
|
|
|
fs::remove_dir_all(root).expect("cleanup test config dir");
|
|
}
|
|
#[test]
|
|
fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() {
|
|
let default_config = GameCreatorAppConfig::default();
|
|
assert_eq!(
|
|
default_config.agent_mode,
|
|
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
|
|
);
|
|
assert!(
|
|
default_config.llm.stream,
|
|
"新的全局 LLM 配置默认使用流式响应"
|
|
);
|
|
|
|
let inherited_agent_llm =
|
|
resolve_game_creator_llm_config_for_agent(&default_config, "design-director");
|
|
assert!(
|
|
inherited_agent_llm.stream,
|
|
"未覆盖时 Agent 必须继承全局流式默认"
|
|
);
|
|
|
|
let mut explicitly_non_streaming_config = default_config.clone();
|
|
explicitly_non_streaming_config.agent_llm.insert(
|
|
"design-director".to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
stream: Some(false),
|
|
..GameCreatorLlmConfigFile::default()
|
|
},
|
|
);
|
|
assert!(
|
|
!resolve_game_creator_llm_config_for_agent(
|
|
&explicitly_non_streaming_config,
|
|
"design-director",
|
|
)
|
|
.stream,
|
|
"Agent 显式关闭流式请求时必须覆盖全局默认"
|
|
);
|
|
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
|
fs::write(
|
|
&config_path,
|
|
r#"{"agentMode":"provider","llm":{"stream":false}}"#,
|
|
)
|
|
.expect("write explicit provider mode and non-stream override");
|
|
let mut config = GameCreatorAppConfig::default();
|
|
merge_game_creator_config_file(&mut config, &config_path).expect("merge provider mode");
|
|
assert_eq!(config.agent_mode, GAME_CREATOR_AGENT_MODE_PROVIDER);
|
|
assert!(
|
|
!config.llm.stream,
|
|
"已有配置显式关闭流式请求时必须保留该选择"
|
|
);
|
|
assert_eq!(
|
|
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|
|
.expect("app-server mode"),
|
|
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
|
|
);
|
|
assert_eq!(
|
|
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_CLI).expect("cli mode"),
|
|
GAME_CREATOR_AGENT_MODE_CODEX_CLI
|
|
);
|
|
assert!(normalize_game_creator_agent_mode("unknown")
|
|
.expect_err("unknown mode")
|
|
.contains("agentMode"));
|
|
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_agent_mode_migration_preserves_non_responses_provider_routes() {
|
|
let responses: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
|
|
"llm": { "apiKind": "openai_responses" }
|
|
}))
|
|
.expect("responses config");
|
|
assert_eq!(
|
|
legacy_game_creator_agent_mode(&responses),
|
|
Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|
|
);
|
|
|
|
let chat: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
|
|
"llm": { "apiKind": "openai_chat" }
|
|
}))
|
|
.expect("chat config");
|
|
assert_eq!(
|
|
legacy_game_creator_agent_mode(&chat),
|
|
Some(GAME_CREATOR_AGENT_MODE_PROVIDER)
|
|
);
|
|
|
|
let mixed: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
|
|
"llm": { "apiKind": "openai_responses" },
|
|
"agentLlm": { "art-director": { "apiKind": "anthropic" } }
|
|
}))
|
|
.expect("mixed config");
|
|
assert_eq!(
|
|
legacy_game_creator_agent_mode(&mixed),
|
|
Some(GAME_CREATOR_AGENT_MODE_PROVIDER)
|
|
);
|
|
|
|
let explicit: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
|
|
"agentMode": "codex_cli",
|
|
"llm": { "apiKind": "openai_chat" }
|
|
}))
|
|
.expect("explicit config");
|
|
assert_eq!(legacy_game_creator_agent_mode(&explicit), None);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_unversioned_config_migration_sets_schema_and_preserves_explicit_search_disable() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
|
fs::write(
|
|
&config_path,
|
|
r#"{
|
|
"agentMode": "codex_app_server",
|
|
"llm": {
|
|
"apiKind": "openai_responses",
|
|
"webSearchEnabled": false
|
|
}
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write legacy config");
|
|
|
|
migrate_legacy_game_creator_agent_mode(&config_path).expect("migrate legacy config");
|
|
let migrated = fs::read_to_string(&config_path).expect("read migrated config");
|
|
let value: serde_json::Value = serde_json::from_str(&migrated).expect("parse migrated config");
|
|
assert_eq!(
|
|
value["schemaVersion"],
|
|
GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION
|
|
);
|
|
assert_eq!(value["llm"]["webSearchEnabled"], false);
|
|
fs::remove_dir_all(root).expect("cleanup migrated config");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_unversioned_direct_config_fills_omitted_controlled_search_default() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
|
fs::write(
|
|
&config_path,
|
|
r#"{
|
|
"agentMode": "codex_app_server",
|
|
"llm": { "apiKind": "openai_responses" }
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write legacy config without search override");
|
|
|
|
migrate_legacy_game_creator_agent_mode(&config_path).expect("migrate legacy config");
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(&config_path).expect("read migrated config"))
|
|
.expect("parse migrated config");
|
|
assert_eq!(
|
|
value["schemaVersion"],
|
|
GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION
|
|
);
|
|
assert_eq!(value["llm"]["webSearchEnabled"], true);
|
|
fs::remove_dir_all(root).expect("cleanup migrated config");
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_config_schema_version_fails_closed() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
|
fs::write(
|
|
&config_path,
|
|
r#"{"schemaVersion":"game-creator-config.v99"}"#,
|
|
)
|
|
.expect("write unsupported config");
|
|
let error = migrate_legacy_game_creator_agent_mode(&config_path)
|
|
.expect_err("unsupported config schema must fail");
|
|
assert!(error.contains("schemaVersion"));
|
|
fs::remove_dir_all(root).expect("cleanup unsupported config");
|
|
}
|
|
|
|
#[test]
|
|
fn locked_config_scrub_removes_all_legacy_provider_credentials() {
|
|
let mut config: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({
|
|
"agentMode": "provider",
|
|
"llm": {
|
|
"apiKey": "legacy-global-key",
|
|
"baseUrl": "https://legacy.example.test/v1",
|
|
"model": "legacy-model",
|
|
"apiKind": "openai_chat",
|
|
"reasoningEffort": "high"
|
|
},
|
|
"agentLlm": {
|
|
"planner": {
|
|
"apiKey": "legacy-agent-key",
|
|
"baseUrl": "https://agent.example.test/v1",
|
|
"model": "agent-model",
|
|
"apiKind": "anthropic"
|
|
}
|
|
},
|
|
"editorApi": {
|
|
"baseUrl": "https://editor.example.test",
|
|
"apiKey": "legacy-editor-key"
|
|
}
|
|
}))
|
|
.expect("legacy release config");
|
|
|
|
assert!(scrub_locked_game_creator_config_file(&mut config));
|
|
assert_eq!(
|
|
config.agent_mode.as_deref(),
|
|
Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|
|
);
|
|
assert!(config.agent_llm.is_none());
|
|
assert!(config.editor_api.is_none());
|
|
let llm = config
|
|
.llm
|
|
.as_ref()
|
|
.expect("global llm remains as non-sensitive tuning");
|
|
assert!(llm.api_key.is_none());
|
|
assert!(llm.base_url.is_none());
|
|
assert!(llm.model.is_none());
|
|
assert!(llm.api_kind.is_none());
|
|
let serialized = serde_json::to_string(&config).expect("serialize scrubbed config");
|
|
assert!(!serialized.contains("legacy-global-key"));
|
|
assert!(!serialized.contains("legacy-agent-key"));
|
|
assert!(!serialized.contains("legacy-editor-key"));
|
|
assert!(!serialized.contains("legacy.example.test"));
|
|
}
|
|
|
|
#[test]
|
|
fn official_llm_route_is_locked_for_debug_and_release_platform_builds() {
|
|
assert!(game_creator_official_llm_route_locked_for_build(false));
|
|
assert!(!game_creator_official_llm_route_locked_for_build(true));
|
|
}
|
|
|
|
#[test]
|
|
fn debug_provider_e2e_flag_only_unlocks_debug_platform_build() {
|
|
assert!(debug_provider_e2e_route_unlocked_for_build(
|
|
true,
|
|
Some(std::ffi::OsStr::new("1"))
|
|
));
|
|
assert!(!debug_provider_e2e_route_unlocked_for_build(
|
|
false,
|
|
Some(std::ffi::OsStr::new("1"))
|
|
));
|
|
assert!(!debug_provider_e2e_route_unlocked_for_build(
|
|
true,
|
|
Some(std::ffi::OsStr::new("0"))
|
|
));
|
|
assert!(!debug_provider_e2e_route_unlocked_for_build(true, None));
|
|
}
|
|
|
|
#[test]
|
|
fn codex_app_server_requires_responses_route_and_disables_native_web_search() {
|
|
let mut llm = GameCreatorLlmConfig::default();
|
|
llm.api_key = "secret".to_string();
|
|
llm.api_kind = "anthropic".to_string();
|
|
assert!(game_creator_codex_app_server_llm_route_error(
|
|
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
|
|
&llm,
|
|
"llm"
|
|
)
|
|
.expect("unsupported route")
|
|
.contains("openai_responses"));
|
|
|
|
llm.api_key.clear();
|
|
assert!(game_creator_codex_app_server_llm_route_error(
|
|
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
|
|
&llm,
|
|
"llm"
|
|
)
|
|
.expect("unsupported empty-key route")
|
|
.contains("openai_responses"));
|
|
llm.api_kind = "openai_responses".to_string();
|
|
llm.web_search_enabled = true;
|
|
assert!(game_creator_codex_app_server_llm_route_error(
|
|
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER,
|
|
&llm,
|
|
"llm"
|
|
)
|
|
.is_none());
|
|
llm.api_key = "secret".to_string();
|
|
assert!(game_creator_codex_app_server_llm_route_error(
|
|
GAME_CREATOR_AGENT_MODE_CODEX_CLI,
|
|
&llm,
|
|
"llm"
|
|
)
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_llm_config_deserialization_supplies_context_budget_defaults() {
|
|
let llm: GameCreatorLlmConfig = serde_json::from_value(serde_json::json!({
|
|
"apiKey": "legacy-key",
|
|
"baseUrl": "https://legacy.example.test/v1",
|
|
"model": "legacy-model",
|
|
"apiKind": "openai_chat",
|
|
"reasoningEffort": "medium",
|
|
"stream": false,
|
|
"webSearchEnabled": false,
|
|
"requestTimeoutMs": 30_000,
|
|
"maxRetries": 1,
|
|
"retryBackoffMs": 500
|
|
}))
|
|
.expect("deserialize legacy llm config");
|
|
|
|
assert_eq!(
|
|
llm.context_window_tokens,
|
|
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
|
|
);
|
|
assert_eq!(
|
|
llm.auto_compact_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
|
|
);
|
|
assert_eq!(
|
|
llm.tool_output_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() {
|
|
let expected = BTreeMap::from([
|
|
("project-supervisor", "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"),
|
|
]);
|
|
let rust_defaults = GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS
|
|
.iter()
|
|
.copied()
|
|
.collect::<BTreeMap<_, _>>();
|
|
assert_eq!(rust_defaults, expected);
|
|
assert_eq!(
|
|
GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS.len(),
|
|
rust_defaults.len(),
|
|
"规范 Agent 默认映射不能包含重复 ID"
|
|
);
|
|
|
|
let status_agent_ids = game_creator_llm_agent_status_definitions()
|
|
.into_iter()
|
|
.map(|definition| definition.agent_id)
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
assert_eq!(
|
|
status_agent_ids,
|
|
expected
|
|
.keys()
|
|
.map(|agent_id| (*agent_id).to_string())
|
|
.collect::<std::collections::BTreeSet<_>>(),
|
|
"新增规范 Agent 时必须先显式选择 reasoning effort,不能静默继承全局"
|
|
);
|
|
for (agent_id, effort) in &expected {
|
|
assert_eq!(
|
|
game_creator_llm_agent_default_reasoning_effort(agent_id),
|
|
Some(*effort)
|
|
);
|
|
parse_game_creator_llm_reasoning_effort(effort).expect("canonical reasoning effort");
|
|
}
|
|
|
|
let template =
|
|
serde_json::from_str::<GameCreatorAppConfigFile>(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)
|
|
.expect("parse bundled runtime config template");
|
|
assert_eq!(
|
|
template.agent_mode.as_deref(),
|
|
Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|
|
);
|
|
assert_eq!(
|
|
template.llm.as_ref().and_then(|llm| llm.max_retries),
|
|
Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES)
|
|
);
|
|
assert_eq!(
|
|
template
|
|
.planning
|
|
.as_ref()
|
|
.and_then(|planning| planning.capability_enabled),
|
|
Some(true),
|
|
"bundled runtime config must keep planning capability enabled by default"
|
|
);
|
|
assert!(
|
|
template.agent_llm.unwrap_or_default().is_empty(),
|
|
"bundled template must not persist canonical defaults as explicit overrides"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_reasoning_only_patch_does_not_activate_role_llm_override() {
|
|
let mut config = GameCreatorAppConfig::default();
|
|
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
|
|
config.agent_llm.insert(
|
|
agent_id.to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
reasoning_effort: Some(effort.to_string()),
|
|
..GameCreatorLlmConfigFile::default()
|
|
},
|
|
);
|
|
assert!(
|
|
!has_game_creator_agent_llm_override(&config, agent_id),
|
|
"canonical reasoning-only default must not activate {agent_id} role LLM"
|
|
);
|
|
}
|
|
|
|
config.agent_llm.insert(
|
|
"design-director".to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
reasoning_effort: Some("high".to_string()),
|
|
..GameCreatorLlmConfigFile::default()
|
|
},
|
|
);
|
|
assert!(has_game_creator_agent_llm_override(
|
|
&config,
|
|
"design-director"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
fs::write(
|
|
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
|
r#"{
|
|
"agentMode": "provider",
|
|
"llm": {
|
|
"apiKey": "global-key",
|
|
"baseUrl": "https://global.example.test/v1",
|
|
"model": "global-model",
|
|
"reasoningEffort": "default"
|
|
},
|
|
"agentLlm": {}
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write legacy empty Agent config");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
|
|
let config = load_game_creator_app_config().expect("load legacy empty Agent config");
|
|
assert!(config.agent_llm.is_empty());
|
|
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
|
|
assert_eq!(
|
|
resolve_game_creator_llm_config_for_agent(&config, agent_id).reasoning_effort,
|
|
effort
|
|
);
|
|
}
|
|
assert_eq!(
|
|
resolve_game_creator_llm_config_for_agent(&config, "non-canonical-agent").reasoning_effort,
|
|
"default"
|
|
);
|
|
|
|
let status = check_game_creator_llm_config_from_config();
|
|
assert!(status.configured, "{:?}", status.error);
|
|
for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
|
|
let agent = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == agent_id)
|
|
.expect("canonical Agent status");
|
|
assert_eq!(agent.reasoning_effort, effort, "{agent_id}");
|
|
}
|
|
|
|
let mut overridden = config;
|
|
overridden.agent_llm.insert(
|
|
GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID.to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
reasoning_effort: Some("medium".to_string()),
|
|
..GameCreatorLlmConfigFile::default()
|
|
},
|
|
);
|
|
for (agent_id, _) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS {
|
|
overridden.agent_llm.insert(
|
|
agent_id.to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
reasoning_effort: Some("default".to_string()),
|
|
..GameCreatorLlmConfigFile::default()
|
|
},
|
|
);
|
|
assert_eq!(
|
|
resolve_game_creator_llm_config_for_agent(&overridden, agent_id).reasoning_effort,
|
|
"default",
|
|
"显式 agentLlm.{agent_id} patch 必须覆盖规范默认值"
|
|
);
|
|
}
|
|
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_config_dir_supplies_app_config_file() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
fs::write(
|
|
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
|
r#"{
|
|
"llm": {
|
|
"apiKey": "runtime-key",
|
|
"baseUrl": "https://runtime.example.test/v1",
|
|
"model": "runtime-model"
|
|
}
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write runtime config");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
|
|
let config = load_game_creator_app_config().expect("load runtime config");
|
|
|
|
assert_eq!(config.llm.api_key, "runtime-key");
|
|
assert_eq!(config.llm.base_url, "https://runtime.example.test/v1");
|
|
assert_eq!(config.llm.model, "runtime-model");
|
|
assert!(llm_api_key_config_error("llm").contains(&root.to_string_lossy().to_string()));
|
|
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_config_read_returns_defaults_when_file_is_missing() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
|
|
let result = read_game_creator_app_config().expect("read default runtime config");
|
|
|
|
assert_eq!(
|
|
result.path,
|
|
root.join(GAME_CREATOR_CONFIG_FILE_NAME)
|
|
.display()
|
|
.to_string()
|
|
);
|
|
assert_eq!(result.config.llm.api_key, "");
|
|
assert_eq!(
|
|
result.config.agent_mode,
|
|
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
|
|
);
|
|
assert_eq!(
|
|
result.config.llm.base_url,
|
|
DEFAULT_GAME_CREATOR_LLM_BASE_URL
|
|
);
|
|
assert_eq!(result.config.llm.model, DEFAULT_GAME_CREATOR_LLM_MODEL);
|
|
assert_eq!(
|
|
result.config.llm.api_kind,
|
|
DEFAULT_GAME_CREATOR_LLM_API_KIND
|
|
);
|
|
assert_eq!(
|
|
result.config.llm.reasoning_effort,
|
|
DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT
|
|
);
|
|
assert!(result.config.llm.stream);
|
|
assert!(result.config.llm.web_search_enabled);
|
|
assert_eq!(
|
|
result.config.llm.context_window_tokens,
|
|
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
|
|
);
|
|
assert_eq!(
|
|
result.config.llm.auto_compact_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
|
|
);
|
|
assert_eq!(
|
|
result.config.llm.tool_output_token_limit,
|
|
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
|
);
|
|
assert_eq!(
|
|
result.config.llm.request_timeout_ms,
|
|
GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS
|
|
);
|
|
assert_eq!(
|
|
result.config.llm.max_retries,
|
|
DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES
|
|
);
|
|
assert_eq!(
|
|
result.config.editor_api.base_url,
|
|
DEFAULT_CANVAS_SYNC_API_BASE_URL
|
|
);
|
|
assert_eq!(result.config.editor_api.api_key, "");
|
|
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
|
|
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn app_config_commands_write_runtime_config_file() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
let mut agent_llm = BTreeMap::new();
|
|
agent_llm.insert(
|
|
" planner ".to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
api_key: Some(" planner-key ".to_string()),
|
|
base_url: Some(" https://planner.example.test/v1 ".to_string()),
|
|
model: Some(" planner-model ".to_string()),
|
|
api_kind: Some("anthropic".to_string()),
|
|
reasoning_effort: Some(" low ".to_string()),
|
|
stream: Some(true),
|
|
web_search_enabled: Some(false),
|
|
context_window_tokens: Some(96_000),
|
|
auto_compact_token_limit: Some(48_000),
|
|
tool_output_token_limit: Some(8_000),
|
|
request_timeout_ms: Some(15_000),
|
|
max_retries: Some(1),
|
|
retry_backoff_ms: Some(300),
|
|
},
|
|
);
|
|
agent_llm.insert("generator".to_string(), GameCreatorLlmConfigFile::default());
|
|
|
|
let saved = write_game_creator_app_config(GameCreatorAppConfig {
|
|
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
|
|
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
|
llm: GameCreatorLlmConfig {
|
|
api_key: " unit-test-key ".to_string(),
|
|
base_url: " https://runtime.example.test/v1 ".to_string(),
|
|
model: " runtime-model ".to_string(),
|
|
api_kind: "openai_chat".to_string(),
|
|
reasoning_effort: " high ".to_string(),
|
|
stream: true,
|
|
web_search_enabled: true,
|
|
context_window_tokens: 128_000,
|
|
auto_compact_token_limit: 64_000,
|
|
tool_output_token_limit: 12_000,
|
|
request_timeout_ms: 42_000,
|
|
max_retries: 2,
|
|
retry_backoff_ms: 700,
|
|
},
|
|
editor_api: GameCreatorEditorApiConfig {
|
|
base_url: " http://127.0.0.1:8099 ".to_string(),
|
|
api_key: " editor-key ".to_string(),
|
|
},
|
|
agent_llm,
|
|
planning: GameCreatorPlanningConfig::default(),
|
|
selected_model_id: "default".to_string(),
|
|
})
|
|
.expect("write runtime config");
|
|
|
|
assert_eq!(
|
|
saved.path,
|
|
root.join(GAME_CREATOR_CONFIG_FILE_NAME)
|
|
.display()
|
|
.to_string()
|
|
);
|
|
assert_eq!(saved.config.llm.api_key, "unit-test-key");
|
|
assert_eq!(saved.config.llm.base_url, "https://runtime.example.test/v1");
|
|
assert_eq!(saved.config.llm.api_kind, "openai_chat");
|
|
assert_eq!(saved.config.llm.reasoning_effort, "high");
|
|
assert!(saved.config.llm.web_search_enabled);
|
|
assert_eq!(
|
|
saved
|
|
.config
|
|
.agent_llm
|
|
.get("planner")
|
|
.and_then(|llm| llm.api_key.as_deref()),
|
|
Some("planner-key")
|
|
);
|
|
assert_eq!(
|
|
saved
|
|
.config
|
|
.agent_llm
|
|
.get("planner")
|
|
.and_then(|llm| llm.web_search_enabled),
|
|
Some(false)
|
|
);
|
|
assert!(!saved.config.agent_llm.contains_key("generator"));
|
|
assert_eq!(saved.config.editor_api.api_key, "");
|
|
assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file());
|
|
let persisted = fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME))
|
|
.expect("read persisted runtime config");
|
|
assert!(!persisted.contains("editorApi"));
|
|
assert!(!persisted.contains("editor-key"));
|
|
assert!(!root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME).exists());
|
|
|
|
let read_back = read_game_creator_app_config().expect("read runtime config");
|
|
assert_eq!(read_back.config.llm.model, "runtime-model");
|
|
assert_eq!(read_back.config.llm.request_timeout_ms, 42_000);
|
|
assert_eq!(
|
|
read_back
|
|
.config
|
|
.agent_llm
|
|
.get("planner")
|
|
.and_then(|llm| llm.api_kind.as_deref()),
|
|
Some("anthropic")
|
|
);
|
|
assert_eq!(
|
|
read_back
|
|
.config
|
|
.agent_llm
|
|
.get("planner")
|
|
.and_then(|llm| llm.reasoning_effort.as_deref()),
|
|
Some("low")
|
|
);
|
|
assert_eq!(
|
|
read_back
|
|
.config
|
|
.agent_llm
|
|
.get("planner")
|
|
.and_then(|llm| llm.web_search_enabled),
|
|
Some(false)
|
|
);
|
|
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn app_config_batch_restores_main_when_overlay_write_fails() {
|
|
for main_exists in [false, true] {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("config dir");
|
|
let main = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
|
let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
|
|
let original = "{\"llm\":{\"stream\":false}}\n";
|
|
if main_exists {
|
|
fs::write(&main, original).expect("main config");
|
|
}
|
|
fs::write(&overlay, original).expect("overlay config");
|
|
// 普通目录占据备份路径,让覆盖文件在替换前失败。
|
|
fs::create_dir(root.join(format!(".{}.previous", GAME_CREATOR_LOCAL_CONFIG_FILE_NAME)))
|
|
.expect("block overlay replacement");
|
|
crate::config::write_game_creator_config_batch(&[
|
|
(main.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()),
|
|
(overlay.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()),
|
|
])
|
|
.expect_err("overlay replacement must fail");
|
|
if main_exists {
|
|
assert_eq!(fs::read_to_string(&main).expect("restored main"), original);
|
|
} else {
|
|
assert!(!main.exists());
|
|
}
|
|
assert_eq!(fs::read_to_string(&overlay).expect("unchanged overlay"), original);
|
|
fs::remove_dir_all(root).expect("cleanup config dir");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn app_config_save_updates_conflicting_local_overlay() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
|
|
fs::write(
|
|
&overlay_path,
|
|
r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","custom":{"keep":true}}"#,
|
|
)
|
|
.expect("write overlay");
|
|
let mut config = load_game_creator_app_config().expect("load config");
|
|
config.llm.reasoning_effort = "high".to_string();
|
|
let saved = write_game_creator_app_config(config).expect("save config");
|
|
let effective = load_game_creator_app_config().expect("effective config");
|
|
assert_eq!(saved.config.llm.reasoning_effort, "high");
|
|
assert_eq!(effective.llm.reasoning_effort, "high");
|
|
let overlay: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay"))
|
|
.expect("parse overlay");
|
|
assert_eq!(overlay["selectedModelId"], "existing");
|
|
assert_eq!(
|
|
overlay["llm"],
|
|
serde_json::json!({"reasoningEffort": "high"})
|
|
);
|
|
assert_eq!(overlay["custom"]["keep"], true);
|
|
fs::remove_dir_all(root).expect("cleanup config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn app_config_model_selection_only_updates_model_overlay() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
|
|
fs::write(
|
|
&overlay_path,
|
|
r#"{"selectedModelId":"old","llm":{"reasoningEffort":"low"}}"#,
|
|
)
|
|
.expect("write overlay");
|
|
let saved = select_game_creator_model("new-model".to_string()).expect("select model");
|
|
let effective = load_game_creator_app_config().expect("effective config");
|
|
assert_eq!(saved.config.selected_model_id, "new-model");
|
|
assert_eq!(effective.selected_model_id, "new-model");
|
|
let overlay: serde_json::Value =
|
|
serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay"))
|
|
.expect("parse overlay");
|
|
assert_eq!(
|
|
overlay["llm"],
|
|
serde_json::json!({"reasoningEffort": "low"})
|
|
);
|
|
fs::remove_dir_all(root).expect("cleanup config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn app_config_write_rejects_invalid_api_kind() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
|
|
let result = write_game_creator_app_config(GameCreatorAppConfig {
|
|
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
|
|
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
|
llm: GameCreatorLlmConfig {
|
|
api_key: String::new(),
|
|
api_kind: "legacy".to_string(),
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
editor_api: GameCreatorEditorApiConfig::default(),
|
|
agent_llm: BTreeMap::new(),
|
|
planning: GameCreatorPlanningConfig::default(),
|
|
selected_model_id: "default".to_string(),
|
|
});
|
|
|
|
assert!(result
|
|
.expect_err("invalid api_kind")
|
|
.contains("LLM api_kind 无效"));
|
|
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
|
|
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn app_config_write_rejects_invalid_reasoning_effort() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
|
|
let result = write_game_creator_app_config(GameCreatorAppConfig {
|
|
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
|
|
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
|
llm: GameCreatorLlmConfig {
|
|
reasoning_effort: "maximum".to_string(),
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
editor_api: GameCreatorEditorApiConfig::default(),
|
|
agent_llm: BTreeMap::new(),
|
|
planning: GameCreatorPlanningConfig::default(),
|
|
selected_model_id: "default".to_string(),
|
|
});
|
|
|
|
assert!(result
|
|
.expect_err("invalid reasoning effort")
|
|
.contains("reasoningEffort"));
|
|
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
|
|
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
|
}
|
|
|
|
#[test]
|
|
fn app_config_write_rejects_too_small_request_timeout() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
|
|
let result = write_game_creator_app_config(GameCreatorAppConfig {
|
|
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
|
|
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
|
llm: GameCreatorLlmConfig {
|
|
request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1,
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
editor_api: GameCreatorEditorApiConfig::default(),
|
|
agent_llm: BTreeMap::new(),
|
|
planning: GameCreatorPlanningConfig::default(),
|
|
selected_model_id: "default".to_string(),
|
|
});
|
|
|
|
assert!(result
|
|
.expect_err("too small timeout")
|
|
.contains("至少为 1000"));
|
|
assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists());
|
|
fs::remove_dir_all(root).expect("cleanup runtime config dir");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn chat_with_game_creator_role_agent_plain_entry_ignores_stream_config() {
|
|
let root = unique_project_path();
|
|
init_local_game_project_at(&root, "project-1", "普通回复回退项目").expect("project init");
|
|
|
|
let (sender, receiver) = mpsc::channel();
|
|
let base_url = spawn_mock_llm_server_responses_with_capture(
|
|
vec!["普通回复回退成功。".to_string()],
|
|
Some(sender),
|
|
);
|
|
let _config_guard = write_test_local_config(format!(
|
|
r#"{{
|
|
"agentLlm": {{
|
|
"art-director": {{
|
|
"apiKey": "art-key",
|
|
"baseUrl": {base_url:?},
|
|
"model": "art-chat-model",
|
|
"apiKind": "openai_chat",
|
|
"stream": true,
|
|
"maxRetries": 0
|
|
}}
|
|
}}
|
|
}}"#
|
|
));
|
|
|
|
let reply =
|
|
chat_with_game_creator_role_agent_at(&root, "art-director", "事件监听不可用,改用普通回复")
|
|
.await
|
|
.expect("plain role chat reply");
|
|
|
|
assert_eq!(reply.reply_text, "普通回复回退成功。");
|
|
let request = receiver
|
|
.recv_timeout(Duration::from_secs(1))
|
|
.expect("captured plain role chat request");
|
|
assert!(request.contains("POST /chat/completions HTTP/1.1"));
|
|
assert!(request.contains("\"stream\":false"));
|
|
assert!(!request.contains("\"stream\":true"));
|
|
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn llm_config_check_reports_status_without_leaking_key() {
|
|
let missing = check_game_creator_llm_config_values(
|
|
&GameCreatorLlmConfig {
|
|
api_key: String::new(),
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
"llm",
|
|
);
|
|
assert!(!missing.configured);
|
|
assert!(missing.error.unwrap().contains("LLM 未配置"));
|
|
|
|
let too_fast = check_game_creator_llm_config_values(
|
|
&GameCreatorLlmConfig {
|
|
api_key: "unit-test-api-key".to_string(),
|
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
|
model: "mock-game-model".to_string(),
|
|
request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1,
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
"llm",
|
|
);
|
|
assert!(!too_fast.configured);
|
|
assert!(too_fast
|
|
.error
|
|
.as_deref()
|
|
.expect("too fast error")
|
|
.contains("至少为 1000"));
|
|
assert!(!serde_json::to_string(&too_fast)
|
|
.unwrap()
|
|
.contains("unit-test-api-key"));
|
|
|
|
let configured = check_game_creator_llm_config_values(
|
|
&GameCreatorLlmConfig {
|
|
api_key: "unit-test-api-key".to_string(),
|
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
|
model: "mock-game-model".to_string(),
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
"llm",
|
|
);
|
|
assert!(configured.configured);
|
|
assert!(!configured.web_search_enabled);
|
|
let serialized = serde_json::to_string(&configured).unwrap();
|
|
assert!(!serialized.contains("unit-test-api-key"));
|
|
assert!(!serialized.contains("http://127.0.0.1:1/v1"));
|
|
assert!(!serialized.contains("mock-game-model"));
|
|
assert!(!serialized.contains("apiKind"));
|
|
}
|
|
|
|
#[test]
|
|
fn llm_config_check_reports_per_agent_status_without_leaking_keys() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
fs::write(
|
|
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
|
r#"{
|
|
"agentMode": "provider",
|
|
"llm": {
|
|
"apiKey": "",
|
|
"baseUrl": "https://global.example.test/v1",
|
|
"model": "global-model",
|
|
"webSearchEnabled": true
|
|
},
|
|
"agentLlm": {
|
|
"project-supervisor": {
|
|
"apiKey": "supervisor-secret-key",
|
|
"baseUrl": "https://supervisor.example.test/v1",
|
|
"model": "supervisor-model",
|
|
"apiKind": "openai_chat"
|
|
},
|
|
"planner": {
|
|
"apiKey": "planner-secret-key",
|
|
"baseUrl": "https://planner.example.test/v1",
|
|
"model": "planner-model",
|
|
"apiKind": "anthropic",
|
|
"webSearchEnabled": false
|
|
},
|
|
"generator": {
|
|
"apiKey": "generator-secret-key",
|
|
"baseUrl": "https://generator.example.test/v1",
|
|
"model": "generator-model",
|
|
"apiKind": "openai_chat"
|
|
},
|
|
"art-asset-plan": {
|
|
"apiKey": "art-secret-key",
|
|
"baseUrl": "https://art.example.test/v1",
|
|
"model": "art-model",
|
|
"apiKind": "openai_chat"
|
|
}
|
|
}
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write runtime config");
|
|
|
|
let status = check_game_creator_llm_config_from_config();
|
|
|
|
assert!(status.configured, "{:?}", status.error);
|
|
assert!(status.web_search_enabled);
|
|
assert!(status.agents.len() > 2);
|
|
let planner = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == "planner")
|
|
.expect("planner status");
|
|
assert!(planner.configured);
|
|
assert!(!planner.web_search_enabled);
|
|
let generator = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == "generator")
|
|
.expect("generator status");
|
|
assert!(generator.configured);
|
|
assert!(generator.web_search_enabled);
|
|
let art = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == "art-asset-plan")
|
|
.expect("art agent status");
|
|
assert!(art.configured);
|
|
assert_eq!(art.label, "美术组 / Asset");
|
|
assert_eq!(art.reasoning_effort, "high");
|
|
let orchestrator = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == "orchestrator")
|
|
.expect("orchestrator status");
|
|
assert_eq!(orchestrator.reasoning_effort, "medium");
|
|
let preview = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == "preview-readiness")
|
|
.expect("preview status");
|
|
assert_eq!(preview.reasoning_effort, "low");
|
|
let serialized = serde_json::to_string(&status).expect("status json");
|
|
assert!(!serialized.contains("planner-secret-key"));
|
|
assert!(!serialized.contains("generator-secret-key"));
|
|
assert!(!serialized.contains("art-secret-key"));
|
|
assert!(!serialized.contains("supervisor-secret-key"));
|
|
assert!(!serialized.contains("global.example.test"));
|
|
assert!(!serialized.contains("supervisor.example.test"));
|
|
assert!(!serialized.contains("planner-model"));
|
|
assert!(!serialized.contains("generator-model"));
|
|
assert!(!serialized.contains("art-model"));
|
|
assert!(!serialized.contains("apiKind"));
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn llm_config_check_reports_agent_specific_config_paths() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
fs::write(
|
|
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
|
r#"{
|
|
"agentMode": "provider",
|
|
"llm": {
|
|
"apiKey": "global-key",
|
|
"baseUrl": "https://global.example.test/v1",
|
|
"model": "global-model"
|
|
},
|
|
"agentLlm": {
|
|
"generator": {
|
|
"apiKey": "",
|
|
"baseUrl": "https://generator.example.test/v1",
|
|
"model": "generator-model"
|
|
}
|
|
}
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write runtime config");
|
|
|
|
let status = check_game_creator_llm_config_from_config();
|
|
|
|
assert!(!status.configured);
|
|
let generator = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == "generator")
|
|
.expect("generator status");
|
|
let error = generator.error.as_deref().expect("generator error");
|
|
assert!(error.contains("agentLlm.generator.apiKey"));
|
|
assert!(!serde_json::to_string(&status)
|
|
.expect("status json")
|
|
.contains("global-key"));
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn llm_config_diagnostic_command_runs_asynchronously() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("create runtime config dir");
|
|
let _guard = use_test_runtime_config_dir(root.clone());
|
|
fs::write(
|
|
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
|
r#"{
|
|
"agentMode": "provider",
|
|
"llm": {
|
|
"apiKey": "test-key",
|
|
"baseUrl": "https://example.test/v1",
|
|
"model": "test-model"
|
|
}
|
|
}
|
|
"#,
|
|
)
|
|
.expect("write runtime config");
|
|
|
|
let status = crate::commands::check_game_creator_llm_config()
|
|
.await
|
|
.expect("run config diagnostic command");
|
|
assert!(status.configured, "{status:?}");
|
|
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() {
|
|
let status = GameCreatorLlmConfigStatus {
|
|
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
|
configured: false,
|
|
account_credential_state: "not_required".to_string(),
|
|
official_route_locked: false,
|
|
reasoning_effort: "high".to_string(),
|
|
stream: false,
|
|
web_search_enabled: true,
|
|
context_window_tokens: 128_000,
|
|
auto_compact_token_limit: 64_000,
|
|
tool_output_token_limit: 12_000,
|
|
request_timeout_ms: 180_000,
|
|
max_retries: 2,
|
|
retry_backoff_ms: 500,
|
|
error: Some("Generator:缺少 API Key".to_string()),
|
|
agents: vec![GameCreatorAgentLlmConfigStatus {
|
|
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
|
agent_id: "generator".to_string(),
|
|
label: "Generator".to_string(),
|
|
configured: false,
|
|
account_credential_state: "not_required".to_string(),
|
|
official_route_locked: false,
|
|
reasoning_effort: "medium".to_string(),
|
|
stream: true,
|
|
web_search_enabled: false,
|
|
context_window_tokens: 96_000,
|
|
auto_compact_token_limit: 48_000,
|
|
tool_output_token_limit: 8_000,
|
|
request_timeout_ms: 90_000,
|
|
max_retries: 1,
|
|
retry_backoff_ms: 250,
|
|
error: Some("LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string()),
|
|
}],
|
|
};
|
|
|
|
let lines = game_creator_llm_status_lines(&status).join("\n");
|
|
|
|
assert!(lines.contains("llm.agent.generator.error=LLM 未配置"));
|
|
assert!(lines.contains("llm.agent.generator.stream=true"));
|
|
assert!(lines.contains("llm.webSearchEnabled=true"));
|
|
assert!(lines.contains("llm.agent.generator.webSearchEnabled=false"));
|
|
assert!(lines.contains("llm.reasoningEffort=high"));
|
|
assert!(lines.contains("llm.agent.generator.reasoningEffort=medium"));
|
|
assert!(lines.contains("llm.maxRetries=2"));
|
|
assert!(lines.contains("llm.agent.generator.maxRetries=1"));
|
|
assert!(lines.contains("llm.error=Generator:缺少 API Key"));
|
|
assert!(!lines.contains("llm.baseUrl="));
|
|
assert!(!lines.contains("llm.model="));
|
|
assert!(!lines.contains("llm.apiKind="));
|
|
assert!(!lines.contains("llm.agent.generator.baseUrl="));
|
|
assert!(!lines.contains("llm.agent.generator.model="));
|
|
assert!(!lines.contains("llm.agent.generator.apiKind="));
|
|
assert!(!lines.contains("sk-"));
|
|
assert!(!lines.contains("secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn llm_api_kind_parses_canonical_names() {
|
|
assert_eq!(
|
|
parse_game_creator_llm_api_kind("anthropic"),
|
|
Ok(LlmApiKind::Anthropic)
|
|
);
|
|
assert_eq!(
|
|
parse_game_creator_llm_api_kind("openai_chat"),
|
|
Ok(LlmApiKind::OpenAiChat)
|
|
);
|
|
assert_eq!(
|
|
parse_game_creator_llm_api_kind("openai_responses"),
|
|
Ok(LlmApiKind::OpenAiResponses)
|
|
);
|
|
assert_eq!(
|
|
parse_game_creator_llm_api_kind(""),
|
|
Ok(LlmApiKind::OpenAiResponses)
|
|
);
|
|
assert!(parse_game_creator_llm_api_kind("legacy").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn llm_reasoning_effort_supports_provider_default_and_explicit_levels() {
|
|
assert_eq!(parse_game_creator_llm_reasoning_effort("default"), Ok(None));
|
|
assert_eq!(
|
|
parse_game_creator_llm_reasoning_effort("low"),
|
|
Ok(Some(platform_llm::LlmResponseReasoningEffort::Low))
|
|
);
|
|
assert_eq!(
|
|
parse_game_creator_llm_reasoning_effort("medium"),
|
|
Ok(Some(platform_llm::LlmResponseReasoningEffort::Medium))
|
|
);
|
|
assert_eq!(
|
|
parse_game_creator_llm_reasoning_effort("high"),
|
|
Ok(Some(platform_llm::LlmResponseReasoningEffort::High))
|
|
);
|
|
assert_eq!(
|
|
parse_game_creator_llm_reasoning_effort("max"),
|
|
Ok(Some(platform_llm::LlmResponseReasoningEffort::Max))
|
|
);
|
|
let error = parse_game_creator_llm_reasoning_effort("maximum")
|
|
.expect_err("maximum is not the max wire value");
|
|
assert!(error.contains("max"));
|
|
|
|
let mut llm = GameCreatorLlmConfig::default();
|
|
llm.reasoning_effort = "default".to_string();
|
|
let request =
|
|
apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm)
|
|
.expect("provider default request");
|
|
assert_eq!(request.response_reasoning_effort, None);
|
|
|
|
llm.reasoning_effort = "max".to_string();
|
|
let request =
|
|
apply_game_creator_llm_reasoning_effort(LlmRunRequest::single_turn("system", "user"), &llm)
|
|
.expect("max reasoning request");
|
|
assert_eq!(
|
|
request.response_reasoning_effort,
|
|
Some(platform_llm::LlmResponseReasoningEffort::Max)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn llm_web_search_applies_only_when_allowed_and_rejects_anthropic() {
|
|
let mut llm = GameCreatorLlmConfig {
|
|
web_search_enabled: true,
|
|
..GameCreatorLlmConfig::default()
|
|
};
|
|
let request =
|
|
apply_game_creator_llm_web_search(LlmRunRequest::single_turn("system", "user"), &llm, true)
|
|
.expect("apply enabled web search");
|
|
assert!(request.enable_web_search);
|
|
|
|
let request = apply_game_creator_llm_web_search(
|
|
LlmRunRequest::single_turn("system", "user"),
|
|
&llm,
|
|
false,
|
|
)
|
|
.expect("disallow web search for this request");
|
|
assert!(!request.enable_web_search);
|
|
|
|
llm.api_kind = "anthropic".to_string();
|
|
let error =
|
|
apply_game_creator_llm_web_search(LlmRunRequest::single_turn("system", "user"), &llm, true)
|
|
.expect_err("Anthropic web search must fail closed");
|
|
assert!(error.contains("apiKind=anthropic"), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn anthropic_web_search_is_rejected_with_precise_global_and_agent_paths() {
|
|
let anthropic_web_search = GameCreatorLlmConfig {
|
|
api_key: "test-key".to_string(),
|
|
base_url: "https://anthropic.example.test/v1".to_string(),
|
|
model: "anthropic-model".to_string(),
|
|
api_kind: "anthropic".to_string(),
|
|
web_search_enabled: true,
|
|
..GameCreatorLlmConfig::default()
|
|
};
|
|
let global_status = check_game_creator_llm_config_values(&anthropic_web_search, "llm");
|
|
assert!(!global_status.configured);
|
|
assert!(global_status
|
|
.error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("llm.webSearchEnabled")));
|
|
let client_error = build_game_creator_llm_client_from_llm_config(
|
|
&anthropic_web_search,
|
|
"agentLlm.design-director",
|
|
)
|
|
.expect_err("client build must reject Anthropic web search");
|
|
assert!(
|
|
client_error.contains("agentLlm.design-director.webSearchEnabled"),
|
|
"{client_error}"
|
|
);
|
|
|
|
let global_error = normalize_game_creator_app_config(GameCreatorAppConfig {
|
|
llm: anthropic_web_search.clone(),
|
|
..GameCreatorAppConfig::default()
|
|
})
|
|
.expect_err("saving global Anthropic web search must fail");
|
|
assert!(
|
|
global_error.contains("llm.webSearchEnabled"),
|
|
"{global_error}"
|
|
);
|
|
|
|
let mut inherited_agent_llm = BTreeMap::new();
|
|
inherited_agent_llm.insert(
|
|
"planner".to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
api_kind: Some("anthropic".to_string()),
|
|
..GameCreatorLlmConfigFile::default()
|
|
},
|
|
);
|
|
let agent_error = normalize_game_creator_app_config(GameCreatorAppConfig {
|
|
llm: GameCreatorLlmConfig {
|
|
web_search_enabled: true,
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
agent_llm: inherited_agent_llm,
|
|
..GameCreatorAppConfig::default()
|
|
})
|
|
.expect_err("saving inherited Agent web search must fail");
|
|
assert!(
|
|
agent_error.contains("agentLlm.planner.webSearchEnabled"),
|
|
"{agent_error}"
|
|
);
|
|
|
|
let mut overridden_agent_llm = BTreeMap::new();
|
|
overridden_agent_llm.insert(
|
|
"planner".to_string(),
|
|
GameCreatorLlmConfigFile {
|
|
api_kind: Some("anthropic".to_string()),
|
|
web_search_enabled: Some(false),
|
|
..GameCreatorLlmConfigFile::default()
|
|
},
|
|
);
|
|
let normalized = normalize_game_creator_app_config(GameCreatorAppConfig {
|
|
llm: GameCreatorLlmConfig {
|
|
web_search_enabled: true,
|
|
..GameCreatorLlmConfig::default()
|
|
},
|
|
agent_llm: overridden_agent_llm,
|
|
..GameCreatorAppConfig::default()
|
|
})
|
|
.expect("explicit Agent false must override inherited true");
|
|
assert!(!resolve_game_creator_llm_config_for_agent(&normalized, "planner").web_search_enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn llm_config_status_preserves_global_web_search_error_when_required_agents_override() {
|
|
let _config_guard = write_test_local_config(
|
|
r#"{
|
|
"agentMode": "provider",
|
|
"llm": {
|
|
"apiKey": "test-key",
|
|
"baseUrl": "https://anthropic.example.test/v1",
|
|
"model": "anthropic-model",
|
|
"apiKind": "anthropic",
|
|
"webSearchEnabled": true
|
|
},
|
|
"agentLlm": {
|
|
"project-supervisor": { "webSearchEnabled": false },
|
|
"planner": { "webSearchEnabled": false },
|
|
"generator": { "webSearchEnabled": false }
|
|
}
|
|
}"#
|
|
.to_string(),
|
|
);
|
|
|
|
let status = check_game_creator_llm_config_from_config();
|
|
|
|
assert!(!status.configured);
|
|
assert!(status
|
|
.error
|
|
.as_deref()
|
|
.is_some_and(|error| error.contains("llm.webSearchEnabled")));
|
|
for agent_id in GAME_CREATOR_REQUIRED_LLM_AGENT_IDS {
|
|
let agent = status
|
|
.agents
|
|
.iter()
|
|
.find(|agent| agent.agent_id == agent_id)
|
|
.expect("required Agent status");
|
|
assert!(agent.configured, "{agent_id} should override search off");
|
|
assert!(!agent.web_search_enabled);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cli_runtime_config_dir_is_explicit_absolute_and_removed_before_command_parse() {
|
|
let temp_root = std::env::temp_dir();
|
|
let config_dir = temp_root.join("genarrative-appdata");
|
|
let project_dir = temp_root.join("genarrative-cli-game");
|
|
let mut args = vec![
|
|
"--agent-task".to_string(),
|
|
"--config-dir".to_string(),
|
|
config_dir.to_string_lossy().into_owned(),
|
|
project_dir.to_string_lossy().into_owned(),
|
|
"code-prototype".to_string(),
|
|
"修复失败测试".to_string(),
|
|
];
|
|
assert_eq!(
|
|
take_cli_runtime_config_dir(&mut args).expect("take config dir"),
|
|
Some(config_dir)
|
|
);
|
|
assert!(!args.iter().any(|arg| arg == "--config-dir"));
|
|
assert!(matches!(
|
|
parse_cli_command(&args).expect("parse command"),
|
|
Some(CliCommand::AgentTask { .. })
|
|
));
|
|
|
|
let mut relative = vec!["--config-dir".to_string(), "config".to_string()];
|
|
assert!(take_cli_runtime_config_dir(&mut relative)
|
|
.expect_err("relative config dir must fail")
|
|
.contains("绝对路径"));
|
|
let mut duplicate = vec![
|
|
"--config-dir".to_string(),
|
|
temp_root.join("a").to_string_lossy().into_owned(),
|
|
"--config-dir".to_string(),
|
|
temp_root.join("b").to_string_lossy().into_owned(),
|
|
];
|
|
assert!(take_cli_runtime_config_dir(&mut duplicate)
|
|
.expect_err("duplicate config dir must fail")
|
|
.contains("只能指定一次"));
|
|
}
|
|
|
|
#[test]
|
|
fn cli_runtime_writes_require_appdata_and_reject_project_local_config_dir() {
|
|
let root = unique_project_path();
|
|
init_local_game_project_at(&root, "project-1", "CLI AppData 边界测试").expect("project init");
|
|
let canonical_root = fs::canonicalize(&root).expect("canonical project");
|
|
let commands = [
|
|
CliCommand::AgentTask {
|
|
project_path: canonical_root.clone(),
|
|
agent_id: "code-prototype".to_string(),
|
|
task: "task".to_string(),
|
|
initialize: false,
|
|
},
|
|
CliCommand::AgentEnqueue {
|
|
project_path: canonical_root.clone(),
|
|
agent_id: "code-prototype".to_string(),
|
|
run_id: "run-1".to_string(),
|
|
task: "task".to_string(),
|
|
initialize: false,
|
|
},
|
|
CliCommand::AgentConfirm {
|
|
project_path: canonical_root.clone(),
|
|
agent_id: "code-prototype".to_string(),
|
|
run_id: "run-1".to_string(),
|
|
action_id: "action-1".to_string(),
|
|
},
|
|
CliCommand::AgentResume {
|
|
project_path: canonical_root.clone(),
|
|
},
|
|
];
|
|
for mut command in commands {
|
|
assert!(command.requires_external_agent_runner());
|
|
assert!(prepare_cli_command_paths(&mut command, None)
|
|
.expect_err("runtime write without AppData must fail")
|
|
.contains("--config-dir"));
|
|
}
|
|
|
|
let project_config = root.join("appdata");
|
|
fs::create_dir(&project_config).expect("create project-local appdata lure");
|
|
let mut command = CliCommand::AgentRuntimeStatus {
|
|
project_path: root.clone(),
|
|
agent_id: "code-prototype".to_string(),
|
|
};
|
|
assert!(
|
|
prepare_cli_command_paths(&mut command, Some(&project_config))
|
|
.expect_err("project-local config dir must fail")
|
|
.contains("项目目录外")
|
|
);
|
|
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn appdata_config_dir_is_owned_privately() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let config_dir = unique_project_path();
|
|
fs::create_dir(&config_dir).expect("create appdata directory");
|
|
fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o755))
|
|
.expect("make appdata directory broad");
|
|
|
|
let canonical =
|
|
prepare_game_creator_runtime_config_dir(&config_dir).expect("tighten appdata directory");
|
|
assert_eq!(
|
|
fs::metadata(&canonical)
|
|
.expect("appdata metadata")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o700
|
|
);
|
|
|
|
fs::remove_dir_all(config_dir).ok();
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn newly_created_windows_appdata_is_owned_by_token_user() {
|
|
let root = unique_project_path();
|
|
let config_dir = root.join("appdata");
|
|
|
|
let prepared = prepare_game_creator_runtime_config_dir(&config_dir)
|
|
.expect("create and secure Windows AppData directory");
|
|
|
|
// TokenOwner 可能是 Administrators;安全边界必须以 TokenUser SID 为准。
|
|
secure_windows_game_creator_path_for_current_user(&prepared, true, false)
|
|
.expect("prepared directory owner must match TokenUser SID");
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_foreign_owner_prepare_preserves_backup_and_recreates_private_appdata() {
|
|
let root = unique_project_path();
|
|
fs::create_dir_all(&root).expect("create backup test root");
|
|
let config_dir = root.join("appdata");
|
|
fs::create_dir(&config_dir).expect("create old config directory");
|
|
fs::write(config_dir.join("important.json"), b"preserve-me").expect("write old configuration");
|
|
if !set_windows_test_path_owner_to_distinct_token_owner(&config_dir) {
|
|
eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID");
|
|
fs::remove_dir_all(root).ok();
|
|
return;
|
|
}
|
|
assert!(secure_windows_game_creator_path_for_current_user(&config_dir, true, false).is_err());
|
|
|
|
let prepared = prepare_game_creator_runtime_config_dir(&config_dir)
|
|
.expect("prepare must isolate foreign-owner AppData and recreate it");
|
|
|
|
assert_eq!(
|
|
prepared,
|
|
fs::canonicalize(&config_dir).expect("canonical AppData")
|
|
);
|
|
secure_windows_game_creator_path_for_current_user(&prepared, true, false)
|
|
.expect("new AppData must be owned privately by TokenUser SID");
|
|
let backups = fs::read_dir(&root)
|
|
.expect("read backup parent")
|
|
.filter_map(Result::ok)
|
|
.map(|entry| entry.path())
|
|
.filter(|path| {
|
|
path.file_name().is_some_and(|name| {
|
|
name.to_string_lossy()
|
|
.starts_with("appdata.owner-mismatch-backup-")
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
backups.len(),
|
|
1,
|
|
"must create exactly one owner-mismatch backup"
|
|
);
|
|
assert_eq!(
|
|
fs::read(backups[0].join("important.json")).expect("read preserved configuration"),
|
|
b"preserve-me"
|
|
);
|
|
assert!(!config_dir.join("important.json").exists());
|
|
fs::remove_dir_all(root).ok();
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
pub(crate) fn set_windows_test_path_owner_to_distinct_token_owner(path: &Path) -> bool {
|
|
use std::ffi::c_void;
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
type Handle = *mut c_void;
|
|
type Sid = *mut c_void;
|
|
|
|
#[repr(C)]
|
|
struct SidAndAttributes {
|
|
sid: Sid,
|
|
attributes: u32,
|
|
}
|
|
|
|
#[repr(C)]
|
|
struct TokenUser {
|
|
user: SidAndAttributes,
|
|
}
|
|
|
|
#[repr(C)]
|
|
struct TokenOwner {
|
|
owner: Sid,
|
|
}
|
|
|
|
#[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 EqualSid(first: Sid, second: Sid) -> i32;
|
|
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;
|
|
}
|
|
|
|
#[link(name = "kernel32")]
|
|
unsafe extern "system" {
|
|
fn GetCurrentProcess() -> Handle;
|
|
fn CloseHandle(handle: Handle) -> i32;
|
|
}
|
|
|
|
const TOKEN_QUERY: u32 = 0x0000_0008;
|
|
const TOKEN_USER_CLASS: u32 = 1;
|
|
const TOKEN_OWNER_CLASS: u32 = 4;
|
|
const SE_FILE_OBJECT: u32 = 1;
|
|
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
|
|
|
unsafe fn token_information(token: Handle, class: u32) -> Option<Vec<usize>> {
|
|
let mut required = 0_u32;
|
|
unsafe { GetTokenInformation(token, class, std::ptr::null_mut(), 0, &mut required) };
|
|
if required == 0 {
|
|
return None;
|
|
}
|
|
let word_size = std::mem::size_of::<usize>();
|
|
let mut buffer = vec![0_usize; (required as usize).div_ceil(word_size)];
|
|
if unsafe {
|
|
GetTokenInformation(
|
|
token,
|
|
class,
|
|
buffer.as_mut_ptr().cast(),
|
|
required,
|
|
&mut required,
|
|
)
|
|
} == 0
|
|
{
|
|
return None;
|
|
}
|
|
Some(buffer)
|
|
}
|
|
|
|
let mut token = std::ptr::null_mut();
|
|
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0
|
|
|| token.is_null()
|
|
{
|
|
return false;
|
|
}
|
|
let changed = (|| {
|
|
let user_buffer = unsafe { token_information(token, TOKEN_USER_CLASS) }?;
|
|
let owner_buffer = unsafe { token_information(token, TOKEN_OWNER_CLASS) }?;
|
|
let token_user = unsafe { (*(user_buffer.as_ptr().cast::<TokenUser>())).user.sid };
|
|
let token_owner = unsafe { (*(owner_buffer.as_ptr().cast::<TokenOwner>())).owner };
|
|
if token_user.is_null()
|
|
|| token_owner.is_null()
|
|
|| unsafe { EqualSid(token_user, token_owner) } != 0
|
|
{
|
|
return None;
|
|
}
|
|
let mut wide_path = path
|
|
.as_os_str()
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect::<Vec<_>>();
|
|
let status = unsafe {
|
|
SetNamedSecurityInfoW(
|
|
wide_path.as_mut_ptr(),
|
|
SE_FILE_OBJECT,
|
|
OWNER_SECURITY_INFORMATION,
|
|
token_owner,
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
std::ptr::null_mut(),
|
|
)
|
|
};
|
|
(status == 0).then_some(())
|
|
})()
|
|
.is_some();
|
|
unsafe { CloseHandle(token) };
|
|
changed
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_private_dacl_does_not_reassert_an_owner_that_already_matches() {
|
|
const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001;
|
|
const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004;
|
|
const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000;
|
|
|
|
assert_eq!(
|
|
windows_private_dacl_security_information(true, true),
|
|
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION
|
|
);
|
|
assert_eq!(
|
|
windows_private_dacl_security_information(true, false),
|
|
OWNER_SECURITY_INFORMATION
|
|
| DACL_SECURITY_INFORMATION
|
|
| PROTECTED_DACL_SECURITY_INFORMATION
|
|
);
|
|
}
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_appdata_validation_does_not_follow_directory_links() {
|
|
let root = unique_project_path();
|
|
let real = root.join("real-appdata");
|
|
let link = root.join("linked-appdata");
|
|
fs::create_dir_all(&real).expect("create real directory");
|
|
if std::os::windows::fs::symlink_dir(&real, &link).is_err() {
|
|
fs::remove_dir_all(root).ok();
|
|
return;
|
|
}
|
|
|
|
let error = inspect_game_creator_runtime_config_dir(&link)
|
|
.expect_err("AppData directory link must be rejected before canonicalize");
|
|
assert!(error.contains("链接") || error.contains("reparse point"));
|
|
fs::remove_dir_all(root).ok();
|
|
}
|