合并远程 master 最新更新
合入游戏聊天 0.1.1、AGC 资源画布布局与跨平台运行修复。 合入画板素材上传后的私有图片预览修复。 保留历史派生图层模型恢复与抠图契约更新。
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',
|
||||
@@ -414,6 +436,28 @@ async function runConfigWizardRegressionChecks() {
|
||||
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
|
||||
);
|
||||
try {
|
||||
const canonicalTestRoot = fs.realpathSync.native(testRoot);
|
||||
const realConfigAncestor = path.join(testRoot, 'real-config-ancestor');
|
||||
const linkedConfigAncestor = path.join(testRoot, 'linked-config-ancestor');
|
||||
fs.mkdirSync(realConfigAncestor);
|
||||
fs.symlinkSync(
|
||||
realConfigAncestor,
|
||||
linkedConfigAncestor,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const missingLinkedConfigDir = path.join(
|
||||
linkedConfigAncestor,
|
||||
'missing-appdata',
|
||||
);
|
||||
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
|
||||
assert.equal(
|
||||
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
|
||||
path.join(
|
||||
fs.realpathSync.native(realConfigAncestor),
|
||||
'missing-appdata',
|
||||
),
|
||||
);
|
||||
|
||||
const gitRoot = path.join(testRoot, 'tracked-repository');
|
||||
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
|
||||
fs.mkdirSync(trackedConfigDir, { recursive: true });
|
||||
@@ -441,7 +485,7 @@ async function runConfigWizardRegressionChecks() {
|
||||
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
|
||||
assert.equal(
|
||||
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
|
||||
outsideConfigDir,
|
||||
path.join(canonicalTestRoot, 'outside-appdata'),
|
||||
);
|
||||
await assert.rejects(
|
||||
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
|
||||
@@ -454,7 +498,7 @@ async function runConfigWizardRegressionChecks() {
|
||||
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
|
||||
requireDedicatedLeaf: true,
|
||||
}),
|
||||
dedicatedConfigDir,
|
||||
path.join(canonicalTestRoot, appIdentifier),
|
||||
);
|
||||
|
||||
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
|
||||
@@ -467,7 +511,7 @@ async function runConfigWizardRegressionChecks() {
|
||||
'fatal: not a git repository (or any of the parent directories): .git\n',
|
||||
}),
|
||||
}),
|
||||
injectedNonGitConfigDir,
|
||||
path.join(canonicalTestRoot, 'injected-non-git'),
|
||||
);
|
||||
await assert.rejects(
|
||||
assertSafeGameCreatorConfigDestination(
|
||||
@@ -916,6 +960,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 +1338,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 +1348,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 +1365,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 +1514,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 +1533,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}"))?;
|
||||
|
||||
@@ -196,6 +196,31 @@ pub(crate) fn get_local_game_manifest(
|
||||
read_manifest_for_project(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_resource_canvas_layout(
|
||||
project_path: String,
|
||||
mode: ProjectResourceCanvasLayoutMode,
|
||||
) -> Result<ProjectResourceCanvasLayout, String> {
|
||||
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
mode: ProjectResourceCanvasLayoutMode,
|
||||
expected_revision: u64,
|
||||
positions: Vec<ProjectResourceCanvasPosition>,
|
||||
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
|
||||
update_project_resource_canvas_layout_at(
|
||||
Path::new(project_path.trim()),
|
||||
mode,
|
||||
&expected_project_id,
|
||||
expected_revision,
|
||||
positions,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn control_agent_run(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user