合并最新 AI 游戏创作 App 分支
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"dev-server": "node scripts/start-dev-server.mjs",
|
"dev-server": "node scripts/start-dev-server.mjs",
|
||||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||||
"build": "npm --prefix ../.. exec tauri -- build",
|
"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",
|
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||||
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
||||||
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
|
"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',
|
'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(
|
const eventCapabilityPath = new URL(
|
||||||
'../src-tauri/capabilities/events.json',
|
'../src-tauri/capabilities/events.json',
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
@@ -57,6 +71,14 @@ const appEntrypointSource = fs.readFileSync(
|
|||||||
new URL('../src/main.tsx', import.meta.url),
|
new URL('../src/main.tsx', import.meta.url),
|
||||||
'utf8',
|
'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(
|
const tauriHandlerSource = fs.readFileSync(
|
||||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -414,6 +436,28 @@ async function runConfigWizardRegressionChecks() {
|
|||||||
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
|
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
|
||||||
);
|
);
|
||||||
try {
|
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 gitRoot = path.join(testRoot, 'tracked-repository');
|
||||||
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
|
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
|
||||||
fs.mkdirSync(trackedConfigDir, { recursive: true });
|
fs.mkdirSync(trackedConfigDir, { recursive: true });
|
||||||
@@ -441,7 +485,7 @@ async function runConfigWizardRegressionChecks() {
|
|||||||
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
|
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
|
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
|
||||||
outsideConfigDir,
|
path.join(canonicalTestRoot, 'outside-appdata'),
|
||||||
);
|
);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
|
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
|
||||||
@@ -454,7 +498,7 @@ async function runConfigWizardRegressionChecks() {
|
|||||||
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
|
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
|
||||||
requireDedicatedLeaf: true,
|
requireDedicatedLeaf: true,
|
||||||
}),
|
}),
|
||||||
dedicatedConfigDir,
|
path.join(canonicalTestRoot, appIdentifier),
|
||||||
);
|
);
|
||||||
|
|
||||||
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
|
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',
|
'fatal: not a git repository (or any of the parent directories): .git\n',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
injectedNonGitConfigDir,
|
path.join(canonicalTestRoot, 'injected-non-git'),
|
||||||
);
|
);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
assertSafeGameCreatorConfigDestination(
|
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 (
|
if (
|
||||||
packageConfig.scripts?.['agent-run'] !==
|
packageConfig.scripts?.['agent-run'] !==
|
||||||
'node scripts/run-cli-with-config.mjs --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(',
|
'fn apply_game_chat_initial_window_url(',
|
||||||
'.find(|window| window.label == "client")',
|
'.find(|window| window.label == "client")',
|
||||||
'client.url = game_chat_window_url(',
|
'client.url = game_chat_window_url(',
|
||||||
'.build(tauri_context)',
|
|
||||||
'app.run(|_, event| handle_game_creator_gui_run_event(&event))',
|
|
||||||
]) {
|
]) {
|
||||||
if (
|
if (
|
||||||
!`${tauriHandlerSource}\n${tauriWindowSource}`.includes(requiredSnippet)
|
!`${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 (
|
if (
|
||||||
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
|
!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(
|
const devServerSource = fs.readFileSync(
|
||||||
new URL('../scripts/start-dev-server.mjs', import.meta.url),
|
new URL('../scripts/start-dev-server.mjs', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -1380,7 +1514,6 @@ for (const snippet of [
|
|||||||
'fn merge_game_creator_config_file(',
|
'fn merge_game_creator_config_file(',
|
||||||
'.join("apps")',
|
'.join("apps")',
|
||||||
'.join("ai-game-creator-shell")',
|
'.join("ai-game-creator-shell")',
|
||||||
'configure_game_creator_runtime_config_dir(app.handle())?',
|
|
||||||
'read_game_creator_app_config,',
|
'read_game_creator_app_config,',
|
||||||
'write_game_creator_app_config,',
|
'write_game_creator_app_config,',
|
||||||
'resolve_game_creator_llm_config_for_agent(app_config, "planner")',
|
'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 [
|
for (const snippet of [
|
||||||
'import.meta.env.DEV',
|
'import.meta.env.DEV',
|
||||||
'#[cfg(all(debug_assertions, not(test)))]',
|
'#[cfg(all(debug_assertions, not(test)))]',
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
game-chat-release = []
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.6.2", features = [] }
|
tauri-build = { version = "2.6.2", features = [] }
|
||||||
|
|
||||||
@@ -39,4 +43,4 @@ tauri-plugin-clipboard-manager = "2.3.2"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[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(
|
pub(crate) fn game_creator_agent_llm_error_public_summary(
|
||||||
error: &platform_llm::LlmError,
|
error: &platform_llm::LlmError,
|
||||||
) -> String {
|
) -> String {
|
||||||
let kind = match error {
|
let (kind, http_status) = match error {
|
||||||
platform_llm::LlmError::Timeout { .. } => "timeout".to_string(),
|
platform_llm::LlmError::Timeout { .. } => ("timeout".to_string(), None),
|
||||||
platform_llm::LlmError::Connectivity { .. } => "connectivity".to_string(),
|
platform_llm::LlmError::Connectivity { .. } => ("connectivity".to_string(), None),
|
||||||
platform_llm::LlmError::Transport(_) => "transport".to_string(),
|
platform_llm::LlmError::Transport(_) => ("transport".to_string(), None),
|
||||||
platform_llm::LlmError::Upstream { status_code, .. } => {
|
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::InvalidConfig(_) => ("invalid-config".to_string(), None),
|
||||||
platform_llm::LlmError::InvalidRequest(_) => "invalid-request".to_string(),
|
platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None),
|
||||||
platform_llm::LlmError::StreamUnavailable => "stream-unavailable".to_string(),
|
platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None),
|
||||||
platform_llm::LlmError::EmptyResponse => "empty-response".to_string(),
|
platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None),
|
||||||
platform_llm::LlmError::Deserialize(_) => "deserialize".to_string(),
|
platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None),
|
||||||
};
|
};
|
||||||
let raw = error.to_string();
|
let raw = error.to_string();
|
||||||
|
let http_status = http_status
|
||||||
|
.map(|status| format!(" httpStatus={status}"))
|
||||||
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
"kind={kind} fingerprint={:x} chars={}",
|
"kind={kind}{http_status} fingerprint={:x} chars={}",
|
||||||
Sha256::digest(raw.as_bytes()),
|
Sha256::digest(raw.as_bytes()),
|
||||||
raw.chars().count()
|
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 structured_plan::*;
|
||||||
pub(in crate::agent) use tool_plan_protocol::*;
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test;
|
pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test;
|
||||||
pub(crate) use action_audit::{
|
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(
|
fn agent_runtime_action_receipt_safe_detail_with_owner(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
receipt_owner: Option<(&str, &str)>,
|
receipt_owner: Option<(&str, &str)>,
|
||||||
@@ -428,16 +436,27 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
|
|||||||
{
|
{
|
||||||
return None;
|
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!({
|
return serde_json::to_string(&serde_json::json!({
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
"reportPath": report_path,
|
"reportPath": report_path,
|
||||||
"screenshots": screenshots,
|
"screenshots": screenshots,
|
||||||
"diagnosticsCount": detail
|
"diagnosticsCount": diagnostics_count,
|
||||||
.get("diagnostics")
|
|
||||||
.and_then(serde_json::Value::as_array)
|
|
||||||
.map(Vec::len)
|
|
||||||
.unwrap_or(0),
|
|
||||||
"playtestPassed": playtest_passed,
|
"playtestPassed": playtest_passed,
|
||||||
"playtestScenario": playtest_scenario,
|
"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() {
|
if let Some(retry) = waiting_provider_retry.as_ref() {
|
||||||
state.status = "running".to_string();
|
state.status = "running".to_string();
|
||||||
state.phase = "waiting-for-provider-retry".to_string();
|
state.phase = "waiting-for-provider-retry".to_string();
|
||||||
state.current_action = format!(
|
let (current_action, waiting_on) =
|
||||||
"Goal 已恢复,继续等待 Provider 瞬态重试 {}/{}",
|
game_creator_agent_runtime_provider_retry_waiting_presentation(retry);
|
||||||
retry.next_attempt, retry.max_retries
|
state.current_action = format!("Goal 已恢复,{current_action}");
|
||||||
);
|
state.waiting_on = waiting_on;
|
||||||
state.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind);
|
|
||||||
state.next_step = "到期后恢复同一 Session/run/loop 和 retry attempt".to_string();
|
state.next_step = "到期后恢复同一 Session/run/loop 和 retry attempt".to_string();
|
||||||
} else if pending_action.as_ref().is_some_and(|pending| {
|
} else if pending_action.as_ref().is_some_and(|pending| {
|
||||||
pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
|
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),
|
Some(&session_id),
|
||||||
LocalConversationMessage {
|
LocalConversationMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: format!("后台任务失败:{error}"),
|
content: game_creator_agent_runtime_failure_conversation_message(
|
||||||
|
&agent_id, &error,
|
||||||
|
),
|
||||||
agent_id: None,
|
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),
|
Some(&session_id),
|
||||||
LocalConversationMessage {
|
LocalConversationMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: format!("后台任务失败:{error}"),
|
content: game_creator_agent_runtime_failure_conversation_message(
|
||||||
|
&agent_id, &error,
|
||||||
|
),
|
||||||
agent_id: None,
|
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),
|
Some(&session_id),
|
||||||
LocalConversationMessage {
|
LocalConversationMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: format!("后台任务失败:{error}"),
|
content: game_creator_agent_runtime_failure_conversation_message(
|
||||||
|
&agent_id, &error,
|
||||||
|
),
|
||||||
agent_id: None,
|
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),
|
Some(&session_id),
|
||||||
LocalConversationMessage {
|
LocalConversationMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: format!("后台任务失败:{error}"),
|
content: game_creator_agent_runtime_failure_conversation_message(
|
||||||
|
&agent_id, &error,
|
||||||
|
),
|
||||||
agent_id: None,
|
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),
|
Some(&session_id),
|
||||||
LocalConversationMessage {
|
LocalConversationMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: format!("后台任务失败:{error}"),
|
content: game_creator_agent_runtime_failure_conversation_message(
|
||||||
|
&agent_id, &error,
|
||||||
|
),
|
||||||
agent_id: None,
|
agent_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -190,10 +190,14 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
|
|||||||
final_sequence: Some(8),
|
final_sequence: Some(8),
|
||||||
final_phase: Some(BrowserPlaytestPhase::Playing),
|
final_phase: Some(BrowserPlaytestPhase::Playing),
|
||||||
final_level: Some(2),
|
final_level: Some(2),
|
||||||
assertions: vec![BrowserPlaytestAssertion {
|
assertions: scenario
|
||||||
name: "fixture-passed".to_string(),
|
.assertion_names()
|
||||||
passed: true,
|
.iter()
|
||||||
}],
|
.map(|name| BrowserPlaytestAssertion {
|
||||||
|
name: (*name).to_string(),
|
||||||
|
passed: true,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}),
|
}),
|
||||||
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.status = "running".to_string();
|
||||||
runtime.phase = "waiting-for-provider-retry".to_string();
|
runtime.phase = "waiting-for-provider-retry".to_string();
|
||||||
runtime.current_action = format!(
|
let (current_action, waiting_on) =
|
||||||
"等待 Provider 瞬态重试 {}/{}",
|
game_creator_agent_runtime_provider_retry_waiting_presentation(retry);
|
||||||
retry.next_attempt, retry.max_retries
|
runtime.current_action = current_action;
|
||||||
);
|
runtime.waiting_on = waiting_on;
|
||||||
runtime.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind);
|
|
||||||
runtime.next_step = "到期后自动恢复同一 Session/run/loop,并重新检查控制状态".to_string();
|
runtime.next_step = "到期后自动恢复同一 Session/run/loop,并重新检查控制状态".to_string();
|
||||||
runtime.error = None;
|
runtime.error = None;
|
||||||
runtime.updated_at = unix_timestamp();
|
runtime.updated_at = unix_timestamp();
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ pub(in crate::agent) fn fail_game_creator_agent_background_context_at(
|
|||||||
Some(session_id),
|
Some(session_id),
|
||||||
LocalConversationMessage {
|
LocalConversationMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: format!("后台任务失败:{error}"),
|
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
|
||||||
agent_id: None,
|
agent_id: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-5
@@ -664,8 +664,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
|
|||||||
BrowserPlaytestScenario::GenericV1 => {
|
BrowserPlaytestScenario::GenericV1 => {
|
||||||
concat!(
|
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;",
|
"完成合同要求 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,同一固定值不得出现在多个控件上。",
|
"界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实可点击控件;primary-action 必须映射游戏的真实主要玩法操作,并在动作发生时推进 state sequence,不能使用空操作或仅更新装饰 UI 的按钮;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
|
||||||
"start 后状态必须推进并进入 playing 或 won,restart 后必须再次推进并回到 ready 或 playing。"
|
"初始状态必须是 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 => {
|
BrowserPlaytestScenario::LaneDefenseV1 => {
|
||||||
@@ -921,7 +921,7 @@ pub(in crate::agent) fn validate_autonomous_evidence_digest(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(in crate::agent) fn validate_autonomous_playtest_receipt(
|
fn validate_autonomous_playtest_receipt_integrity(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
contract: &AgentRuntimeAutonomousCompletionContract,
|
contract: &AgentRuntimeAutonomousCompletionContract,
|
||||||
receipt: &AgentRuntimeAutonomousPlaytestReceipt,
|
receipt: &AgentRuntimeAutonomousPlaytestReceipt,
|
||||||
@@ -992,6 +992,20 @@ pub(in crate::agent) fn validate_autonomous_playtest_receipt(
|
|||||||
Ok(())
|
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(
|
pub(in crate::agent) fn read_autonomous_playtest_receipt(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
contract: &AgentRuntimeAutonomousCompletionContract,
|
contract: &AgentRuntimeAutonomousCompletionContract,
|
||||||
@@ -1004,7 +1018,12 @@ pub(in crate::agent) fn read_autonomous_playtest_receipt(
|
|||||||
"自主试玩回执",
|
"自主试玩回执",
|
||||||
)?;
|
)?;
|
||||||
if let Some(receipt) = receipt.as_ref() {
|
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)
|
Ok(receipt)
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1062,7 @@ pub(in crate::agent) fn verify_autonomous_playtest_evidence_files_at(
|
|||||||
.ok_or_else(|| "自主试玩浏览器报告缺少交互试玩结果".to_string())?;
|
.ok_or_else(|| "自主试玩浏览器报告缺少交互试玩结果".to_string())?;
|
||||||
if !report.passed
|
if !report.passed
|
||||||
|| !playtest.passed
|
|| !playtest.passed
|
||||||
|
|| !playtest.matches_scenario_contract()
|
||||||
|| playtest.scenario != receipt.playtest_scenario
|
|| playtest.scenario != receipt.playtest_scenario
|
||||||
|| playtest.scenario_fingerprint != receipt.scenario_fingerprint
|
|| playtest.scenario_fingerprint != receipt.scenario_fingerprint
|
||||||
{
|
{
|
||||||
@@ -1069,10 +1089,13 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at(
|
|||||||
.playtest
|
.playtest
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "自主构建 preview.validate 缺少交互试玩结果".to_string())?;
|
.ok_or_else(|| "自主构建 preview.validate 缺少交互试玩结果".to_string())?;
|
||||||
|
let expected_scenario_fingerprint =
|
||||||
|
browser_playtest_scenario_fingerprint(contract.playtest_scenario);
|
||||||
if !result.passed
|
if !result.passed
|
||||||
|| !playtest.passed
|
|| !playtest.passed
|
||||||
|
|| !playtest.matches_scenario_contract()
|
||||||
|| playtest.scenario != contract.playtest_scenario
|
|| playtest.scenario != contract.playtest_scenario
|
||||||
|| !is_lowercase_sha256(&playtest.scenario_fingerprint)
|
|| playtest.scenario_fingerprint != expected_scenario_fingerprint
|
||||||
{
|
{
|
||||||
return Err("自主构建交互试玩未通过或场景身份不匹配".to_string());
|
return Err("自主构建交互试玩未通过或场景身份不匹配".to_string());
|
||||||
}
|
}
|
||||||
@@ -1102,6 +1125,7 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at(
|
|||||||
.ok_or_else(|| "持久浏览器报告缺少交互试玩结果".to_string())?;
|
.ok_or_else(|| "持久浏览器报告缺少交互试玩结果".to_string())?;
|
||||||
if !persisted_report.passed
|
if !persisted_report.passed
|
||||||
|| !persisted_playtest.passed
|
|| !persisted_playtest.passed
|
||||||
|
|| !persisted_playtest.matches_scenario_contract()
|
||||||
|| persisted_playtest.scenario != playtest.scenario
|
|| persisted_playtest.scenario != playtest.scenario
|
||||||
|| persisted_playtest.scenario_fingerprint != playtest.scenario_fingerprint
|
|| persisted_playtest.scenario_fingerprint != playtest.scenario_fingerprint
|
||||||
{
|
{
|
||||||
|
|||||||
+236
-4
@@ -288,10 +288,14 @@ fn browser_result_fixture(
|
|||||||
final_sequence: Some(8),
|
final_sequence: Some(8),
|
||||||
final_phase: Some(BrowserPlaytestPhase::Playing),
|
final_phase: Some(BrowserPlaytestPhase::Playing),
|
||||||
final_level: Some(2),
|
final_level: Some(2),
|
||||||
assertions: vec![BrowserPlaytestAssertion {
|
assertions: scenario
|
||||||
name: "fixture-passed".to_string(),
|
.assertion_names()
|
||||||
passed: true,
|
.iter()
|
||||||
}],
|
.map(|name| BrowserPlaytestAssertion {
|
||||||
|
name: (*name).to_string(),
|
||||||
|
passed: true,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}),
|
}),
|
||||||
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]
|
#[test]
|
||||||
fn delegated_autonomous_agent_inherits_parent_playtest_contract() {
|
fn delegated_autonomous_agent_inherits_parent_playtest_contract() {
|
||||||
let (_temporary, root, parent_state, _contract) = autonomous_fixture(
|
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,
|
error,
|
||||||
retry_autonomous_upstream_400,
|
retry_autonomous_upstream_400,
|
||||||
) {
|
) {
|
||||||
Some(kind) => {
|
Some(transient_kind) => {
|
||||||
format!("{AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX}{kind}\n{public_error}")
|
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,
|
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(
|
pub(in crate::agent) fn append_game_creator_agent_runtime_provider_retry_audit(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
provider_snapshot: &AgentRuntimeProviderRequestSnapshot,
|
provider_snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||||
@@ -1097,7 +1215,11 @@ where
|
|||||||
&provider_snapshot.agent_id,
|
&provider_snapshot.agent_id,
|
||||||
&provider_snapshot.run_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(
|
let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||||
root,
|
root,
|
||||||
@@ -1232,7 +1354,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
|
|||||||
max_retries
|
max_retries
|
||||||
};
|
};
|
||||||
if attempt >= error_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 retry_attempt = attempt.saturating_add(1);
|
||||||
let backoff_ms = append_game_creator_agent_runtime_provider_retry_audit(
|
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;
|
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;
|
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||||
let path = self.path.join(name);
|
let path = self.path.join(name);
|
||||||
let file = fs::OpenOptions::new()
|
let file = fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.create_new(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)
|
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.map_err(|_| ())?;
|
.map_err(|_| ())?;
|
||||||
secure_windows_game_creator_path_for_current_user(&path, false, true)
|
let secured = (|| {
|
||||||
.map_err(|_| ())?;
|
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);
|
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 {
|
fn game_creator_agent_runtime_public_failure_detail(agent_id: &str, error: &str) -> String {
|
||||||
let (error_sha256, error_chars) = game_creator_agent_runtime_failure_metadata(error);
|
game_creator_agent_runtime_failure_conversation_message(agent_id, error)
|
||||||
format!("errorSha256={error_sha256} · errorChars={error_chars}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn append_game_creator_agent_background_task_failed_audit(
|
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,
|
event_type,
|
||||||
"error" | "turn.failed" | "turn.budget_exhausted"
|
"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"
|
let max_chars = if event_type == "observation"
|
||||||
&& summary.starts_with("agent.action_history:")
|
&& 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 generic;
|
||||||
mod lane_defense;
|
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) use lane_defense::{lane_enemy_state_changes, LaneBattleProgress};
|
||||||
|
|
||||||
pub(super) const PLAYABLE_GAME_STATE_SCHEMA_VERSION: &str = "playable-web-game-state.v1";
|
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] = &[
|
const GENERIC_PLAYTEST_ASSERTIONS: &[&str] = &[
|
||||||
"state-surface-valid",
|
"state-surface-valid",
|
||||||
|
"initial-phase-ready",
|
||||||
|
"level-positive",
|
||||||
"start-control-clicked",
|
"start-control-clicked",
|
||||||
"start-sequence-advanced",
|
"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-control-clicked",
|
||||||
"restart-sequence-advanced",
|
"restart-sequence-advanced",
|
||||||
"restart-phase-ready-or-playing",
|
"restart-phase-ready-or-playing",
|
||||||
|
"restart-phase-stable-ready-or-playing",
|
||||||
];
|
];
|
||||||
|
|
||||||
const LANE_DEFENSE_PLAYTEST_ASSERTIONS: &[&str] = &[
|
const LANE_DEFENSE_PLAYTEST_ASSERTIONS: &[&str] = &[
|
||||||
@@ -111,7 +130,7 @@ pub(super) struct PlaytestPollOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BrowserPlaytestScenario {
|
impl BrowserPlaytestScenario {
|
||||||
pub(super) fn assertion_names(self) -> &'static [&'static str] {
|
pub(crate) fn assertion_names(self) -> &'static [&'static str] {
|
||||||
match self {
|
match self {
|
||||||
Self::GenericV1 => GENERIC_PLAYTEST_ASSERTIONS,
|
Self::GenericV1 => GENERIC_PLAYTEST_ASSERTIONS,
|
||||||
Self::LaneDefenseV1 => LANE_DEFENSE_PLAYTEST_ASSERTIONS,
|
Self::LaneDefenseV1 => LANE_DEFENSE_PLAYTEST_ASSERTIONS,
|
||||||
@@ -191,9 +210,20 @@ impl BrowserPlaytestResult {
|
|||||||
failed_assertions.join("、")
|
failed_assertions.join("、")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.passed = browser_playtest_assertions_passed(&self.assertions, &self.diagnostics);
|
self.passed = self.matches_scenario_contract();
|
||||||
self
|
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(
|
pub(super) fn browser_playtest_assertions_passed(
|
||||||
@@ -241,7 +271,44 @@ pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestSce
|
|||||||
match scenario {
|
match scenario {
|
||||||
BrowserPlaytestScenario::GenericV1 => {
|
BrowserPlaytestScenario::GenericV1 => {
|
||||||
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR);
|
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, 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 => {
|
BrowserPlaytestScenario::LaneDefenseV1 => {
|
||||||
for selector in [
|
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_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_RESTART_SELECTOR: &str = r#"[data-playtest-id="restart"]"#;
|
||||||
pub(super) const PLAYTEST_DEFENDER_OPTION_SELECTOR: &str =
|
pub(super) const PLAYTEST_DEFENDER_OPTION_SELECTOR: &str =
|
||||||
r#"[data-playtest-id="defender-option"]"#;
|
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)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
crate::configure_windows_background_tokio_command(command, true);
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
|
||||||
command
|
|
||||||
.as_std_mut()
|
|
||||||
.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1501,13 +1497,16 @@ async fn request_project_command_process_group_termination(
|
|||||||
if !taskkill.is_absolute() || !taskkill.is_file() {
|
if !taskkill.is_absolute() || !taskkill.is_file() {
|
||||||
return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string());
|
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"])
|
.args(["/PID", &process_id.to_string(), "/T", "/F"])
|
||||||
.env_clear()
|
.env_clear()
|
||||||
.env("SystemRoot", &system_root)
|
.env("SystemRoot", &system_root)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null());
|
||||||
|
crate::configure_windows_background_tokio_command(&mut command, false);
|
||||||
|
let status = command
|
||||||
.status()
|
.status()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?;
|
.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(
|
fn validate_game_creator_runtime_config_dir_metadata(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
tighten: bool,
|
tighten: bool,
|
||||||
|
initialize_windows_owner: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||||
format!(
|
format!(
|
||||||
@@ -382,6 +383,7 @@ fn validate_game_creator_runtime_config_dir_metadata(
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||||
|
let _ = initialize_windows_owner;
|
||||||
|
|
||||||
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
|
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
|
||||||
let effective_user_id = unsafe { libc::geteuid() };
|
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 {
|
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||||
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
|
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)))]
|
#[cfg(not(any(unix, windows)))]
|
||||||
@@ -437,24 +444,155 @@ fn resolve_game_creator_runtime_config_dir(
|
|||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return Err("客户端 AppData 配置目录必须是绝对路径".to_string());
|
return Err("客户端 AppData 配置目录必须是绝对路径".to_string());
|
||||||
}
|
}
|
||||||
|
let mut created = false;
|
||||||
if create_and_tighten {
|
if create_and_tighten {
|
||||||
fs::create_dir_all(path).map_err(|error| {
|
match fs::symlink_metadata(path) {
|
||||||
format!(
|
Ok(_) => {}
|
||||||
"创建客户端 AppData 配置目录失败:{}: {error}",
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
path.display()
|
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| {
|
let canonical = fs::canonicalize(path).map_err(|error| {
|
||||||
format!(
|
format!(
|
||||||
"解析客户端 AppData 配置目录失败:{}: {error}",
|
"解析客户端 AppData 配置目录失败:{}: {error}",
|
||||||
path.display()
|
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)
|
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> {
|
pub(crate) fn prepare_game_creator_runtime_config_dir(path: &Path) -> Result<PathBuf, String> {
|
||||||
resolve_game_creator_runtime_config_dir(path, true)
|
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,
|
path: &Path,
|
||||||
is_directory: bool,
|
is_directory: bool,
|
||||||
tighten: 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> {
|
) -> Result<(), String> {
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
@@ -661,6 +821,41 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
|||||||
.encode_wide()
|
.encode_wide()
|
||||||
.chain(std::iter::once(0))
|
.chain(std::iter::once(0))
|
||||||
.collect::<Vec<_>>();
|
.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 {
|
if tighten {
|
||||||
let mut entry = ExplicitAccessW {
|
let mut entry = ExplicitAccessW {
|
||||||
access_permissions: FILE_ALL_ACCESS,
|
access_permissions: FILE_ALL_ACCESS,
|
||||||
@@ -693,8 +888,18 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
|||||||
SetNamedSecurityInfoW(
|
SetNamedSecurityInfoW(
|
||||||
wide_path.as_mut_ptr(),
|
wide_path.as_mut_ptr(),
|
||||||
SE_FILE_OBJECT,
|
SE_FILE_OBJECT,
|
||||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
DACL_SECURITY_INFORMATION
|
||||||
std::ptr::null_mut(),
|
| 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(),
|
std::ptr::null_mut(),
|
||||||
private_dacl,
|
private_dacl,
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
@@ -704,7 +909,7 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
|
|||||||
unsafe { LocalFree(private_dacl) };
|
unsafe { LocalFree(private_dacl) };
|
||||||
if set_status != 0 {
|
if set_status != 0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"收紧 Windows 当前用户私有 DACL 失败:{}: error {set_status}",
|
"初始化 Windows 当前用户 owner/私有 DACL 失败:{}: error {set_status}",
|
||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2263,6 +2263,7 @@ fn build_sandboxed_git_command(
|
|||||||
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
|
||||||
let sandbox = context.sandbox.path();
|
let sandbox = context.sandbox.path();
|
||||||
let mut command = Command::new(&context.executable);
|
let mut command = Command::new(&context.executable);
|
||||||
|
crate::configure_windows_background_std_command(&mut command, false);
|
||||||
command.env_clear();
|
command.env_clear();
|
||||||
for key in ["SystemRoot", "WINDIR", "PATHEXT"] {
|
for key in ["SystemRoot", "WINDIR", "PATHEXT"] {
|
||||||
if let Some(value) = std::env::var_os(key) {
|
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);
|
command.env(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::configure_windows_background_tokio_command(command, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[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