发布独立游戏聊天0.1.1并强化试玩验收
新增只能打开游戏聊天页的0.1.1独立release构建配置。 内置Runner启动、诊断日志、Windows后台进程与退出收束。 修复自动预览启动、同页版本刷新和结构化验收状态展示。 强化试玩协议、窗口末端采样与固定失败识别,允许正常输赢但拒绝无法推进。 完善旧验收回执自愈和当前场景指纹完成门。 补齐Rust、前端、真实Chrome回归测试及项目文档。
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"dev-server": "node scripts/start-dev-server.mjs",
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
"build": "npm --prefix ../.. exec tauri -- build",
|
||||
"build:game-chat-release": "npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
||||
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const npmCli =
|
||||
process.env.npm_execpath ??
|
||||
resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js');
|
||||
|
||||
function run(args, extraEnv = {}) {
|
||||
const result = spawnSync(process.execPath, [npmCli, ...args], {
|
||||
cwd: appRoot,
|
||||
env: { ...process.env, ...extraEnv },
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
run(['--prefix', repoRoot, 'run', 'ai-game-creator-shell:typecheck']);
|
||||
run(
|
||||
[
|
||||
'--prefix',
|
||||
repoRoot,
|
||||
'exec',
|
||||
'vite',
|
||||
'--',
|
||||
'build',
|
||||
'--config',
|
||||
'vite.config.ts',
|
||||
],
|
||||
{ VITE_AGC_GAME_CHAT_ONLY: 'true' },
|
||||
);
|
||||
@@ -27,6 +27,20 @@ const tauriConfig = JSON.parse(
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const gameChatReleaseTauriConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const cargoManifestSource = fs.readFileSync(
|
||||
new URL('../src-tauri/Cargo.toml', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const cargoPackageVersion = cargoManifestSource
|
||||
.split(/\r?\n(?=\[)/u)
|
||||
.find((section) => section.startsWith('[package]'))
|
||||
?.match(/^version\s*=\s*"([^"]+)"\s*$/mu)?.[1];
|
||||
const eventCapabilityPath = new URL(
|
||||
'../src-tauri/capabilities/events.json',
|
||||
import.meta.url,
|
||||
@@ -57,6 +71,14 @@ const appEntrypointSource = fs.readFileSync(
|
||||
new URL('../src/main.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const appModuleSource = fs.readFileSync(
|
||||
new URL('../src/App.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const gameChatReleaseBuildSource = fs.readFileSync(
|
||||
new URL('../scripts/build-game-chat-release.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const tauriHandlerSource = fs.readFileSync(
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
@@ -916,6 +938,29 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
const gameChatReleaseAppIndex = appModuleSource.indexOf(
|
||||
'export function GameChatReleaseApp(',
|
||||
);
|
||||
const gameChatReleaseBranchIndex = appEntrypointSource.indexOf(
|
||||
'{gameChatReleaseMode ? (',
|
||||
);
|
||||
const authenticatedClientIndex = appEntrypointSource.indexOf(
|
||||
'<AuthenticatedClient>',
|
||||
gameChatReleaseBranchIndex,
|
||||
);
|
||||
if (
|
||||
gameChatReleaseAppIndex === -1 ||
|
||||
gameChatReleaseBranchIndex === -1 ||
|
||||
!appEntrypointSource
|
||||
.slice(gameChatReleaseBranchIndex, authenticatedClientIndex)
|
||||
.includes('gameChatApp') ||
|
||||
authenticatedClientIndex < gameChatReleaseBranchIndex
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator game-chat release must render the local chat App before the platform authentication boundary',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
packageConfig.scripts?.['agent-run'] !==
|
||||
'node scripts/run-cli-with-config.mjs --agent-run'
|
||||
@@ -1271,8 +1316,6 @@ for (const requiredSnippet of [
|
||||
'fn apply_game_chat_initial_window_url(',
|
||||
'.find(|window| window.label == "client")',
|
||||
'client.url = game_chat_window_url(',
|
||||
'.build(tauri_context)',
|
||||
'app.run(|_, event| handle_game_creator_gui_run_event(&event))',
|
||||
]) {
|
||||
if (
|
||||
!`${tauriHandlerSource}\n${tauriWindowSource}`.includes(requiredSnippet)
|
||||
@@ -1283,6 +1326,15 @@ for (const requiredSnippet of [
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!tauriHandlerSource.includes('.build(tauri_context)') ||
|
||||
!tauriHandlerSource.includes('handle_game_creator_gui_run_event(&event)')
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator Tauri runtime must build from the prepared Context and preserve the generic GUI exit hook',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
|
||||
) {
|
||||
@@ -1291,6 +1343,66 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!appEntrypointSource.includes(
|
||||
"import.meta.env.VITE_AGC_GAME_CHAT_ONLY === 'true'",
|
||||
) ||
|
||||
!appEntrypointSource.includes(
|
||||
"import.meta.env.DEV && initialSearchParams.has('game-chat')",
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator game-chat release must be compile-time fixed while preserving the dev query entry',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
packageConfig.scripts?.['build:game-chat-release'] !==
|
||||
'npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release' ||
|
||||
rootPackageConfig.scripts?.['agc:build:game-chat-release'] !==
|
||||
'npm --prefix apps/ai-game-creator-shell run build:game-chat-release --'
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator game-chat release build commands must stay wired through the dedicated Tauri config',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
gameChatReleaseTauriConfig.productName !== 'Genarrative Game Chat' ||
|
||||
gameChatReleaseTauriConfig.version !== '0.1.1' ||
|
||||
gameChatReleaseTauriConfig.identifier === tauriConfig.identifier ||
|
||||
gameChatReleaseTauriConfig.build?.beforeBuildCommand !==
|
||||
'node scripts/build-game-chat-release.mjs' ||
|
||||
!gameChatReleaseTauriConfig.bundle?.targets?.includes('nsis')
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator game-chat release must keep version 0.1.1, its independent identity, frontend build, and NSIS target',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
tauriConfig.version !== '0.1.0' ||
|
||||
packageConfig.version !== '0.1.0' ||
|
||||
cargoPackageVersion !== '0.1.0'
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator standard release must remain version 0.1.0 while game-chat uses its dedicated version',
|
||||
);
|
||||
}
|
||||
|
||||
for (const requiredSnippet of [
|
||||
"'ai-game-creator-shell:typecheck'",
|
||||
"VITE_AGC_GAME_CHAT_ONLY: 'true'",
|
||||
"'--config'",
|
||||
"'vite.config.ts'",
|
||||
]) {
|
||||
if (!gameChatReleaseBuildSource.includes(requiredSnippet)) {
|
||||
throw new Error(
|
||||
`AI game creator game-chat release build guardrail drifted: ${requiredSnippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const devServerSource = fs.readFileSync(
|
||||
new URL('../scripts/start-dev-server.mjs', import.meta.url),
|
||||
'utf8',
|
||||
@@ -1380,7 +1492,6 @@ for (const snippet of [
|
||||
'fn merge_game_creator_config_file(',
|
||||
'.join("apps")',
|
||||
'.join("ai-game-creator-shell")',
|
||||
'configure_game_creator_runtime_config_dir(app.handle())?',
|
||||
'read_game_creator_app_config,',
|
||||
'write_game_creator_app_config,',
|
||||
'resolve_game_creator_llm_config_for_agent(app_config, "planner")',
|
||||
@@ -1400,6 +1511,31 @@ for (const snippet of [
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfigSetupStart = tauriHandlerSource.indexOf(
|
||||
'configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {',
|
||||
);
|
||||
const runtimeConfigSetupEnd = tauriHandlerSource.indexOf(
|
||||
'})?;',
|
||||
runtimeConfigSetupStart,
|
||||
);
|
||||
const runtimeConfigSetupSource = tauriHandlerSource.slice(
|
||||
runtimeConfigSetupStart,
|
||||
runtimeConfigSetupEnd,
|
||||
);
|
||||
if (
|
||||
runtimeConfigSetupStart === -1 ||
|
||||
runtimeConfigSetupEnd === -1 ||
|
||||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
|
||||
!runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') ||
|
||||
!runtimeConfigSetupSource.includes(
|
||||
'startup.appdata.configure.failed details={details}',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
|
||||
);
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'import.meta.env.DEV',
|
||||
'#[cfg(all(debug_assertions, not(test)))]',
|
||||
|
||||
@@ -4,6 +4,10 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
game-chat-release = []
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.2", features = [] }
|
||||
|
||||
@@ -39,4 +43,4 @@ tauri-plugin-clipboard-manager = "2.3.2"
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects"] }
|
||||
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
@@ -514,22 +514,25 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_llm_client(
|
||||
pub(crate) fn game_creator_agent_llm_error_public_summary(
|
||||
error: &platform_llm::LlmError,
|
||||
) -> String {
|
||||
let kind = match error {
|
||||
platform_llm::LlmError::Timeout { .. } => "timeout".to_string(),
|
||||
platform_llm::LlmError::Connectivity { .. } => "connectivity".to_string(),
|
||||
platform_llm::LlmError::Transport(_) => "transport".to_string(),
|
||||
let (kind, http_status) = match error {
|
||||
platform_llm::LlmError::Timeout { .. } => ("timeout".to_string(), None),
|
||||
platform_llm::LlmError::Connectivity { .. } => ("connectivity".to_string(), None),
|
||||
platform_llm::LlmError::Transport(_) => ("transport".to_string(), None),
|
||||
platform_llm::LlmError::Upstream { status_code, .. } => {
|
||||
format!("upstream-{status_code}")
|
||||
(format!("upstream-{status_code}"), Some(*status_code))
|
||||
}
|
||||
platform_llm::LlmError::InvalidConfig(_) => "invalid-config".to_string(),
|
||||
platform_llm::LlmError::InvalidRequest(_) => "invalid-request".to_string(),
|
||||
platform_llm::LlmError::StreamUnavailable => "stream-unavailable".to_string(),
|
||||
platform_llm::LlmError::EmptyResponse => "empty-response".to_string(),
|
||||
platform_llm::LlmError::Deserialize(_) => "deserialize".to_string(),
|
||||
platform_llm::LlmError::InvalidConfig(_) => ("invalid-config".to_string(), None),
|
||||
platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None),
|
||||
platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None),
|
||||
platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None),
|
||||
platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None),
|
||||
};
|
||||
let raw = error.to_string();
|
||||
let http_status = http_status
|
||||
.map(|status| format!(" httpStatus={status}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"kind={kind} fingerprint={:x} chars={}",
|
||||
"kind={kind}{http_status} fingerprint={:x} chars={}",
|
||||
Sha256::digest(raw.as_bytes()),
|
||||
raw.chars().count()
|
||||
)
|
||||
|
||||
@@ -44,6 +44,8 @@ pub(in crate::agent) use run_status_observation::*;
|
||||
pub(in crate::agent) use structured_plan::*;
|
||||
pub(in crate::agent) use tool_plan_protocol::*;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test;
|
||||
#[cfg(test)]
|
||||
pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test;
|
||||
pub(crate) use action_audit::{
|
||||
|
||||
@@ -194,6 +194,14 @@ pub(crate) fn agent_runtime_action_receipt_safe_detail_for_owner_for_test(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn agent_runtime_action_receipt_public_safe_detail_for_test(
|
||||
root: &Path,
|
||||
observation: &AgentRuntimeToolObservation,
|
||||
) -> Option<String> {
|
||||
agent_runtime_action_receipt_safe_detail(root, observation)
|
||||
}
|
||||
|
||||
fn agent_runtime_action_receipt_safe_detail_with_owner(
|
||||
root: &Path,
|
||||
receipt_owner: Option<(&str, &str)>,
|
||||
@@ -428,16 +436,27 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let diagnostics_count = detail
|
||||
.get("diagnostics")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
if receipt_owner.is_none() {
|
||||
return serde_json::to_string(&serde_json::json!({
|
||||
"passed": passed,
|
||||
"revision": revision,
|
||||
"diagnosticsCount": diagnostics_count,
|
||||
"playtestPassed": playtest_passed,
|
||||
"playtestScenario": playtest_scenario,
|
||||
}))
|
||||
.ok();
|
||||
}
|
||||
return serde_json::to_string(&serde_json::json!({
|
||||
"passed": passed,
|
||||
"revision": revision,
|
||||
"reportPath": report_path,
|
||||
"screenshots": screenshots,
|
||||
"diagnosticsCount": detail
|
||||
.get("diagnostics")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0),
|
||||
"diagnosticsCount": diagnostics_count,
|
||||
"playtestPassed": playtest_passed,
|
||||
"playtestScenario": playtest_scenario,
|
||||
}))
|
||||
|
||||
@@ -480,11 +480,10 @@ pub(crate) fn resume_game_creator_agent_runtime_for_goal_at(
|
||||
if let Some(retry) = waiting_provider_retry.as_ref() {
|
||||
state.status = "running".to_string();
|
||||
state.phase = "waiting-for-provider-retry".to_string();
|
||||
state.current_action = format!(
|
||||
"Goal 已恢复,继续等待 Provider 瞬态重试 {}/{}",
|
||||
retry.next_attempt, retry.max_retries
|
||||
);
|
||||
state.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind);
|
||||
let (current_action, waiting_on) =
|
||||
game_creator_agent_runtime_provider_retry_waiting_presentation(retry);
|
||||
state.current_action = format!("Goal 已恢复,{current_action}");
|
||||
state.waiting_on = waiting_on;
|
||||
state.next_step = "到期后恢复同一 Session/run/loop 和 retry attempt".to_string();
|
||||
} else if pending_action.as_ref().is_some_and(|pending| {
|
||||
pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
|
||||
|
||||
@@ -629,7 +629,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
Some(&session_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("后台任务失败:{error}"),
|
||||
content: game_creator_agent_runtime_failure_conversation_message(
|
||||
&agent_id, &error,
|
||||
),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
@@ -1817,7 +1819,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
Some(&session_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("后台任务失败:{error}"),
|
||||
content: game_creator_agent_runtime_failure_conversation_message(
|
||||
&agent_id, &error,
|
||||
),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
@@ -1941,7 +1945,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
Some(&session_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("后台任务失败:{error}"),
|
||||
content: game_creator_agent_runtime_failure_conversation_message(
|
||||
&agent_id, &error,
|
||||
),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
@@ -2201,7 +2207,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
Some(&session_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("后台任务失败:{error}"),
|
||||
content: game_creator_agent_runtime_failure_conversation_message(
|
||||
&agent_id, &error,
|
||||
),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
@@ -3002,7 +3010,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
|
||||
Some(&session_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("后台任务失败:{error}"),
|
||||
content: game_creator_agent_runtime_failure_conversation_message(
|
||||
&agent_id, &error,
|
||||
),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -190,10 +190,14 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
|
||||
final_sequence: Some(8),
|
||||
final_phase: Some(BrowserPlaytestPhase::Playing),
|
||||
final_level: Some(2),
|
||||
assertions: vec![BrowserPlaytestAssertion {
|
||||
name: "fixture-passed".to_string(),
|
||||
passed: true,
|
||||
}],
|
||||
assertions: scenario
|
||||
.assertion_names()
|
||||
.iter()
|
||||
.map(|name| BrowserPlaytestAssertion {
|
||||
name: (*name).to_string(),
|
||||
passed: true,
|
||||
})
|
||||
.collect(),
|
||||
diagnostics: Vec::new(),
|
||||
}),
|
||||
diagnostics: Vec::new(),
|
||||
|
||||
@@ -684,11 +684,10 @@ pub(in crate::agent) fn persist_waiting_provider_retry_context_at(
|
||||
}
|
||||
runtime.status = "running".to_string();
|
||||
runtime.phase = "waiting-for-provider-retry".to_string();
|
||||
runtime.current_action = format!(
|
||||
"等待 Provider 瞬态重试 {}/{}",
|
||||
retry.next_attempt, retry.max_retries
|
||||
);
|
||||
runtime.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind);
|
||||
let (current_action, waiting_on) =
|
||||
game_creator_agent_runtime_provider_retry_waiting_presentation(retry);
|
||||
runtime.current_action = current_action;
|
||||
runtime.waiting_on = waiting_on;
|
||||
runtime.next_step = "到期后自动恢复同一 Session/run/loop,并重新检查控制状态".to_string();
|
||||
runtime.error = None;
|
||||
runtime.updated_at = unix_timestamp();
|
||||
|
||||
@@ -228,7 +228,7 @@ pub(in crate::agent) fn fail_game_creator_agent_background_context_at(
|
||||
Some(session_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("后台任务失败:{error}"),
|
||||
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
|
||||
+29
-5
@@ -664,8 +664,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
|
||||
BrowserPlaytestScenario::GenericV1 => {
|
||||
concat!(
|
||||
"完成合同要求 generic-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level;",
|
||||
"界面必须提供 data-playtest-id=\"start\" 与 data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
|
||||
"start 后状态必须推进并进入 playing 或 won,restart 后必须再次推进并回到 ready 或 playing。"
|
||||
"界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实可点击控件;primary-action 必须映射游戏的真实主要玩法操作,并在动作发生时推进 state sequence,不能使用空操作或仅更新装饰 UI 的按钮;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
|
||||
"初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并先至少持续 2 秒保持 playing,让玩家获得可操作机会,在 primary-action 之前进入 ready、won 或 lost 都会失败;随后 primary-action 必须再次严格推进 sequence,primary-action 后 phase 可为 playing、won 或 lost,单次 won 或 lost 都是正常游戏终态,不会仅凭一次 lost 判定试玩失败;若动作后仍为 playing,则最多继续观察 3 秒,期间 won/lost 可提前形成首轮结果,始终 playing 也可在观察完成后证明非失败推进。restart 后必须再次推进,且至少持续 3 秒的稳定观察窗口内只能保持 ready 或 playing,进入 won 或 lost 都会失败;如果首轮 primary-action 结果为 lost,重开稳定后必须自动执行第二次受控尝试,恢复为 ready 时先 start 推进到 playing,随后无论重开结果原本是 ready 还是 playing,都必须再次完成至少 2 秒的 playing 操作机会,再次点击 primary-action 且严格推进 sequence;第二次必须进入 won,或保持 playing 并完成 3 秒观察,观察期间可进入 won 但不得进入 lost。两次受控尝试都进入 lost 说明游戏存在无法正常推进的固定失败,必须判定试玩失败;全部观察期间 sequence 始终不得回退。"
|
||||
)
|
||||
}
|
||||
BrowserPlaytestScenario::LaneDefenseV1 => {
|
||||
@@ -921,7 +921,7 @@ pub(in crate::agent) fn validate_autonomous_evidence_digest(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn validate_autonomous_playtest_receipt(
|
||||
fn validate_autonomous_playtest_receipt_integrity(
|
||||
root: &Path,
|
||||
contract: &AgentRuntimeAutonomousCompletionContract,
|
||||
receipt: &AgentRuntimeAutonomousPlaytestReceipt,
|
||||
@@ -992,6 +992,20 @@ pub(in crate::agent) fn validate_autonomous_playtest_receipt(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn validate_autonomous_playtest_receipt(
|
||||
root: &Path,
|
||||
contract: &AgentRuntimeAutonomousCompletionContract,
|
||||
receipt: &AgentRuntimeAutonomousPlaytestReceipt,
|
||||
) -> Result<(), String> {
|
||||
validate_autonomous_playtest_receipt_integrity(root, contract, receipt)?;
|
||||
let expected_scenario_fingerprint =
|
||||
browser_playtest_scenario_fingerprint(contract.playtest_scenario);
|
||||
if receipt.scenario_fingerprint != expected_scenario_fingerprint {
|
||||
return Err("自主试玩回执场景指纹与当前固定试玩合同不匹配".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn read_autonomous_playtest_receipt(
|
||||
root: &Path,
|
||||
contract: &AgentRuntimeAutonomousCompletionContract,
|
||||
@@ -1004,7 +1018,12 @@ pub(in crate::agent) fn read_autonomous_playtest_receipt(
|
||||
"自主试玩回执",
|
||||
)?;
|
||||
if let Some(receipt) = receipt.as_ref() {
|
||||
validate_autonomous_playtest_receipt(root, contract, receipt)?;
|
||||
validate_autonomous_playtest_receipt_integrity(root, contract, receipt)?;
|
||||
let expected_scenario_fingerprint =
|
||||
browser_playtest_scenario_fingerprint(contract.playtest_scenario);
|
||||
if receipt.scenario_fingerprint != expected_scenario_fingerprint {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(receipt)
|
||||
}
|
||||
@@ -1043,6 +1062,7 @@ pub(in crate::agent) fn verify_autonomous_playtest_evidence_files_at(
|
||||
.ok_or_else(|| "自主试玩浏览器报告缺少交互试玩结果".to_string())?;
|
||||
if !report.passed
|
||||
|| !playtest.passed
|
||||
|| !playtest.matches_scenario_contract()
|
||||
|| playtest.scenario != receipt.playtest_scenario
|
||||
|| playtest.scenario_fingerprint != receipt.scenario_fingerprint
|
||||
{
|
||||
@@ -1069,10 +1089,13 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at(
|
||||
.playtest
|
||||
.as_ref()
|
||||
.ok_or_else(|| "自主构建 preview.validate 缺少交互试玩结果".to_string())?;
|
||||
let expected_scenario_fingerprint =
|
||||
browser_playtest_scenario_fingerprint(contract.playtest_scenario);
|
||||
if !result.passed
|
||||
|| !playtest.passed
|
||||
|| !playtest.matches_scenario_contract()
|
||||
|| playtest.scenario != contract.playtest_scenario
|
||||
|| !is_lowercase_sha256(&playtest.scenario_fingerprint)
|
||||
|| playtest.scenario_fingerprint != expected_scenario_fingerprint
|
||||
{
|
||||
return Err("自主构建交互试玩未通过或场景身份不匹配".to_string());
|
||||
}
|
||||
@@ -1102,6 +1125,7 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at(
|
||||
.ok_or_else(|| "持久浏览器报告缺少交互试玩结果".to_string())?;
|
||||
if !persisted_report.passed
|
||||
|| !persisted_playtest.passed
|
||||
|| !persisted_playtest.matches_scenario_contract()
|
||||
|| persisted_playtest.scenario != playtest.scenario
|
||||
|| persisted_playtest.scenario_fingerprint != playtest.scenario_fingerprint
|
||||
{
|
||||
|
||||
+236
-4
@@ -288,10 +288,14 @@ fn browser_result_fixture(
|
||||
final_sequence: Some(8),
|
||||
final_phase: Some(BrowserPlaytestPhase::Playing),
|
||||
final_level: Some(2),
|
||||
assertions: vec![BrowserPlaytestAssertion {
|
||||
name: "fixture-passed".to_string(),
|
||||
passed: true,
|
||||
}],
|
||||
assertions: scenario
|
||||
.assertion_names()
|
||||
.iter()
|
||||
.map(|name| BrowserPlaytestAssertion {
|
||||
name: (*name).to_string(),
|
||||
passed: true,
|
||||
})
|
||||
.collect(),
|
||||
diagnostics: Vec::new(),
|
||||
}),
|
||||
diagnostics: Vec::new(),
|
||||
@@ -555,6 +559,234 @@ fn autonomous_playtest_contract_requires_unique_visible_enabled_automation_contr
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_playtest_contract_requires_play_opportunity_and_post_action_outcome() {
|
||||
let prompt = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
|
||||
for requirement in [
|
||||
"start 后状态必须推进并进入 playing",
|
||||
"先至少持续 2 秒保持 playing",
|
||||
"玩家获得可操作机会",
|
||||
"data-playtest-id=\"primary-action\"",
|
||||
"真实主要玩法操作",
|
||||
"动作发生时推进 state sequence",
|
||||
"primary-action 后 phase 可为 playing、won 或 lost",
|
||||
"单次 won 或 lost 都是正常游戏终态",
|
||||
"不会仅凭一次 lost 判定试玩失败",
|
||||
"若动作后仍为 playing,则最多继续观察 3 秒",
|
||||
"restart 后必须再次推进",
|
||||
"至少持续 3 秒",
|
||||
"稳定观察窗口内只能保持 ready 或 playing",
|
||||
"首轮 primary-action 结果为 lost",
|
||||
"自动执行第二次受控尝试",
|
||||
"无论重开结果原本是 ready 还是 playing",
|
||||
"再次完成至少 2 秒的 playing 操作机会",
|
||||
"观察期间可进入 won 但不得进入 lost",
|
||||
"两次受控尝试都进入 lost",
|
||||
"无法正常推进的固定失败",
|
||||
"sequence 始终不得回退",
|
||||
] {
|
||||
assert!(
|
||||
prompt.contains(requirement),
|
||||
"missing generic stability requirement: {requirement}"
|
||||
);
|
||||
}
|
||||
assert!(!prompt.contains("进入 playing 或 won"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() {
|
||||
let (_temporary, root, state, contract) = autonomous_fixture(
|
||||
"做一个完整小游戏",
|
||||
"autonomous-stale-playtest-fingerprint-run",
|
||||
);
|
||||
let revision = advance_game_index_revision(
|
||||
&root,
|
||||
&state,
|
||||
"<!doctype html><title>新游戏</title><canvas></canvas>",
|
||||
);
|
||||
let result =
|
||||
browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1);
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "preview.validate".to_string(),
|
||||
reason: Some("验证真实可玩闭环".to_string()),
|
||||
input: serde_json::json!({}),
|
||||
};
|
||||
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
|
||||
let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint);
|
||||
let mut stale_result = result.clone();
|
||||
stale_result
|
||||
.playtest
|
||||
.as_mut()
|
||||
.expect("stale browser result playtest")
|
||||
.scenario_fingerprint =
|
||||
"a6ac4da3a698881b175f754ee4660d5ebc8b3486e357c2ab1e5df2add3d3349a".to_string();
|
||||
write_autonomous_playtest_receipt_at(
|
||||
&root,
|
||||
&contract,
|
||||
&action_id,
|
||||
&action_fingerprint,
|
||||
revision,
|
||||
&stale_result,
|
||||
)
|
||||
.expect_err("previous scenario fingerprint must be rejected before receipt persistence");
|
||||
|
||||
let mut receipt = write_autonomous_playtest_receipt_at(
|
||||
&root,
|
||||
&contract,
|
||||
&action_id,
|
||||
&action_fingerprint,
|
||||
revision,
|
||||
&result,
|
||||
)
|
||||
.expect("persist current autonomous playtest receipt");
|
||||
|
||||
let mut legacy_report = result.clone();
|
||||
legacy_report
|
||||
.playtest
|
||||
.as_mut()
|
||||
.expect("legacy browser report playtest")
|
||||
.assertions = [
|
||||
"state-surface-valid",
|
||||
"start-control-clicked",
|
||||
"start-sequence-advanced",
|
||||
"start-phase-playing-or-won",
|
||||
"restart-control-clicked",
|
||||
"restart-sequence-advanced",
|
||||
"restart-phase-ready-or-playing",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| BrowserPlaytestAssertion {
|
||||
name: name.to_string(),
|
||||
passed: true,
|
||||
})
|
||||
.collect();
|
||||
let legacy_report_bytes =
|
||||
serde_json::to_vec_pretty(&legacy_report).expect("encode legacy browser report");
|
||||
fs::write(root.join(&receipt.report.path), &legacy_report_bytes)
|
||||
.expect("persist legacy browser report");
|
||||
receipt.report.sha256 = format!("{:x}", Sha256::digest(&legacy_report_bytes));
|
||||
receipt.report.size_bytes = legacy_report_bytes.len() as u64;
|
||||
receipt.receipt_fingerprint = autonomous_playtest_receipt_fingerprint(&receipt);
|
||||
validate_autonomous_playtest_receipt(&root, &contract, &receipt)
|
||||
.expect("legacy report keeps a structurally valid receipt");
|
||||
let legacy_error = verify_autonomous_playtest_evidence_files_at(&root, &receipt)
|
||||
.expect_err("legacy weak assertion set must fail current playtest verification");
|
||||
assert!(
|
||||
legacy_error.contains("未通过"),
|
||||
"unexpected error: {legacy_error}"
|
||||
);
|
||||
|
||||
receipt.scenario_fingerprint =
|
||||
"a6ac4da3a698881b175f754ee4660d5ebc8b3486e357c2ab1e5df2add3d3349a".to_string();
|
||||
receipt.receipt_fingerprint = autonomous_playtest_receipt_fingerprint(&receipt);
|
||||
let error = validate_autonomous_playtest_receipt(&root, &contract, &receipt)
|
||||
.expect_err("previous generic-v1 scenario fingerprint must fail closed");
|
||||
assert!(error.contains("场景指纹"), "unexpected error: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() {
|
||||
let (_temporary, root, state, contract) = autonomous_fixture(
|
||||
"做一个完整小游戏",
|
||||
"autonomous-recover-stale-playtest-fingerprint-run",
|
||||
);
|
||||
let revision = advance_game_index_revision(
|
||||
&root,
|
||||
&state,
|
||||
"<!doctype html><title>可恢复试玩</title><canvas></canvas>",
|
||||
);
|
||||
let result =
|
||||
browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1);
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "preview.validate".to_string(),
|
||||
reason: Some("验证可恢复试玩回执".to_string()),
|
||||
input: serde_json::json!({}),
|
||||
};
|
||||
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
|
||||
let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint);
|
||||
let current = write_autonomous_playtest_receipt_at(
|
||||
&root,
|
||||
&contract,
|
||||
&action_id,
|
||||
&action_fingerprint,
|
||||
revision,
|
||||
&result,
|
||||
)
|
||||
.expect("persist current autonomous playtest receipt");
|
||||
|
||||
let mut stale = current.clone();
|
||||
stale.scenario_fingerprint =
|
||||
"b48e3189a0765d82b84b56ce88cff8d05db7d1c0cc218a5d068e6470b83010d3".to_string();
|
||||
stale.receipt_fingerprint = autonomous_playtest_receipt_fingerprint(&stale);
|
||||
write_agent_runtime_json_sidecar(
|
||||
&root,
|
||||
&autonomous_playtest_receipt_relative_path(&contract.agent_id, &contract.run_id),
|
||||
"旧自主试玩回执 fixture",
|
||||
&stale,
|
||||
)
|
||||
.expect("persist stale autonomous playtest receipt fixture");
|
||||
|
||||
assert!(
|
||||
read_autonomous_playtest_receipt(&root, &contract)
|
||||
.expect("stale scenario fingerprint is recoverable")
|
||||
.is_none(),
|
||||
"stale scenario receipt must behave like missing evidence"
|
||||
);
|
||||
|
||||
let receipt_path =
|
||||
autonomous_playtest_receipt_relative_path(&contract.agent_id, &contract.run_id);
|
||||
let mut digest_tampered = stale.clone();
|
||||
digest_tampered.report.sha256 = "0".repeat(64);
|
||||
write_agent_runtime_json_sidecar(
|
||||
&root,
|
||||
&receipt_path,
|
||||
"摘要篡改自主试玩回执 fixture",
|
||||
&digest_tampered,
|
||||
)
|
||||
.expect("persist digest-tampered receipt fixture");
|
||||
assert!(
|
||||
read_autonomous_playtest_receipt(&root, &contract).is_err(),
|
||||
"digest tampering must remain a hard read error"
|
||||
);
|
||||
|
||||
let mut binding_tampered = stale.clone();
|
||||
binding_tampered.run_profile_binding_fingerprint = "1".repeat(64);
|
||||
binding_tampered.receipt_fingerprint =
|
||||
autonomous_playtest_receipt_fingerprint(&binding_tampered);
|
||||
write_agent_runtime_json_sidecar(
|
||||
&root,
|
||||
&receipt_path,
|
||||
"绑定篡改自主试玩回执 fixture",
|
||||
&binding_tampered,
|
||||
)
|
||||
.expect("persist binding-tampered receipt fixture");
|
||||
assert!(
|
||||
read_autonomous_playtest_receipt(&root, &contract).is_err(),
|
||||
"binding tampering must remain a hard read error"
|
||||
);
|
||||
|
||||
write_agent_runtime_json_sidecar(&root, &receipt_path, "旧自主试玩回执 fixture", &stale)
|
||||
.expect("restore stale autonomous playtest receipt fixture");
|
||||
assert!(read_autonomous_playtest_receipt(&root, &contract)
|
||||
.expect("restored stale scenario fingerprint is recoverable")
|
||||
.is_none());
|
||||
|
||||
let replacement = write_autonomous_playtest_receipt_at(
|
||||
&root,
|
||||
&contract,
|
||||
&action_id,
|
||||
&action_fingerprint,
|
||||
revision,
|
||||
&result,
|
||||
)
|
||||
.expect("replace stale autonomous playtest receipt");
|
||||
assert_eq!(
|
||||
read_autonomous_playtest_receipt(&root, &contract)
|
||||
.expect("read replacement autonomous playtest receipt"),
|
||||
Some(replacement)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_autonomous_agent_inherits_parent_playtest_contract() {
|
||||
let (_temporary, root, parent_state, _contract) = autonomous_fixture(
|
||||
|
||||
@@ -76,13 +76,131 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_error_with_transient
|
||||
error,
|
||||
retry_autonomous_upstream_400,
|
||||
) {
|
||||
Some(kind) => {
|
||||
format!("{AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX}{kind}\n{public_error}")
|
||||
Some(transient_kind) => {
|
||||
let error_kind = match error {
|
||||
platform_llm::LlmError::Upstream { status_code, .. } => {
|
||||
format!("upstream-{status_code}")
|
||||
}
|
||||
_ => transient_kind.to_string(),
|
||||
};
|
||||
format!("{AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX}{error_kind}\n{public_error}")
|
||||
}
|
||||
None => public_error,
|
||||
}
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_provider_upstream_http_status(error_kind: &str) -> Option<u16> {
|
||||
let status = error_kind.strip_prefix("upstream-")?;
|
||||
if status.len() != 3 || !status.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
status
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|status| (100..=599).contains(status))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_waiting_presentation(
|
||||
retry: &AgentRuntimeProviderRetryRecord,
|
||||
) -> (String, String) {
|
||||
let current_action =
|
||||
match game_creator_agent_runtime_provider_upstream_http_status(&retry.error_kind) {
|
||||
Some(status) => format!(
|
||||
"Provider 上游返回 HTTP {status},准备自动重试 {}/{}",
|
||||
retry.next_attempt, retry.max_retries
|
||||
),
|
||||
None => format!(
|
||||
"Provider 瞬态故障,准备自动重试 {}/{}",
|
||||
retry.next_attempt, retry.max_retries
|
||||
),
|
||||
};
|
||||
let remaining_ms = crate::provider_retry::remaining_ms(retry);
|
||||
let remaining_seconds = game_creator_agent_runtime_provider_retry_wait_seconds(remaining_ms);
|
||||
(current_action, format!("预计 {remaining_seconds} 秒后重试"))
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_provider_retry_exhausted_error(
|
||||
public_error: &str,
|
||||
retry_attempt: u32,
|
||||
max_retries: u32,
|
||||
) -> String {
|
||||
format!(
|
||||
"{public_error} retryAttempt={retry_attempt} maxRetries={max_retries} retryState=exhausted"
|
||||
)
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_provider_retry_wait_seconds(remaining_ms: u64) -> u64 {
|
||||
remaining_ms / 1_000 + u64::from(remaining_ms % 1_000 != 0)
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_exhausted_upstream_retry_fields(
|
||||
error: &str,
|
||||
) -> Option<(u16, u32, u32)> {
|
||||
const KIND_PREFIX: &str = "kind=upstream-";
|
||||
let start = error.rfind(KIND_PREFIX)?;
|
||||
if let Some(boundary) = error[..start].chars().next_back() {
|
||||
if !boundary.is_whitespace() && boundary != ':' && boundary != ':' {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut fields = error[start..].split_ascii_whitespace();
|
||||
let kind_status = fields.next()?.strip_prefix(KIND_PREFIX)?;
|
||||
let http_status = fields.next()?.strip_prefix("httpStatus=")?;
|
||||
let fingerprint = fields.next()?.strip_prefix("fingerprint=")?;
|
||||
let chars = fields.next()?.strip_prefix("chars=")?;
|
||||
let retry_attempt = fields.next()?.strip_prefix("retryAttempt=")?;
|
||||
let max_retries = fields.next()?.strip_prefix("maxRetries=")?;
|
||||
if fields.next()? != "retryState=exhausted" || fields.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if kind_status.len() != 3
|
||||
|| http_status.len() != 3
|
||||
|| fingerprint.len() != 64
|
||||
|| !fingerprint
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
|| chars.is_empty()
|
||||
|| !chars.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let kind_status = kind_status.parse::<u16>().ok()?;
|
||||
let http_status = http_status.parse::<u16>().ok()?;
|
||||
let retry_attempt = retry_attempt.parse::<u32>().ok()?;
|
||||
let max_retries = max_retries.parse::<u32>().ok()?;
|
||||
if kind_status != http_status
|
||||
|| !(500..=599).contains(&http_status)
|
||||
|| retry_attempt != max_retries
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((http_status, retry_attempt, max_retries))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn game_creator_agent_runtime_failure_conversation_message(
|
||||
agent_id: &str,
|
||||
error: &str,
|
||||
) -> String {
|
||||
let subject = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
"项目总控 Agent"
|
||||
} else {
|
||||
"专业 Agent"
|
||||
};
|
||||
if let Some((http_status, retry_attempt, max_retries)) =
|
||||
game_creator_agent_runtime_exhausted_upstream_retry_fields(error)
|
||||
{
|
||||
return format!(
|
||||
"{subject} 上游服务返回 HTTP {http_status};自动重试已耗尽({retry_attempt}/{max_retries})"
|
||||
);
|
||||
}
|
||||
if error.contains("kind=upstream-")
|
||||
|| (error.contains("kind=") && error.contains(" fingerprint=") && error.contains(" chars="))
|
||||
{
|
||||
return format!("{subject} 服务请求失败,请稍后重试");
|
||||
}
|
||||
format!("{subject} 执行失败,请稍后重试")
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn append_game_creator_agent_runtime_provider_retry_audit(
|
||||
root: &Path,
|
||||
provider_snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
@@ -1097,7 +1215,11 @@ where
|
||||
&provider_snapshot.agent_id,
|
||||
&provider_snapshot.run_id,
|
||||
)?;
|
||||
return Err(public_error.to_string());
|
||||
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
|
||||
public_error,
|
||||
attempt,
|
||||
error_max_retries,
|
||||
));
|
||||
}
|
||||
let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
@@ -1232,7 +1354,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
|
||||
max_retries
|
||||
};
|
||||
if attempt >= error_max_retries {
|
||||
return Err(public_error.to_string());
|
||||
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
|
||||
public_error,
|
||||
attempt,
|
||||
error_max_retries,
|
||||
));
|
||||
}
|
||||
let retry_attempt = attempt.saturating_add(1);
|
||||
let backoff_ms = append_game_creator_agent_runtime_provider_retry_audit(
|
||||
@@ -1432,4 +1558,122 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_upstream_503_keeps_generic_classification_and_specific_safe_code() {
|
||||
let secret = ["sk", "provider-body-secret"].join("-");
|
||||
let error = platform_llm::LlmError::Upstream {
|
||||
status_code: 503,
|
||||
message: format!(
|
||||
"unavailable url=https://provider.example/private?token={secret} path=C:\\private\\provider.txt"
|
||||
),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_transient_provider_error_kind(&error, false),
|
||||
Some("upstream-5xx")
|
||||
);
|
||||
let encoded = game_creator_agent_runtime_provider_error_with_transient_kind(
|
||||
&error,
|
||||
"agentLlm.project-supervisor",
|
||||
"规划",
|
||||
false,
|
||||
);
|
||||
let encoded = encoded
|
||||
.strip_prefix(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX)
|
||||
.expect("retryable error prefix");
|
||||
let (error_kind, public_error) = encoded.split_once('\n').expect("encoded public error");
|
||||
assert_eq!(error_kind, "upstream-503");
|
||||
assert!(public_error.contains("kind=upstream-503 httpStatus=503 fingerprint="));
|
||||
assert!(public_error.contains(" chars="));
|
||||
for forbidden in [
|
||||
"provider.example",
|
||||
"token=",
|
||||
secret.as_str(),
|
||||
"C:\\private\\provider.txt",
|
||||
"unavailable",
|
||||
] {
|
||||
assert!(
|
||||
!public_error.contains(forbidden),
|
||||
"public Provider error leaked {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_retry_exhausted_error_has_stable_machine_fields() {
|
||||
let error = game_creator_agent_runtime_provider_retry_exhausted_error(
|
||||
"kind=upstream-503 httpStatus=503 fingerprint=abc chars=42",
|
||||
3,
|
||||
3,
|
||||
);
|
||||
assert_eq!(
|
||||
error,
|
||||
"kind=upstream-503 httpStatus=503 fingerprint=abc chars=42 retryAttempt=3 maxRetries=3 retryState=exhausted"
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_upstream_http_status("upstream-503"),
|
||||
Some(503)
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_upstream_http_status("upstream-5xx"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_upstream_http_status(
|
||||
"upstream-503 malicious provider body"
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(game_creator_agent_runtime_provider_retry_wait_seconds(0), 0);
|
||||
assert_eq!(game_creator_agent_runtime_provider_retry_wait_seconds(1), 1);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_retry_wait_seconds(1_000),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_retry_wait_seconds(1_001),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_provider_retry_wait_seconds(30_001),
|
||||
31
|
||||
);
|
||||
|
||||
let canonical = format!(
|
||||
"agentLlm.project-supervisor 调用 LLM 失败:kind=upstream-503 httpStatus=503 fingerprint={} chars=42 retryAttempt=3 maxRetries=3 retryState=exhausted",
|
||||
"a".repeat(64)
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_failure_conversation_message(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&canonical,
|
||||
),
|
||||
"项目总控 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)"
|
||||
);
|
||||
assert_eq!(
|
||||
game_creator_agent_runtime_failure_conversation_message("design-director", &canonical),
|
||||
"专业 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)"
|
||||
);
|
||||
|
||||
let malicious = format!("{canonical} provider body https://secret.example?api_key=sk-x");
|
||||
let visible = game_creator_agent_runtime_failure_conversation_message(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&malicious,
|
||||
);
|
||||
assert_eq!(visible, "项目总控 Agent 服务请求失败,请稍后重试");
|
||||
assert!(!visible.contains("secret.example"));
|
||||
assert!(!visible.contains("sk-x"));
|
||||
|
||||
let unrelated_private_error =
|
||||
"failed path=C:\\Users\\victim\\private.txt <absolute-path> [redacted-secret]";
|
||||
let unrelated_visible = game_creator_agent_runtime_failure_conversation_message(
|
||||
"design-director",
|
||||
unrelated_private_error,
|
||||
);
|
||||
assert_eq!(unrelated_visible, "专业 Agent 执行失败,请稍后重试");
|
||||
assert!(!unrelated_visible.contains("victim"));
|
||||
assert!(!unrelated_visible.contains("absolute-path"));
|
||||
assert!(!unrelated_visible.contains("redacted-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -283,17 +283,36 @@ impl AgentRuntimeRealE2eCheckpointAppData {
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
|
||||
const FILE_SHARE_READ: u32 = 0x0000_0001;
|
||||
const FILE_SHARE_DELETE: u32 = 0x0000_0004;
|
||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||
let path = self.path.join(name);
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
// The ACK protocol reads the completed temp file through a second handle and
|
||||
// atomically renames it while this validated handle remains live. Deny writers,
|
||||
// but permit those two operations so the handle continues to pin file identity.
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(&path)
|
||||
.map_err(|_| ())?;
|
||||
secure_windows_game_creator_path_for_current_user(&path, false, true)
|
||||
.map_err(|_| ())?;
|
||||
let secured = (|| {
|
||||
crate::runner::validate_windows_regular_file_handle(&file, "real E2E 私有文件")
|
||||
.map_err(|_| ())?;
|
||||
crate::initialize_windows_game_creator_file_owner_for_current_user(&path)
|
||||
.map_err(|_| ())?;
|
||||
crate::runner::validate_windows_regular_file_handle(&file, "real E2E 私有文件")
|
||||
.map_err(|_| ())?;
|
||||
secure_windows_game_creator_path_for_current_user(&path, false, false)
|
||||
.map_err(|_| ())
|
||||
})();
|
||||
if secured.is_err() {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(path);
|
||||
return Err(());
|
||||
}
|
||||
return Ok(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1068,9 +1068,8 @@ fn game_creator_agent_runtime_failure_metadata(error: &str) -> (String, usize) {
|
||||
)
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_public_failure_detail(error: &str) -> String {
|
||||
let (error_sha256, error_chars) = game_creator_agent_runtime_failure_metadata(error);
|
||||
format!("errorSha256={error_sha256} · errorChars={error_chars}")
|
||||
fn game_creator_agent_runtime_public_failure_detail(agent_id: &str, error: &str) -> String {
|
||||
game_creator_agent_runtime_failure_conversation_message(agent_id, error)
|
||||
}
|
||||
|
||||
pub(crate) fn append_game_creator_agent_background_task_failed_audit(
|
||||
@@ -2334,7 +2333,10 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
|
||||
event_type,
|
||||
"error" | "turn.failed" | "turn.budget_exhausted"
|
||||
) {
|
||||
return game_creator_agent_runtime_public_failure_detail(value);
|
||||
return game_creator_agent_runtime_public_failure_detail(
|
||||
&state.agent_id,
|
||||
value,
|
||||
);
|
||||
}
|
||||
let max_chars = if event_type == "observation"
|
||||
&& summary.starts_with("agent.action_history:")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,16 @@ use super::model::{
|
||||
mod generic;
|
||||
mod lane_defense;
|
||||
|
||||
pub(super) use generic::{
|
||||
finish_generic_stability_observation, generic_non_loss_progression_phase_is_valid,
|
||||
generic_primary_action_phase_is_valid, generic_restart_phase_is_valid,
|
||||
generic_start_phase_is_valid, validate_generic_stability_sample,
|
||||
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP, GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_POST_ACTION_WINDOW, GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW,
|
||||
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW,
|
||||
};
|
||||
pub(super) use lane_defense::{lane_enemy_state_changes, LaneBattleProgress};
|
||||
|
||||
pub(super) const PLAYABLE_GAME_STATE_SCHEMA_VERSION: &str = "playable-web-game-state.v1";
|
||||
@@ -24,12 +34,21 @@ const PLAYTEST_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
const GENERIC_PLAYTEST_ASSERTIONS: &[&str] = &[
|
||||
"state-surface-valid",
|
||||
"initial-phase-ready",
|
||||
"level-positive",
|
||||
"start-control-clicked",
|
||||
"start-sequence-advanced",
|
||||
"start-phase-playing-or-won",
|
||||
"start-phase-playing",
|
||||
"start-opportunity-stable-playing",
|
||||
"primary-action-control-clicked",
|
||||
"primary-action-sequence-advanced",
|
||||
"primary-action-phase-playing-or-terminal",
|
||||
"primary-action-outcome-valid",
|
||||
"non-loss-progression-observed",
|
||||
"restart-control-clicked",
|
||||
"restart-sequence-advanced",
|
||||
"restart-phase-ready-or-playing",
|
||||
"restart-phase-stable-ready-or-playing",
|
||||
];
|
||||
|
||||
const LANE_DEFENSE_PLAYTEST_ASSERTIONS: &[&str] = &[
|
||||
@@ -111,7 +130,7 @@ pub(super) struct PlaytestPollOutcome {
|
||||
}
|
||||
|
||||
impl BrowserPlaytestScenario {
|
||||
pub(super) fn assertion_names(self) -> &'static [&'static str] {
|
||||
pub(crate) fn assertion_names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::GenericV1 => GENERIC_PLAYTEST_ASSERTIONS,
|
||||
Self::LaneDefenseV1 => LANE_DEFENSE_PLAYTEST_ASSERTIONS,
|
||||
@@ -191,9 +210,20 @@ impl BrowserPlaytestResult {
|
||||
failed_assertions.join("、")
|
||||
));
|
||||
}
|
||||
self.passed = browser_playtest_assertions_passed(&self.assertions, &self.diagnostics);
|
||||
self.passed = self.matches_scenario_contract();
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn matches_scenario_contract(&self) -> bool {
|
||||
let required = self.scenario.assertion_names();
|
||||
self.assertions.len() == required.len()
|
||||
&& self
|
||||
.assertions
|
||||
.iter()
|
||||
.zip(required)
|
||||
.all(|(assertion, required_name)| assertion.name == *required_name)
|
||||
&& browser_playtest_assertions_passed(&self.assertions, &self.diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn browser_playtest_assertions_passed(
|
||||
@@ -241,7 +271,44 @@ pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestSce
|
||||
match scenario {
|
||||
BrowserPlaytestScenario::GenericV1 => {
|
||||
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR);
|
||||
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_PRIMARY_ACTION_SELECTOR);
|
||||
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR);
|
||||
update_playtest_fingerprint_component(
|
||||
&mut hasher,
|
||||
&format!(
|
||||
concat!(
|
||||
"startOpportunityWindowMs={}\n",
|
||||
"postActionWindowMs={}\n",
|
||||
"restartStabilityWindowMs={}\n",
|
||||
"startOpportunityPhase=playing\n",
|
||||
"postActionPhase=playing|won|lost\n",
|
||||
"firstPostActionTerminal=won|lost-early-outcome\n",
|
||||
"restartStablePhase=ready|playing\n",
|
||||
"firstLostRetry=after-restart-stability\n",
|
||||
"retryReadyFlow=start-then-opportunity-window\n",
|
||||
"retryPlayingFlow=additional-opportunity-window\n",
|
||||
"retryPostActionPhase=playing|won\n",
|
||||
"retryWon=early-success\n",
|
||||
"retryPlaying=full-post-action-window\n",
|
||||
"doubleControlledLost=fixed-failure\n",
|
||||
"nonLossProgression=required\n",
|
||||
"stabilitySequence=monotonic\n",
|
||||
"windowEndSample=forced\n",
|
||||
"maxFinalSampleGapMs={}\n",
|
||||
"startOpportunityMinSamples={}\n",
|
||||
"postActionMinSamplesIfPlaying={}\n",
|
||||
"restartStabilityMinSamples={}\n",
|
||||
"primaryActionSequence=strict-advance"
|
||||
),
|
||||
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW.as_millis(),
|
||||
GENERIC_PLAYTEST_POST_ACTION_WINDOW.as_millis(),
|
||||
GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW.as_millis(),
|
||||
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP.as_millis(),
|
||||
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES
|
||||
),
|
||||
);
|
||||
}
|
||||
BrowserPlaytestScenario::LaneDefenseV1 => {
|
||||
for selector in [
|
||||
@@ -330,6 +397,7 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
|
||||
}"#;
|
||||
|
||||
pub(super) const PLAYTEST_START_SELECTOR: &str = r#"[data-playtest-id="start"]"#;
|
||||
pub(super) const PLAYTEST_PRIMARY_ACTION_SELECTOR: &str = r#"[data-playtest-id="primary-action"]"#;
|
||||
pub(super) const PLAYTEST_RESTART_SELECTOR: &str = r#"[data-playtest-id="restart"]"#;
|
||||
pub(super) const PLAYTEST_DEFENDER_OPTION_SELECTOR: &str =
|
||||
r#"[data-playtest-id="defender-option"]"#;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1316,11 +1316,7 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
command
|
||||
.as_std_mut()
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||
crate::configure_windows_background_tokio_command(command, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1501,13 +1497,16 @@ async fn request_project_command_process_group_termination(
|
||||
if !taskkill.is_absolute() || !taskkill.is_file() {
|
||||
return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string());
|
||||
}
|
||||
let status = tokio::process::Command::new(taskkill)
|
||||
let mut command = tokio::process::Command::new(taskkill);
|
||||
command
|
||||
.args(["/PID", &process_id.to_string(), "/T", "/F"])
|
||||
.env_clear()
|
||||
.env("SystemRoot", &system_root)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
crate::configure_windows_background_tokio_command(&mut command, false);
|
||||
let status = command
|
||||
.status()
|
||||
.await
|
||||
.map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?;
|
||||
|
||||
@@ -368,6 +368,7 @@ pub(crate) fn game_creator_llm_reasoning_effort_name(
|
||||
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!(
|
||||
@@ -382,6 +383,7 @@ fn validate_game_creator_runtime_config_dir_metadata(
|
||||
#[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() };
|
||||
@@ -418,7 +420,12 @@ fn validate_game_creator_runtime_config_dir_metadata(
|
||||
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(path, true, tighten)?;
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
path,
|
||||
true,
|
||||
tighten,
|
||||
initialize_windows_owner,
|
||||
)?;
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
@@ -437,24 +444,155 @@ fn resolve_game_creator_runtime_config_dir(
|
||||
if !path.is_absolute() {
|
||||
return Err("客户端 AppData 配置目录必须是绝对路径".to_string());
|
||||
}
|
||||
let mut created = false;
|
||||
if create_and_tighten {
|
||||
fs::create_dir_all(path).map_err(|error| {
|
||||
format!(
|
||||
"创建客户端 AppData 配置目录失败:{}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|create_error| {
|
||||
format!(
|
||||
"创建客户端 AppData 配置父目录失败:{}: {create_error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
match fs::create_dir(path) {
|
||||
Ok(()) => created = true,
|
||||
// 与其他启动进程竞争时,不把对方创建的目录误判为本进程的新对象。
|
||||
Err(create_error)
|
||||
if create_error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(create_error) => {
|
||||
return Err(format!(
|
||||
"创建客户端 AppData 配置目录失败:{}: {create_error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"检查客户端 AppData 配置目录失败:{}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// canonicalize 会跟随目录链接,因此必须先检查用户给出的目录项本身。
|
||||
validate_game_creator_runtime_config_dir_entry_type(path)?;
|
||||
let canonical = fs::canonicalize(path).map_err(|error| {
|
||||
format!(
|
||||
"解析客户端 AppData 配置目录失败:{}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten)?;
|
||||
match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created)
|
||||
{
|
||||
Ok(()) => {}
|
||||
#[cfg(windows)]
|
||||
Err(error)
|
||||
if create_and_tighten
|
||||
&& !created
|
||||
&& error.starts_with("Windows 安全对象不属于当前用户:") =>
|
||||
{
|
||||
let backup = migrate_windows_foreign_owner_config_dir(path)?;
|
||||
fs::create_dir(path).map_err(|create_error| {
|
||||
format!(
|
||||
"旧 AppData 配置已安全保留在 {},但重新创建当前用户配置目录失败:{}: {create_error}",
|
||||
backup.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
validate_game_creator_runtime_config_dir_metadata(path, true, true).map_err(
|
||||
|validation_error| {
|
||||
format!(
|
||||
"旧 AppData 配置已安全保留在 {},但新配置目录安全初始化失败:{validation_error}",
|
||||
backup.display()
|
||||
)
|
||||
},
|
||||
)?;
|
||||
return fs::canonicalize(path).map_err(|canonicalize_error| {
|
||||
format!(
|
||||
"旧 AppData 配置已安全保留在 {},但解析新配置目录失败:{}: {canonicalize_error}",
|
||||
backup.display(),
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn validate_game_creator_runtime_config_dir_entry_type(path: &Path) -> Result<(), String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
format!(
|
||||
"读取客户端 AppData 配置目录元数据失败:{}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||||
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn migrate_windows_foreign_owner_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||||
validate_game_creator_runtime_config_dir_entry_type(path)?;
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
format!(
|
||||
"AppData 配置目录没有可用于安全迁移的父目录:{}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("AppData 配置目录名称无效,无法安全迁移:{}", path.display()))?;
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
for attempt in 0..100_u32 {
|
||||
let backup = parent.join(format!(
|
||||
"{}.owner-mismatch-backup-{timestamp}-{}-{attempt}",
|
||||
name.to_string_lossy(),
|
||||
std::process::id()
|
||||
));
|
||||
match fs::symlink_metadata(&backup) {
|
||||
Ok(_) => continue,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"检查旧 AppData 配置备份路径失败:{}: {error}",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
// 同一父目录内 rename 是原子目录项替换;目标已确认不存在,旧配置不会被覆盖。
|
||||
fs::rename(path, &backup).map_err(|error| {
|
||||
format!(
|
||||
"AppData 配置目录 owner 不匹配,无法安全迁移。请保留并手动恢复 {};计划备份路径为 {}:{error}",
|
||||
path.display(),
|
||||
backup.display()
|
||||
)
|
||||
})?;
|
||||
return Ok(backup);
|
||||
}
|
||||
Err(format!(
|
||||
"AppData 配置目录 owner 不匹配,但无法找到不冲突的备份路径;请手动保留并恢复 {}",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_game_creator_runtime_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||||
resolve_game_creator_runtime_config_dir(path, true)
|
||||
}
|
||||
@@ -480,6 +618,28 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
||||
path: &Path,
|
||||
is_directory: bool,
|
||||
tighten: bool,
|
||||
) -> Result<(), String> {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
path,
|
||||
is_directory,
|
||||
tighten,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user(
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
path: &Path,
|
||||
is_directory: bool,
|
||||
tighten: bool,
|
||||
initialize_owner: bool,
|
||||
) -> Result<(), String> {
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
@@ -661,6 +821,41 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
let mut initial_owner = std::ptr::null_mut();
|
||||
let mut initial_descriptor = std::ptr::null_mut();
|
||||
// 先验证 owner,再修改 DACL,避免对其他用户持有的旧配置做任何权限变更。
|
||||
let owner_status = unsafe {
|
||||
GetNamedSecurityInfoW(
|
||||
wide_path.as_mut_ptr(),
|
||||
SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION,
|
||||
&mut initial_owner,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut initial_descriptor,
|
||||
)
|
||||
};
|
||||
if owner_status != 0 || initial_owner.is_null() || initial_descriptor.is_null() {
|
||||
if !initial_descriptor.is_null() {
|
||||
unsafe { LocalFree(initial_descriptor) };
|
||||
}
|
||||
return Err(format!(
|
||||
"读取 Windows owner 失败:{}: error {owner_status}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let owner_matches = unsafe { IsValidSid(initial_owner) } != 0
|
||||
&& unsafe { EqualSid(initial_owner, current_user_sid) } != 0;
|
||||
unsafe { LocalFree(initial_descriptor) };
|
||||
if !owner_matches {
|
||||
if !(initialize_owner && tighten) {
|
||||
return Err(format!(
|
||||
"Windows 安全对象不属于当前用户:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
if tighten {
|
||||
let mut entry = ExplicitAccessW {
|
||||
access_permissions: FILE_ALL_ACCESS,
|
||||
@@ -693,8 +888,18 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
||||
SetNamedSecurityInfoW(
|
||||
wide_path.as_mut_ptr(),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
DACL_SECURITY_INFORMATION
|
||||
| PROTECTED_DACL_SECURITY_INFORMATION
|
||||
| if initialize_owner {
|
||||
OWNER_SECURITY_INFORMATION
|
||||
} else {
|
||||
0
|
||||
},
|
||||
if initialize_owner {
|
||||
current_user_sid
|
||||
} else {
|
||||
std::ptr::null_mut()
|
||||
},
|
||||
std::ptr::null_mut(),
|
||||
private_dacl,
|
||||
std::ptr::null_mut(),
|
||||
@@ -704,7 +909,7 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
||||
unsafe { LocalFree(private_dacl) };
|
||||
if set_status != 0 {
|
||||
return Err(format!(
|
||||
"收紧 Windows 当前用户私有 DACL 失败:{}: error {set_status}",
|
||||
"初始化 Windows 当前用户 owner/私有 DACL 失败:{}: error {set_status}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2263,6 +2263,7 @@ fn build_sandboxed_git_command(
|
||||
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
||||
let sandbox = context.sandbox.path();
|
||||
let mut command = Command::new(&context.executable);
|
||||
crate::configure_windows_background_std_command(&mut command, false);
|
||||
command.env_clear();
|
||||
for key in ["SystemRoot", "WINDIR", "PATHEXT"] {
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -631,6 +631,7 @@ fn apply_game_creator_mcp_platform_environment(command: &mut tokio::process::Com
|
||||
command.env(name, value);
|
||||
}
|
||||
}
|
||||
crate::configure_windows_background_tokio_command(command, false);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user