Merge branch 'master' into style/agc-project-page
Project CI / Repository checks (pull_request) Successful in 3m12s
Project CI / Frontend tests (pull_request) Successful in 4m1s
Project CI / Backend tests (pull_request) Successful in 8m9s
Project CI / Native shell tests (pull_request) Successful in 16m53s

This commit is contained in:
2026-08-31 18:58:37 +08:00
146 changed files with 12817 additions and 3103 deletions
+1
View File
@@ -27,6 +27,7 @@
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
1. 不写历史兼容代码。
2. 不写用于维持退役行为的防御性兼容测试。
@@ -736,7 +736,7 @@ function isAdminImageSequenceFrameUrlUsable(
): cached is AdminImageSequenceFrameCacheEntry {
return Boolean(
cached?.resolvedUrl &&
(cached.expiresAtMs === null || cached.expiresAtMs > Date.now()),
(cached.expiresAtMs === null || cached.expiresAtMs > Date.now()),
);
}
@@ -26,8 +26,7 @@ const wrapperSuite =
const configFileName = 'game-creator.config.json';
const configSentinelName = '.deterministic-provider-e2e.json';
const configSentinelSchema = 'genarrative-deterministic-provider-e2e-config.v1';
const platformSessionFixtureEnv =
'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE';
const platformSessionFixtureEnv = 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE';
const platformSessionFixtureName = '.deterministic-platform-session.json';
const platformSessionFixtureSchema =
'genarrative-agc-platform-session-fixture.v1';
@@ -100,8 +100,7 @@ import { appendBounded, runProcess } from './process.mjs';
import { decodeUtf8Fatal, isIsolatedRunnerSuite } from './reporting.mjs';
import { killRunnerOnce, readRunnerStatus, runnerBootId } from './runtime.mjs';
const platformSessionFixtureEnv =
'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE';
const platformSessionFixtureEnv = 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE';
const platformSessionFixtureMaxBytes = 16 * 1024;
const isolatedPlatformSessionFixtureName =
'.deterministic-platform-session.json';
@@ -538,7 +537,9 @@ async function readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir) {
);
let fixture;
try {
fixture = JSON.parse(decodeUtf8Fatal(bytes, 'platform-session-fixture-invalid-utf8'));
fixture = JSON.parse(
decodeUtf8Fatal(bytes, 'platform-session-fixture-invalid-utf8'),
);
} catch (error) {
throw codedError(
'supervisor-autonomous-playable-platform-session-fixture-invalid',
@@ -577,8 +578,12 @@ async function installPlatformSessionFixtureIntoIsolatedAppData(
appDataDir,
) {
if (!isSupervisorAutonomousPlayableLaneDefenseSuite()) return;
const source = await readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir);
const isolatedPath = path.join(appDataDir, isolatedPlatformSessionFixtureName);
const source =
await readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir);
const isolatedPath = path.join(
appDataDir,
isolatedPlatformSessionFixtureName,
);
await fs.copyFile(
source.sourcePath,
isolatedPath,
@@ -609,9 +614,7 @@ async function installPlatformSessionFixtureIntoIsolatedAppData(
absolutePathVariants(source.sourcePath, isolatedPath),
);
const previousLeakCount = state.transcriptScanner?.count ?? 0;
state.secrets = [
...new Set([...state.secrets, source.fixture.accessToken]),
];
state.secrets = [...new Set([...state.secrets, source.fixture.accessToken])];
rebuildSupervisorSwarmTranscriptScanner();
state.transcriptScanner.count = previousLeakCount;
}
@@ -990,7 +990,10 @@ export function createDeterministicLaneDefenseRouter({
}
function recordReadyTaskRun(agentId, runId) {
if (!relaxedAutonomous && !deterministicManifestReadyAgentIds.includes(agentId)) {
if (
!relaxedAutonomous &&
!deterministicManifestReadyAgentIds.includes(agentId)
) {
throw providerError(`provider-ready-agent-unsupported:${agentId}`);
}
const existingRunId = readyTaskRunIdsByAgent.get(agentId);
@@ -1405,14 +1408,10 @@ export function createDeterministicLaneDefenseRouter({
]);
}
if (tools.has(runtimeFunction('task.list'))) {
const calls = [
nativeAction('task.list', '查看并行任务当前状态', {}),
];
const calls = [nativeAction('task.list', '查看并行任务当前状态', {})];
if (tools.has(runtimeFunction('agent.run_status'))) {
stats.runStatusCount += 1;
calls.push(
runStatusCall('读取并行任务的最新运行状态'),
);
calls.push(runStatusCall('读取并行任务的最新运行状态'));
}
return callsResponse('project-supervisor', tools, calls);
}
@@ -1751,7 +1750,11 @@ export function createDeterministicLaneDefenseRouter({
stats.sourceWriteCount += 1;
stats.manifestReadyTaskFileWriteCount += 1;
return readyCallsResponse(agentId, runId, tools, [
fileWriteCall(path, content, `重试写入 ${path} 并交给 Runtime 收束门验证`),
fileWriteCall(
path,
content,
`重试写入 ${path} 并交给 Runtime 收束门验证`,
),
]);
}
// Runtime validates fixed owner artifacts after the owner responds. Once
@@ -1769,12 +1772,7 @@ export function createDeterministicLaneDefenseRouter({
if (calls.some((call) => call.name === 'respond_to_user')) {
recordReadyTaskCompletion(agentId, runId);
}
return readyCallsResponse(
agentId,
runId,
tools,
calls,
);
return readyCallsResponse(agentId, runId, tools, calls);
}
const recovery = runData.get(runId)?.writerRecovery ?? null;
const observations = observationContext(context);
@@ -2195,13 +2193,18 @@ export function createDeterministicLaneDefenseRouter({
if (finalReplyRuns.has(key)) {
throw providerError('provider-duplicate-final-reply-request');
}
if (!relaxedAutonomous &&
if (
!relaxedAutonomous &&
!context.includes('给用户一个正常中文回复') &&
!context.includes('给开发者一个正常中文回复')
) {
throw providerError('provider-unexpected-text-request');
}
if (!relaxedAutonomous && identity.agentId === 'project-supervisor' && parentStage !== 'done') {
if (
!relaxedAutonomous &&
identity.agentId === 'project-supervisor' &&
parentStage !== 'done'
) {
throw providerError('provider-parent-final-reply-before-acceptance');
}
finalReplyRuns.add(key);
@@ -2586,8 +2589,7 @@ function createDeterministicCanvasFixture(apiKey) {
}
if (
request.method === 'POST' &&
canonicalPath ===
'/api/external/v1/editor/icon-spritesheets/generations'
canonicalPath === '/api/external/v1/editor/icon-spritesheets/generations'
) {
const idempotencyKey = request.headers['idempotency-key'];
if (
@@ -2906,7 +2908,9 @@ function createDeterministicCanvasFixture(apiKey) {
if (
request.method === 'POST' &&
canonicalPath?.match(/^\/api\/external\/v1\/editor\/projects\/[^/]+\/resources$/)
canonicalPath?.match(
/^\/api\/external\/v1\/editor\/projects\/[^/]+\/resources$/,
)
) {
const body = await readJsonBody(request);
const objectKey =
@@ -2959,7 +2963,11 @@ export async function startDeterministicLaneDefenseProvider({
relaxed = false,
fallbackPorts = DEFAULT_FALLBACK_PORTS,
} = {}) {
const router = createDeterministicLaneDefenseRouter({ apiKey, model, relaxed });
const router = createDeterministicLaneDefenseRouter({
apiKey,
model,
relaxed,
});
const canvasFixture = createDeterministicCanvasFixture(apiKey);
const sockets = new Set();
let stopped = false;
@@ -136,23 +136,20 @@ fn normalize_external_editor_api_key(value: &str) -> Result<String, String> {
fn private_external_editor_api_credentials_from_file_at(
path: &Path,
) -> Result<Option<ExternalEditorApiCredentials>, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}"));
}
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string());
if !crate::prepare_game_creator_private_path_for_read(path, false, "本机陶泥儿开发者 Key 文件")?
{
return Ok(None);
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?;
if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES {
return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string());
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(path, false, false)?;
let content = fs::read_to_string(path)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?;
let content = crate::read_game_creator_private_file_to_string(
path,
"本机陶泥儿开发者 Key 配置",
PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES,
)?;
let parsed = serde_json::from_str::<PrivateExternalEditorApiKeyFile>(&content)
.map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?;
let api_key = normalize_external_editor_api_key(&parsed.api_key)?;
@@ -162,6 +159,22 @@ fn private_external_editor_api_credentials_from_file_at(
.as_deref()
.unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL),
)?;
let expected_fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes()));
let actual_fingerprint = path
.file_name()
.and_then(|value| value.to_str())
.and_then(|value| {
value
.strip_prefix(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX)
.and_then(|value| value.strip_suffix(".json"))
})
.filter(|value| value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
.ok_or_else(|| "本机陶泥儿开发者 Key 文件名身份无效,请重新登录客户端后重试".to_string())?;
if !actual_fingerprint.eq_ignore_ascii_case(&expected_fingerprint[..16]) {
return Err(
"本机陶泥儿开发者 Key 文件身份与服务器地址不一致,请重新登录客户端后重试".to_string(),
);
}
Ok(Some(ExternalEditorApiCredentials {
api_base_url,
api_key,
@@ -171,18 +184,13 @@ fn private_external_editor_api_credentials_from_file_at(
fn unique_private_external_editor_api_credentials_at(
directory: &Path,
) -> Result<Option<ExternalEditorApiCredentials>, String> {
let metadata = match fs::symlink_metadata(directory) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 目录失败:{error}"));
}
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string());
if !crate::prepare_game_creator_private_path_for_read(
directory,
true,
"本机陶泥儿开发者 Key 目录",
)? {
return Ok(None);
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(directory, true, false)?;
let mut candidates = fs::read_dir(directory)
.map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))?
.filter_map(Result::ok)
@@ -219,35 +227,15 @@ fn ensure_plain_private_external_editor_directory(
path: &Path,
label: &str,
) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录"));
}
Ok(false)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let created = match fs::create_dir(path) {
Ok(()) => true,
Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => {
false
}
Err(create_error) => {
return Err(format!(
"创建本机陶泥儿开发者凭据{label}失败:{create_error}"
));
}
};
let metadata = fs::symlink_metadata(path).map_err(|metadata_error| {
format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}")
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录"));
}
Ok(created)
}
Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")),
let label = format!("本机陶泥儿开发者凭据{label}");
let created = crate::ensure_game_creator_private_directory_tree(path, &label)?;
#[cfg(windows)]
if !created {
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path, true, true,
)?;
}
Ok(created)
}
/// Prepares the exact private directory before a one-time remote developer key
@@ -264,15 +252,11 @@ fn prepare_private_external_editor_api_credentials_parent_dir_at(
.parent()
.ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?;
ensure_plain_private_external_editor_directory(container, "上级目录")?;
let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?;
ensure_plain_private_external_editor_directory(parent, "目录")?;
#[cfg(windows)]
if parent_created {
initialize_windows_game_creator_directory_owner_for_current_user(parent)?;
} else {
secure_windows_game_creator_path_for_current_user(parent, true, true)?;
}
secure_windows_game_creator_path_for_current_user_with_auto_elevation(parent, true, true)?;
#[cfg(unix)]
if parent_created {
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?;
@@ -301,8 +285,19 @@ fn write_private_external_editor_api_credentials_at(
if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() {
return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string());
}
if path.exists() {
return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string());
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(
"本机陶泥儿开发者 Key 目标必须是普通文件,不能是链接或其他对象".to_string(),
);
}
return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("读取本机陶泥儿开发者 Key 目标失败:{error}"));
}
}
let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile {
api_key: credentials.api_key.clone(),
@@ -325,9 +320,19 @@ fn write_private_external_editor_api_credentials_at(
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&temporary)
.map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?;
crate::harden_new_game_creator_private_path(&temporary, false, "本机陶泥儿开发者 Key 临时文件")
.map_err(|error| {
let _ = fs::remove_file(&temporary);
format!("初始化本机陶泥儿开发者 Key 临时文件安全权限失败:{error}")
})?;
let write_result = file
.write_all(format!("{body}\n").as_bytes())
.and_then(|_| file.sync_all());
@@ -336,8 +341,6 @@ fn write_private_external_editor_api_credentials_at(
let _ = fs::remove_file(&temporary);
return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}"));
}
#[cfg(windows)]
initialize_windows_game_creator_file_owner_for_current_user(&temporary)?;
match fs::hard_link(&temporary, path) {
Ok(()) => {
let _ = fs::remove_file(&temporary);
@@ -353,7 +356,14 @@ fn write_private_external_editor_api_credentials_at(
}
}
#[cfg(windows)]
secure_windows_game_creator_path_for_current_user(path, false, true)?;
if let Err(error) =
secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)
{
let _ = fs::remove_file(path);
return Err(format!(
"复核本机陶泥儿开发者 Key 文件安全权限失败:{error}"
));
}
Ok(())
}
@@ -474,11 +484,10 @@ pub(crate) fn upload_local_asset_at(
let relative_path = format!("assets/uploads/{asset_id}-{safe_name}");
let absolute_path = root.join(&relative_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建上传目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "上传目录")?;
prepare_game_creator_private_path_for_read(parent, true, "上传目录")?;
}
fs::write(&absolute_path, bytes)
.map_err(|error| format!("写入上传文件失败:{}: {error}", absolute_path.display()))?;
crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?;
register_local_asset_entry(
root,
@@ -578,13 +587,11 @@ pub(crate) fn import_canvas_export_at(
if !export_path.is_absolute() {
return Err("画板导出 ZIP 路径必须是绝对路径".to_string());
}
crate::prepare_game_creator_user_selected_path_for_read(export_path, false, "画板导出 ZIP")?;
let metadata = fs::symlink_metadata(export_path)
.map_err(|error| format!("读取画板导出 ZIP 失败:{}: {error}", export_path.display()))?;
if metadata.file_type().is_symlink() {
return Err("画板导出 ZIP 不能是符号链接".to_string());
}
if !metadata.is_file() {
return Err("画板导出路径必须是 ZIP 文件".to_string());
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("画板导出路径必须是普通 ZIP 文件".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
@@ -765,12 +772,10 @@ pub(crate) async fn sync_canvas_project_assets_at(
);
let absolute_path = root.join(&local_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板同步目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "画板同步目录")?;
prepare_game_creator_private_path_for_read(parent, true, "画板同步目录")?;
}
fs::write(&absolute_path, &download.bytes).map_err(|error| {
format!("写入画板同步资产失败:{}: {error}", absolute_path.display())
})?;
crate::write_game_creator_private_file(&absolute_path, &download.bytes, "画板同步资产")?;
assets.push(register_local_asset_entry(
root,
&local_path,
@@ -1523,13 +1528,18 @@ pub(crate) fn extract_canvas_export_zip_files(
let local_relative_path = format!("{import_relative_root}/{normalized_relative}");
let target_path = resolve_local_project_path(root, &local_relative_path)?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板导入目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "画板导入目录")?;
prepare_game_creator_private_path_for_read(parent, true, "画板导入目录")?;
}
let mut output = File::create(&target_path)
.map_err(|error| format!("写入画板导入文件失败:{}: {error}", target_path.display()))?;
std::io::copy(&mut entry, &mut output)
let entry_size = entry.size();
let mut bytes = Vec::with_capacity(entry_size.min(MAX_CANVAS_EXPORT_BYTES as u64) as usize);
std::io::Read::take(&mut entry, entry_size + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("解压画板导出文件失败:{}: {error}", target_path.display()))?;
if bytes.len() as u64 != entry_size {
return Err("画板导出 ZIP 条目读取长度不一致".to_string());
}
crate::write_game_creator_private_file(&target_path, &bytes, "画板导入文件")?;
copied_files.push(normalized_relative);
}
if copied_files.is_empty() {
@@ -1807,11 +1817,16 @@ mod tests {
{
let root = tempfile::tempdir().expect("temp dir");
let directory = root.path().join("config").join("genarrative");
let first_path = directory.join("external-editor-api-0000000000000001.json");
let first = ExternalEditorApiCredentials {
api_base_url: "https://dev.genarrative.world".to_string(),
api_key: "tnr_sk_headless_fixture_1".to_string(),
};
let first_path = directory.join(
private_external_editor_api_key_path_for_base_url(&first.api_base_url)
.expect("first credential path")
.file_name()
.expect("first credential filename"),
);
write_private_external_editor_api_credentials_at(&first_path, &first)
.expect("write first private credential");
let recovered = unique_private_external_editor_api_credentials_at(&directory)
@@ -1820,11 +1835,16 @@ mod tests {
assert_eq!(recovered.api_base_url, first.api_base_url);
assert_eq!(recovered.api_key, first.api_key);
let second_path = directory.join("external-editor-api-0000000000000002.json");
let second = ExternalEditorApiCredentials {
api_base_url: "https://www.genarrative.world".to_string(),
api_key: "tnr_sk_headless_fixture_2".to_string(),
};
let second_path = directory.join(
private_external_editor_api_key_path_for_base_url(&second.api_base_url)
.expect("second credential path")
.file_name()
.expect("second credential filename"),
);
write_private_external_editor_api_credentials_at(&second_path, &second)
.expect("write second private credential");
let error = match unique_private_external_editor_api_credentials_at(&directory) {
@@ -1856,11 +1876,17 @@ mod tests {
#[test]
fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() {
let root = tempfile::tempdir().expect("temp dir");
let credential_file_name =
private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world")
.expect("credential path")
.file_name()
.expect("credential filename")
.to_owned();
let path = root
.path()
.join("config")
.join("genarrative")
.join("external-editor-api-test.json");
.join(credential_file_name);
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
.expect("prepare private credential directory");
@@ -1889,7 +1915,13 @@ mod tests {
"fixture must reproduce the inherited ACL rejection"
);
let path = parent.join("external-editor-api-test.json");
let credential_file_name =
private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world")
.expect("credential path")
.file_name()
.expect("credential filename")
.to_owned();
let path = parent.join(credential_file_name);
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
.expect("current-user directory should be tightened locally before remote creation");
secure_windows_game_creator_path_for_current_user(&parent, true, false)
@@ -165,8 +165,14 @@ fn validate_local_asset_import_requirements(
}
let mut total_size = 0u64;
for source in source_paths {
let metadata =
fs::symlink_metadata(source.trim()).map_err(|_| "读取本地文件失败".to_string())?;
let path = Path::new(source.trim());
// Run the explicit user-selection ACL preparation before any size/type
// preflight. On Windows, metadata traversal can itself fail with
// ERROR_ACCESS_DENIED; doing this only in the later import worker would
// leave the early validation path unable to reach the one-shot UAC
// repair entry.
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?;
let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("只能导入普通文件".to_string());
}
@@ -282,12 +288,8 @@ pub(crate) fn create_automatic_local_game_project_at(
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
return Err("自动工作区根目录必须是绝对路径".to_string());
}
fs::create_dir_all(projects_root).map_err(|error| {
format!(
"创建自动工作区根目录失败:{}: {error}",
projects_root.display()
)
})?;
ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?;
prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?;
let metadata = fs::symlink_metadata(projects_root).map_err(|error| {
format!(
"读取自动工作区根目录失败:{}: {error}",
@@ -306,6 +308,11 @@ pub(crate) fn create_automatic_local_game_project_at(
match fs::create_dir(&project_root) {
Ok(()) => {
let result = (|| {
prepare_game_creator_private_path_for_read(
&project_root,
true,
"自动项目目录",
)?;
enforce_project_permission_policy(&project_root, "project.create")?;
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
init_local_game_project_at(
@@ -377,6 +384,7 @@ pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Resu
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
if !root.exists() {
return Ok(false);
}
@@ -405,6 +413,15 @@ pub(crate) fn inspect_local_project_directory(
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
match fs::symlink_metadata(root) {
Ok(metadata) if metadata.is_dir() => {
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
}
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
}
_ => {}
}
let recent_run_trace = recent_game_creator_run_trace(root);
let godot_project_root = discover_local_godot_project_root(root)?;
Ok(LocalProjectDirectoryStatus {
@@ -464,7 +481,12 @@ pub(crate) fn game_creator_project_manifest_error(root: &Path) -> Option<String>
pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationAgentRunTrace> {
let trace_path = root.join(".agent/run.latest.json");
let content = fs::read_to_string(trace_path).ok()?;
let content = crate::read_game_creator_private_file_to_string(
&trace_path,
"最近运行状态",
256 * 1024,
)
.ok()?;
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
}
@@ -500,9 +522,24 @@ pub(crate) async fn pick_local_project_directory(
else {
return Ok(None);
};
path.into_path()
.map(|path| Some(path.to_string_lossy().into_owned()))
.map_err(|error| format!("读取项目目录失败:{error}"))
let path = path
.into_path()
.map_err(|error| format!("读取项目目录失败:{error}"))?;
#[cfg(windows)]
crate::register_game_creator_user_selected_path(&path, true);
// The native picker is the explicit user-selection boundary. Prepare the
// selected root before returning it so inspect/create/open never races the
// first ACL read.
if let Err(error) = crate::prepare_game_creator_project_root_for_read(
&path,
true,
"用户选择项目目录",
) {
#[cfg(windows)]
crate::revoke_game_creator_user_selected_path(&path);
return Err(error);
}
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
@@ -521,9 +558,21 @@ pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<Stri
else {
return Ok(None);
};
path.into_path()
.map(|path| Some(path.to_string_lossy().into_owned()))
.map_err(|error| format!("读取本地文件失败:{error}"))
let path = path
.into_path()
.map_err(|error| format!("读取本地文件失败:{error}"))?;
#[cfg(windows)]
crate::register_game_creator_user_selected_path(&path, false);
if let Err(error) = crate::prepare_game_creator_user_selected_path_for_read(
&path,
false,
"用户选择文件",
) {
#[cfg(windows)]
crate::revoke_game_creator_user_selected_path(&path);
return Err(error);
}
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
@@ -553,6 +602,7 @@ pub(crate) fn validated_local_project_directory_path(
if !path.is_dir() {
return Err("项目路径不是文件夹".to_string());
}
crate::prepare_game_creator_project_root_for_read(path, true, "项目目录")?;
Ok(path.to_path_buf())
}
@@ -1805,6 +1855,7 @@ pub(crate) fn install_platform_account_session(
#[tauri::command]
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(generation);
Ok(())
@@ -1901,14 +1952,30 @@ pub(crate) fn create_ui_design_resource(
let relative_path = format!("ui/{resource_name}.json");
let absolute_path = resolve_local_project_path(root, &relative_path)?;
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?;
prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?;
}
if absolute_path.exists() {
if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? {
return Err("UI 设计资源路径已存在,拒绝覆盖".to_string());
}
fs::write(&absolute_path, "")
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&absolute_path)
.map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") {
drop(file);
let _ = fs::remove_file(&absolute_path);
return Err(error);
}
file.sync_all()
.map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?;
drop(file);
let asset = match register_local_asset_at(
root,
&relative_path,
@@ -2123,6 +2190,7 @@ pub(crate) fn import_ui_editor_local_files(
let mut total_size = 0u64;
for source in source_paths {
let path = Path::new(source.trim());
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地图片")?;
let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("只能导入普通图片文件".to_string());
@@ -2203,6 +2271,7 @@ fn read_registered_ui_editor_font(
return Err("项目资产不是受支持的字体候选".to_string());
}
let target = resolve_local_project_path(root, &asset.local_path)?;
prepare_game_creator_private_path_for_read(&target, false, "项目字体")?;
let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("项目字体必须是普通文件".to_string());
@@ -2279,6 +2348,7 @@ pub(crate) fn import_ui_editor_local_fonts(
let mut input_hashes = std::collections::BTreeSet::new();
for source in source_paths {
let path = Path::new(source.trim());
crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?;
let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("只能导入普通字体文件".to_string());
@@ -2337,7 +2407,8 @@ pub(crate) fn import_ui_editor_local_fonts(
if !new_inputs.is_empty() {
let font_root = root.join("assets/fonts");
fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?;
ensure_game_creator_private_directory_tree(&font_root, "项目字体目录")?;
prepare_game_creator_private_path_for_read(&font_root, true, "项目字体目录")?;
}
// 字体批次同样是增量提交合同:已经复制并登记的字体在后续失败时保留。
let mut result = Vec::with_capacity(inputs.len());
@@ -2358,7 +2429,28 @@ pub(crate) fn import_ui_editor_local_fonts(
validated.metadata.format.extension()
);
let target = resolve_local_project_path(root, &relative_path)?;
fs::write(&target, &bytes).map_err(|_| "写入项目字体失败".to_string())?;
if prepare_game_creator_private_path_for_read(&target, false, "项目字体")? {
return Err(format!("项目字体目标已存在但未登记:{}", target.display()));
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&target)
.map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&target, false, "项目字体") {
drop(file);
let _ = fs::remove_file(&target);
return Err(error);
}
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?;
drop(file);
let registered = register_local_asset_entry(
root,
&relative_path,
@@ -2598,7 +2690,7 @@ mod ui_editor_font_tests {
assert_eq!(
read_registered_ui_editor_font(root, &entry).expect_err("reject symlink"),
"项目文件路径不能包含符号链接"
"项目文件路径不能包含符号链接或 Windows reparse point"
);
}
}
@@ -3380,6 +3472,7 @@ pub(crate) fn import_local_project_image_assets_for_agent(
reject_sensitive_project_file_read(&normalized)?;
reject_agent_local_image_source_path(&normalized)?;
let source = resolve_local_project_path(root, &normalized)?;
prepare_game_creator_private_path_for_read(&source, false, "本地图片")?;
let metadata =
fs::symlink_metadata(&source).map_err(|_| format!("本地图片不存在:{normalized}"))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
@@ -3430,16 +3523,37 @@ pub(crate) fn import_local_project_image_assets_for_agent(
});
continue;
}
if target.exists() && source_path != local_path {
if target.exists() {
prepare_game_creator_private_path_for_read(&target, false, "目标图片")?;
let existing_bytes = fs::read(&target).map_err(|_| "读取目标图片失败".to_string())?;
if existing_bytes != bytes {
return Err(format!("本地图片目标已存在且内容不同:{local_path}"));
}
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|_| "创建本地图片导入目录失败".to_string())?;
ensure_game_creator_private_directory_tree(parent, "本地图片导入目录")?;
prepare_game_creator_private_path_for_read(parent, true, "本地图片导入目录")?;
}
fs::write(&target, &bytes).map_err(|_| "写入本地图片失败".to_string())?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&target)
.map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标图片")
{
drop(file);
let _ = fs::remove_file(&target);
return Err(error);
}
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?;
drop(file);
}
let registered = register_local_asset_entry(
root,
@@ -3588,12 +3702,33 @@ pub(crate) async fn import_account_editor_assets_for_agent(
continue;
}
if target.exists() {
prepare_game_creator_private_path_for_read(&target, false, "账户图片目标")?;
return Err(format!("账户图片目标已存在但尚未登记:{local_path}"));
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|_| "创建账户图片导入目录失败".to_string())?;
ensure_game_creator_private_directory_tree(parent, "账户图片导入目录")?;
prepare_game_creator_private_path_for_read(parent, true, "账户图片导入目录")?;
}
fs::write(&target, &bytes).map_err(|_| "写入账户图片失败".to_string())?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&target)
.map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&target, false, "账户图片目标")
{
drop(file);
let _ = fs::remove_file(&target);
return Err(error);
}
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?;
drop(file);
let (source_kind, canvas_project_id, generation_route) = match record.origin {
AgentEditorAssetOrigin::AccountLibrary => (
GameCreationAppAssetSourceKind::Canvas,
@@ -3695,9 +3830,31 @@ pub(crate) async fn import_ui_editor_remote_assets(
for (asset, asset_id, media_type, local_path, bytes) in downloads {
let target = resolve_local_project_path(root, &local_path)?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?;
ensure_game_creator_private_directory_tree(parent, "平台素材导入目录")?;
prepare_game_creator_private_path_for_read(parent, true, "平台素材导入目录")?;
}
fs::write(&target, &bytes).map_err(|e| format!("写入平台素材失败:{e}"))?;
if prepare_game_creator_private_path_for_read(&target, false, "平台素材")? {
return Err(format!("平台素材目标已存在但尚未登记:{local_path}"));
}
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&target)
.map_err(|error| format!("写入平台素材失败:{e}", e = error))?;
if let Err(error) = harden_new_game_creator_private_path(&target, false, "平台素材") {
drop(file);
let _ = fs::remove_file(&target);
return Err(error);
}
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("写入平台素材失败:{error}"))?;
drop(file);
let registered = register_local_asset_entry(
root,
&local_path,
File diff suppressed because it is too large Load Diff
@@ -1891,6 +1891,61 @@ fn install_agent_runtime_async_runtime_with_deep_stack() {
Box::leak(Box::new(runtime));
}
#[cfg(windows)]
fn run_windows_acl_repair_if_requested(args: &[String]) -> Option<i32> {
let [
command,
path,
target_user_sid_flag,
target_user_sid,
authorization_flag,
nonce,
scope_flag,
scope_value,
] = args
else {
if args.first().map(String::as_str) == Some("--repair-private-acl") {
eprintln!(
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
);
return Some(1);
}
return None;
};
if command != "--repair-private-acl"
|| target_user_sid_flag != "--target-user-sid"
|| authorization_flag != "--authorization"
|| scope_flag != "--scope"
{
eprintln!(
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
);
return Some(1);
}
let path = std::path::PathBuf::from(path);
let scope = match config::parse_windows_acl_repair_scope(scope_value) {
Ok(scope) => scope,
Err(error) => {
eprintln!("{error}");
return Some(1);
}
};
let result = config::consume_windows_acl_repair_authorization(
&path,
target_user_sid,
nonce,
scope,
)
.and_then(|()| config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid));
match result {
Ok(()) => Some(0),
Err(error) => {
eprintln!("AGC ACL 提权修复失败:{error}");
Some(1)
}
}
}
#[cfg(test)]
mod async_runtime_stack_tests {
/// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。
@@ -1928,6 +1983,10 @@ fn main() {
if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) {
std::process::exit(exit_code);
}
#[cfg(windows)]
if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) {
std::process::exit(exit_code);
}
#[cfg(target_os = "linux")]
if command_sandbox_trampoline::is_trampoline_mode(&args) {
match command_sandbox_trampoline::run_trampoline() {
@@ -349,8 +349,8 @@ fn open_agent_db_directory(root: &Path, create: bool) -> Result<Option<AgentDbDi
use std::os::unix::ffi::OsStrExt;
if create {
fs::create_dir_all(root)
.map_err(|error| format!("创建 Agent DB 项目目录失败:{}: {error}", root.display()))?;
ensure_game_creator_private_directory_tree(root, "Agent DB 项目目录")?;
prepare_game_creator_private_path_for_read(root, true, "Agent DB 项目目录")?;
}
let root_name = std::ffi::CString::new(root.as_os_str().as_bytes())
.map_err(|_| "Agent DB 项目目录包含 NUL".to_string())?;
@@ -813,8 +813,17 @@ fn nt_open_windows_agent_db_relative(
#[cfg(windows)]
fn open_agent_db_directory(root: &Path, create: bool) -> Result<Option<AgentDbDirectory>, String> {
if create {
fs::create_dir_all(root)
.map_err(|error| format!("创建 Agent DB 项目目录失败:{}: {error}", root.display()))?;
ensure_game_creator_private_directory_tree(root, "Agent DB 项目目录")?;
}
if !prepare_game_creator_private_path_for_read(root, true, "Agent DB 项目目录")? {
return Ok(None);
}
let agent_path = root.join(".agent");
if create {
ensure_game_creator_private_directory_tree(&agent_path, "项目 .agent 目录")?;
}
if !prepare_game_creator_private_path_for_read(&agent_path, true, "项目 .agent 目录")? {
return Ok(None);
}
let root_directory = match open_windows_agent_db_root(root, create) {
Ok(file) => file,
@@ -875,6 +884,21 @@ fn open_agent_db_storage(
create: bool,
) -> Result<Option<AgentDbStorage>, String> {
verify_agent_db_directory_current(&directory)?;
let path = directory.path.join("agent.db");
let existed = match fs::symlink_metadata(&path) {
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => {
// Do not let an ACL-denied existing Agent DB fall through to
// NtCreateFile, which would otherwise be reported as a generic
// open failure without trying the approved repair path.
prepare_game_creator_private_path_for_read(&path, false, "Agent 本地索引")?;
true
}
};
if existed {
prepare_game_creator_private_path_for_read(&path, false, "Agent 本地索引")?;
}
let file = {
let mut opened = None;
for attempt in 0..100 {
@@ -924,10 +948,16 @@ fn open_agent_db_storage(
return Err("Agent 本地索引必须是普通文件".to_string());
}
validate_windows_regular_file_handle(&file, "Agent 本地索引")?;
if existed {
secure_windows_game_creator_path_for_current_user_with_auto_elevation(&path, false, true)?;
} else {
initialize_windows_game_creator_file_owner_for_current_user(&path)?;
secure_windows_game_creator_path_for_current_user_with_auto_elevation(&path, false, false)?;
}
verify_agent_db_directory_current(&directory)?;
Ok(Some(AgentDbStorage {
file,
path: directory.path.join("agent.db"),
path,
root_path: directory.root_path,
root_directory: directory.root_directory,
agent_directory: directory.agent_directory,
@@ -4369,13 +4399,10 @@ fn project_append_os_lock_path(path: &Path) -> Result<PathBuf, String> {
fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result<File, String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"创建{error_label}跨进程锁目录失败:{}: {error}",
parent.display()
)
})?;
ensure_game_creator_private_directory_tree(parent, error_label)?;
prepare_game_creator_private_path_for_read(parent, true, error_label)?;
}
prepare_game_creator_private_path_for_read(path, false, error_label)?;
for attempt in 0..100 {
if let Some(file) = try_open_project_append_os_lock(path, error_label)? {
return Ok(file);
@@ -4391,12 +4418,58 @@ fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result<File
fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Option<File>, String> {
use std::os::fd::AsRawFd;
let file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?;
let mut options = fs::OpenOptions::new();
options.create(true).read(true).write(true);
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
let file = options
.open(path)
.map_err(|error| format!("打开{error_label}跨进程锁失败:{}: {error}", path.display()))?;
let metadata = file.metadata().map_err(|error| {
format!(
"读取{error_label}跨进程锁元数据失败:{}: {error}",
path.display()
)
})?;
if !metadata.is_file() {
return Err(format!(
"{error_label}跨进程锁必须是普通文件:{}",
path.display()
));
}
if !existed {
if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) {
drop(file);
let _ = fs::remove_file(path);
return Err(error);
}
}
let path_metadata = fs::symlink_metadata(path).map_err(|error| {
format!(
"复核{error_label}跨进程锁路径失败:{}: {error}",
path.display()
)
})?;
if path_metadata.file_type().is_symlink() {
return Err(format!(
"{error_label}跨进程锁不能是符号链接:{}",
path.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if path_metadata.nlink() != 1
|| path_metadata.dev() != metadata.dev()
|| path_metadata.ino() != metadata.ino()
{
return Err(format!(
"{error_label}跨进程锁路径在打开期间发生替换:{}",
path.display()
));
}
}
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if result == 0 {
return Ok(Some(file));
@@ -4416,14 +4489,33 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Option<File>, String> {
use std::os::windows::fs::OpenOptionsExt;
match fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.share_mode(0)
.open(path)
{
Ok(file) => Ok(Some(file)),
let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
match {
let mut options = fs::OpenOptions::new();
options
.create(true)
.read(true)
.write(true)
.share_mode(0)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
options.open(path)
} {
Ok(file) => {
crate::runner::validate_windows_regular_file_handle(&file, error_label)?;
if !existed {
if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) {
drop(file);
let _ = fs::remove_file(path);
return Err(error);
}
}
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path, false, true,
)?;
crate::runner::validate_windows_regular_file_handle(&file, error_label)?;
Ok(Some(file))
}
Err(error)
if matches!(
error.kind(),
@@ -4453,22 +4545,76 @@ pub(super) fn append_jsonl_line_unlocked(
error_label: &str,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建{error_label}目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, error_label)?;
prepare_game_creator_private_path_for_read(parent, true, error_label)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?;
let mut options = fs::OpenOptions::new();
options.create(true).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
const FILE_SHARE_DELETE: u32 = 0x0000_0004;
options
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE);
}
let mut file = options
.open(path)
.map_err(|error| format!("打开{error_label}失败:{}: {error}", path.display()))?;
let opened_metadata = file.metadata().map_err(|error| {
format!(
"读取{error_label}文件句柄元数据失败:{}: {error}",
path.display()
)
})?;
if !opened_metadata.is_file() {
return Err(format!("{error_label}必须是普通文件:{}", path.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if opened_metadata.nlink() != 1 {
return Err(format!("{error_label}不能是硬链接:{}", path.display()));
}
let path_metadata = fs::symlink_metadata(path)
.map_err(|error| format!("复核{error_label}路径失败:{}: {error}", path.display()))?;
if path_metadata.file_type().is_symlink()
|| path_metadata.dev() != opened_metadata.dev()
|| path_metadata.ino() != opened_metadata.ino()
{
return Err(format!(
"{error_label}路径在安全打开期间发生替换:{}",
path.display()
));
}
}
#[cfg(windows)]
crate::runner::validate_windows_regular_file_handle(&file, error_label)?;
if !existed {
if let Err(error) = harden_new_game_creator_private_path(path, false, error_label) {
drop(file);
let _ = fs::remove_file(path);
return Err(error);
}
}
repair_truncated_jsonl_tail_unlocked(&mut file, path, error_label)?;
let framed = format!("{line}\n");
file.seek(SeekFrom::End(0))
.and_then(|_| file.write_all(framed.as_bytes()))
.and_then(|_| file.flush())
.and_then(|_| file.sync_data())
.map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display()))
.map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display()))?;
prepare_game_creator_private_path_for_read(path, false, error_label)?;
Ok(())
}
const AGENT_DB_FINALIZATION_SLOT_PREPARED: u8 = 0;
@@ -5,6 +5,54 @@ use super::filesystem::validate_portable_project_path_component;
#[cfg(windows)]
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
#[cfg(windows)]
fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> {
use std::ffi::c_void;
use std::os::windows::io::AsRawHandle;
#[repr(C)]
struct FileTime {
low_date_time: u32,
high_date_time: u32,
}
#[repr(C)]
struct ByHandleFileInformation {
file_attributes: u32,
creation_time: FileTime,
last_access_time: FileTime,
last_write_time: FileTime,
volume_serial_number: u32,
file_size_high: u32,
file_size_low: u32,
number_of_links: u32,
file_index_high: u32,
file_index_low: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetFileInformationByHandle(
file: *mut c_void,
information: *mut ByHandleFileInformation,
) -> i32;
}
// SAFETY: the structure is plain data initialized by GetFileInformationByHandle.
let mut information = unsafe { std::mem::zeroed::<ByHandleFileInformation>() };
// SAFETY: file owns a live kernel handle and information is a valid output pointer.
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 {
return Err(format!(
"读取 {label} Windows 文件句柄身份失败:{}",
std::io::Error::last_os_error()
));
}
Ok((
information.volume_serial_number,
(u64::from(information.file_index_high) << 32) | u64::from(information.file_index_low),
))
}
pub(crate) fn create_local_project_checkpoint_at(
root: &Path,
) -> Result<LocalProjectCheckpointResult, String> {
@@ -22,10 +70,10 @@ pub(crate) fn create_local_project_checkpoint_at(
&checkpoint_file_relative_path(&checkpoint_id, &normalized_path),
)?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建 checkpoint 目录失败:{}: {error}", parent.display())
})?;
ensure_game_creator_private_directory_tree(parent, "checkpoint 目录")?;
prepare_game_creator_private_path_for_read(parent, true, "checkpoint 目录")?;
}
prepare_game_creator_private_path_for_read(&target, false, "checkpoint 文件")?;
fs::copy(&source, &target).map_err(|error| {
format!(
"写入 checkpoint 文件失败:{} -> {}: {error}",
@@ -33,6 +81,7 @@ pub(crate) fn create_local_project_checkpoint_at(
target.display()
)
})?;
prepare_game_creator_private_path_for_read(&target, false, "checkpoint 文件")?;
}
let total_bytes = files.iter().map(|file| file.size).sum::<u64>();
let manifest = serde_json::json!({
@@ -42,20 +91,21 @@ pub(crate) fn create_local_project_checkpoint_at(
});
let manifest_path =
resolve_local_project_path(root, &checkpoint_manifest_relative_path(&checkpoint_id))?;
fs::write(
if let Some(parent) = manifest_path.parent() {
ensure_game_creator_private_directory_tree(parent, "checkpoint manifest 目录")?;
prepare_game_creator_private_path_for_read(parent, true, "checkpoint manifest 目录")?;
}
prepare_game_creator_private_path_for_read(&manifest_path, false, "checkpoint manifest")?;
crate::write_game_creator_private_file(
&manifest_path,
format!(
"{}\n",
serde_json::to_string_pretty(&manifest)
.map_err(|error| format!("序列化 checkpoint 失败:{error}"))?
),
)
.map_err(|error| {
format!(
"写入 checkpoint manifest 失败:{}: {error}",
manifest_path.display()
)
})?;
.as_bytes(),
"checkpoint manifest",
)?;
append_agent_db_record(
root,
serde_json::json!({
@@ -147,13 +197,68 @@ pub(crate) fn open_project_snapshot_regular_file(
}
#[cfg(windows)]
validate_windows_regular_file_handle(&file, label)?;
// The pathname was checked before opening, but another process can replace
// it between those two operations. Compare the opened handle identity to
// the current directory entry before any caller reads bytes; subsequent
// reads use the already-open handle and therefore are not pathname-based.
let path_metadata = fs::symlink_metadata(path)
.map_err(|error| format!("复核{label}路径失败:{}: {error}", path.display()))?;
if path_metadata.file_type().is_symlink() || !path_metadata.is_file() {
return Err(format!(
"{label}路径在安全打开期间发生替换:{}",
path.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if path_metadata.dev() != metadata.dev() || path_metadata.ino() != metadata.ino() {
return Err(format!(
"{label}路径在安全打开期间发生替换:{}",
path.display()
));
}
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
let mut identity_options = fs::OpenOptions::new();
identity_options
.read(true)
.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
let identity_file = identity_options
.open(path)
.map_err(|error| format!("复核{label}路径失败:{}: {error}", path.display()))?;
validate_windows_regular_file_handle(&identity_file, label)?;
if windows_regular_file_handle_identity(&identity_file, label)?
!= windows_regular_file_handle_identity(&file, label)?
{
return Err(format!(
"{label}路径在安全打开期间发生替换:{}",
path.display()
));
}
}
Ok((file, metadata))
}
/// Opens an AGC-managed project file after the private-path owner/DACL gate
/// has had a chance to repair an inherited or foreign ACL. Callers that read
/// arbitrary user-selected files must keep using
/// `open_project_snapshot_regular_file` so importing an external file never
/// silently changes its owner.
pub(crate) fn open_project_private_regular_file(
path: &Path,
label: &str,
) -> Result<(File, fs::Metadata), String> {
prepare_game_creator_private_path_for_read(path, false, label)?;
open_project_snapshot_regular_file(path, label)
}
fn read_local_project_content_diff_source(
path: &Path,
) -> Result<LocalProjectContentDiffSource, String> {
let (mut file, metadata) = open_project_snapshot_regular_file(path, "内容 diff 文件")?;
let (mut file, metadata) = open_project_private_regular_file(path, "内容 diff 文件")?;
let mut hasher = Sha256::new();
let mut bytes = (metadata.len() <= PROJECT_CONTENT_DIFF_MAX_FILE_BYTES)
.then(|| Vec::with_capacity(metadata.len() as usize));
@@ -518,10 +623,12 @@ pub(crate) fn restore_local_project_checkpoint_at(
let restored_count = restore_plan.len();
for (source, target) in restore_plan {
prepare_game_creator_private_path_for_read(&source, false, "checkpoint 源文件")?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建恢复目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "恢复目录")?;
prepare_game_creator_private_path_for_read(parent, true, "恢复目录")?;
}
prepare_game_creator_private_path_for_read(&target, false, "恢复目标文件")?;
fs::copy(&source, &target).map_err(|error| {
format!(
"恢复 checkpoint 文件失败:{} -> {}: {error}",
@@ -529,6 +636,7 @@ pub(crate) fn restore_local_project_checkpoint_at(
target.display()
)
})?;
prepare_game_creator_private_path_for_read(&target, false, "恢复目标文件")?;
}
let deleted_count = delete_plan.len();
for target in delete_plan {
@@ -68,6 +68,9 @@ fn file_modified_timestamp(path: &Path) -> u64 {
}
fn count_conversation_messages(path: &Path) -> Result<u64, String> {
if !prepare_game_creator_private_path_for_read(path, false, "对话记录")? {
return Ok(0);
}
match File::open(path) {
Ok(file) => {
let mut count = 0_u64;
@@ -118,6 +121,7 @@ fn read_agent_conversation_session_catalog_unlocked(
validate_project_root(root)?;
let agent_id = normalize_conversation_agent_id(agent_id)?;
let catalog_path = agent_conversation_session_catalog_path(root, &agent_id);
prepare_game_creator_private_path_for_read(&catalog_path, false, "Agent Session 目录")?;
let mut catalog = match fs::read_to_string(&catalog_path) {
Ok(content) => serde_json::from_str::<AgentConversationSessionCatalogFile>(&content)
.map_err(|error| {
@@ -305,37 +309,16 @@ fn write_agent_conversation_session_catalog_unlocked(
) -> Result<(), String> {
let path = agent_conversation_session_catalog_path(root, &catalog.agent_id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建 Agent Session 目录失败:{}: {error}", parent.display())
})?;
ensure_game_creator_private_directory_tree(parent, "Agent Session 目录")?;
prepare_game_creator_private_path_for_read(parent, true, "Agent Session 目录")?;
}
let content = serde_json::to_string_pretty(catalog)
.map_err(|error| format!("序列化 Agent Session 目录失败:{error}"))?;
let temp_path = path.with_file_name(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("sessions.json"),
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
fs::write(&temp_path, format!("{content}\n")).map_err(|error| {
format!(
"写入 Agent Session 临时目录失败:{}: {error}",
temp_path.display()
)
})?;
fs::rename(&temp_path, &path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
format!(
"替换 Agent Session 目录失败:{} -> {}: {error}",
temp_path.display(),
path.display()
)
})
write_game_creator_private_file(
&path,
format!("{content}\n").as_bytes(),
"Agent Session 目录",
)
}
pub(crate) fn ensure_agent_conversation_session_at(
@@ -419,6 +402,7 @@ pub(crate) fn ensure_agent_session_has_no_live_tasks(
let task_path = root
.join(".agent/runtime/tasks")
.join(format!("{agent_id}.jsonl"));
prepare_game_creator_private_path_for_read(&task_path, false, "Agent Runtime 任务")?;
match File::open(&task_path) {
Ok(file) => {
for line in BufReader::new(file).lines() {
@@ -497,6 +481,7 @@ pub(crate) fn ensure_agent_session_has_no_live_tasks(
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
continue;
}
prepare_game_creator_private_path_for_read(&path, false, "Agent Runtime 任务")?;
let mut delegated_latest_by_run = BTreeMap::<String, AgentRuntimeTaskRecord>::new();
let file = File::open(&path)
.map_err(|error| format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()))?;
@@ -558,19 +543,33 @@ pub(crate) fn create_game_creator_agent_session_at(
let conversation_path =
conversation_file_path_for_resolved_session(root, &agent_id, &session_id);
if let Some(parent) = conversation_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "对话目录")?;
prepare_game_creator_private_path_for_read(parent, true, "对话目录")?;
}
fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&conversation_path)
.map_err(|error| {
format!(
"创建 Agent Session 对话失败:{}: {error}",
conversation_path.display()
)
})?;
prepare_game_creator_private_path_for_read(&conversation_path, false, "对话记录")?;
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options.open(&conversation_path).map_err(|error| {
format!(
"创建 Agent Session 对话失败:{}: {error}",
conversation_path.display()
)
})?;
if let Err(error) = harden_new_game_creator_private_path(
&conversation_path,
false,
"对话记录",
) {
drop(file);
let _ = fs::remove_file(&conversation_path);
return Err(error);
}
drop(file);
catalog.sessions.push(AgentConversationSessionRecord {
session_id: session_id.clone(),
title,
@@ -665,20 +664,28 @@ where
let conversation_path =
conversation_file_path_for_resolved_session(root, &agent_id, &session_id);
if let Some(parent) = conversation_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "对话目录")?;
prepare_game_creator_private_path_for_read(parent, true, "对话目录")?;
}
prepare_game_creator_private_path_for_read(
&conversation_path,
false,
"Agent Session 分叉对话",
)?;
let write_result = (|| -> Result<(), String> {
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&conversation_path)
.map_err(|error| {
format!(
"创建 Agent Session 分叉对话失败:{}: {error}",
conversation_path.display()
)
})?;
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options.open(&conversation_path).map_err(|error| {
format!(
"创建 Agent Session 分叉对话失败:{}: {error}",
conversation_path.display()
)
})?;
for record in &records {
serde_json::to_writer(&mut file, record)
.map_err(|error| format!("序列化 Agent Session 分叉消息失败:{error}"))?;
@@ -700,6 +707,11 @@ where
let _ = fs::remove_file(&conversation_path);
return Err(error);
}
prepare_game_creator_private_path_for_read(
&conversation_path,
false,
"Agent Session 分叉对话",
)?;
let now = unix_timestamp();
let requested_title = title.trim();
@@ -938,6 +950,7 @@ fn read_persisted_local_conversation_records_unlocked(
path: &Path,
) -> Result<Vec<PersistedLocalConversationMessageRecord>, String> {
let mut records = Vec::new();
prepare_game_creator_private_path_for_read(path, false, "对话记录")?;
match File::open(path) {
Ok(file) => {
for line in BufReader::new(file).lines() {
@@ -1431,23 +1444,22 @@ pub(crate) fn append_markdown_entry(
error_label: &str,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("{error_label}{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, error_label)?;
prepare_game_creator_private_path_for_read(parent, true, error_label)?;
}
prepare_game_creator_private_path_for_read(path, false, error_label)?;
let needs_header = fs::metadata(path)
.map(|metadata| metadata.len() == 0)
.unwrap_or(true);
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|error| format!("{error_label}{}: {error}", path.display()))?;
if needs_header {
file.write_all(header.as_bytes())
.map_err(|error| format!("{error_label}{}: {error}", path.display()))?;
}
file.write_all(entry.as_bytes())
.map_err(|error| format!("{error_label}{}: {error}", path.display()))
let bytes = if needs_header {
let mut bytes = Vec::with_capacity(header.len() + entry.len());
bytes.extend_from_slice(header.as_bytes());
bytes.extend_from_slice(entry.as_bytes());
bytes
} else {
entry.as_bytes().to_vec()
};
append_game_creator_private_file(path, &bytes, error_label)
}
pub(crate) fn append_local_permission_log_at(
@@ -1474,16 +1486,12 @@ pub(crate) fn append_local_permission_log_at(
let log_path = root.join(".agent/logs/command.log");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "命令日志目录")?;
prepare_game_creator_private_path_for_read(parent, true, "命令日志目录")?;
}
prepare_game_creator_private_path_for_read(&log_path, false, "命令日志")?;
let line = format!("{} {event} {command_id}\n", unix_timestamp());
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")
}
#[cfg(test)]
@@ -13,6 +13,7 @@ pub(crate) fn export_local_project_package_at(
if !game_index_metadata.is_file() {
return Err("导出试玩包前需要先生成 game/index.html".to_string());
}
prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?;
let game_index = fs::read_to_string(&game_index_path)
.map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?;
if game_index.trim().is_empty() {
@@ -51,12 +52,26 @@ pub(crate) fn export_local_project_package_at(
let package_relative_path = next_project_export_package_relative_path(root)?;
let package_path = resolve_local_project_path(root, &package_relative_path)?;
if let Some(parent) = package_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建导出目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "导出目录")?;
prepare_game_creator_private_path_for_read(parent, true, "导出目录")?;
}
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
let file = File::create(&package_path)
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options
.open(&package_path)
.map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?;
if let Err(error) = harden_new_game_creator_private_path(&package_path, false, "试玩包") {
drop(file);
let _ = fs::remove_file(&package_path);
return Err(error);
}
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
@@ -64,6 +79,7 @@ pub(crate) fn export_local_project_package_at(
writer
.start_file(relative_path, options)
.map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?;
prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?;
let bytes = fs::read(absolute_path)
.map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?;
writer
@@ -73,13 +89,10 @@ pub(crate) fn export_local_project_package_at(
writer
.finish()
.map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?;
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
let updated_at = unix_timestamp();
let log_path = root.join(".agent/logs/command.log");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
}
let output = format!(
"导出试玩包:{}{} 个文件,{}B",
package_relative_path,
@@ -87,12 +100,7 @@ pub(crate) fn export_local_project_package_at(
total_bytes
);
let line = format!("{updated_at} project.export_package: {output}\n");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?;
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
record_command_run(
root,
GameCreationAppCommandRunState {
@@ -1,7 +1,7 @@
use super::*;
#[cfg(windows)]
pub(super) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(1);
@@ -135,6 +135,7 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool {
return false;
};
if metadata.file_type().is_symlink()
|| windows_metadata_is_reparse_point(&metadata)
|| !metadata.is_file()
|| metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES
{
@@ -196,8 +197,8 @@ pub(crate) fn acquire_project_write_lock(
validate_project_root(root)?;
let mut path = resolve_project_write_lock_path(root)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "项目锁目录")?;
prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")?;
}
// Re-check the parent after creation so skipping metadata only for the final
// create_new target cannot weaken the normal ancestor link/reparse checks.
@@ -212,16 +213,26 @@ pub(crate) fn acquire_project_write_lock(
.map_err(|error| format!("生成项目写锁失败:{error}"))?;
let mut retried_after_reclaim = false;
loop {
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
match options.open(&path) {
Ok(mut file) => {
if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁")
{
drop(file);
let _ = fs::remove_file(&path);
return Err(error);
}
if let Err(error) = file.write_all(content.as_bytes()) {
let _ = fs::remove_file(&path);
return Err(format!("写入项目写锁失败:{}: {error}", path.display()));
}
prepare_game_creator_private_path_for_read(&path, false, "项目写锁")?;
return Ok(ProjectWriteLock {
path,
content: content.clone(),
@@ -283,14 +294,13 @@ pub(crate) fn list_local_project_files_at(
{
let entry =
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!("读取文件类型失败:{}: {error}", entry.path().display())
})?;
if file_type.is_symlink() {
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
continue;
}
let path = entry.path();
let file_type = metadata.file_type();
let relative_path = relative_project_path(root, &path)?;
if is_agent_runtime_private_control_path(&relative_path)
|| is_agent_checkpoint_control_path(&relative_path)
@@ -298,9 +308,6 @@ pub(crate) fn list_local_project_files_at(
{
continue;
}
let metadata = entry.metadata().map_err(|error| {
format!("读取文件元数据失败:{}: {error}", entry.path().display())
})?;
let modified_at = metadata
.modified()
.ok()
@@ -342,6 +349,7 @@ pub(crate) fn read_local_project_file_at(
reject_agent_runtime_private_control_path(&normalized_path)?;
reject_sensitive_project_file_read(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
prepare_game_creator_private_path_for_read(&path, false, "项目文件")?;
let metadata = fs::metadata(&path)
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
if !metadata.is_file() {
@@ -483,12 +491,7 @@ pub(crate) fn write_local_project_file_at(
if path.exists() && !path.is_file() {
return Err("只能写入文件".to_string());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入项目文件失败:{}: {error}", path.display()))?;
crate::write_game_creator_private_file(&path, content.as_bytes(), "项目文件")?;
Ok(LocalProjectFileMutationResult {
path: normalized_path,
@@ -516,6 +519,7 @@ pub(crate) fn delete_local_project_file_at(
if !path.is_file() {
return Err("只能删除文件".to_string());
}
prepare_game_creator_private_path_for_read(&path, false, "项目文件")?;
fs::remove_file(&path)
.map_err(|error| format!("删除项目文件失败:{}: {error}", path.display()))?;
@@ -537,24 +541,17 @@ pub(crate) fn build_local_project_index_at(root: &Path) -> Result<LocalProjectIn
total_bytes,
files,
};
if let Some(parent) = root.join(PROJECT_INDEX_PATH).parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目索引目录失败:{}: {error}", parent.display()))?;
}
fs::write(
root.join(PROJECT_INDEX_PATH),
let index_path = root.join(PROJECT_INDEX_PATH);
crate::write_game_creator_private_file(
&index_path,
format!(
"{}\n",
serde_json::to_string_pretty(&result)
.map_err(|error| format!("序列化项目索引失败:{error}"))?
),
)
.map_err(|error| {
format!(
"写入项目索引失败:{}: {error}",
root.join(PROJECT_INDEX_PATH).display()
)
})?;
.as_bytes(),
"项目索引",
)?;
append_agent_db_record(
root,
serde_json::json!({
@@ -582,13 +579,13 @@ pub(crate) fn collect_project_index_files(
{
let entry =
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!("读取文件类型失败:{}: {error}", entry.path().display())
})?;
if file_type.is_symlink() {
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
continue;
}
let path = entry.path();
let file_type = metadata.file_type();
let relative_path = relative_project_path(root, &path)?;
if should_skip_project_index_path(&relative_path) {
continue;
@@ -597,7 +594,7 @@ pub(crate) fn collect_project_index_files(
dirs.push(path);
} else if file_type.is_file() {
let (mut file, metadata) =
open_project_snapshot_regular_file(&path, "项目索引文件")?;
open_project_private_regular_file(&path, "项目索引文件")?;
let mut bytes =
Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default());
file.read_to_end(&mut bytes)
@@ -777,19 +774,36 @@ pub(crate) fn resolve_local_project_path(
let normalized = normalize_relative_path(relative_path)?;
let mut path = root.to_path_buf();
let mut should_check_metadata = true;
let mut acl_repair_attempted = false;
for part in normalized.split('/') {
path.push(part);
if !should_check_metadata {
continue;
}
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err("项目文件路径不能包含符号链接".to_string());
Ok(metadata)
if metadata.file_type().is_symlink()
|| windows_metadata_is_reparse_point(&metadata) =>
{
return Err("项目文件路径不能包含符号链接或 Windows reparse point".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
should_check_metadata = false;
}
Err(error)
if !acl_repair_attempted
&& error.kind() == std::io::ErrorKind::PermissionDenied =>
{
acl_repair_attempted = true;
#[cfg(windows)]
if crate::prepare_game_creator_private_path_for_read(&path, true, "项目路径")
.is_ok()
{
continue;
}
return Err(format!("读取路径失败:{}: {error}", path.display()));
}
Err(error) => {
return Err(format!("读取路径失败:{}: {error}", path.display()));
}
@@ -808,10 +822,18 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> {
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
// Every project operation enters through this validator. On Windows the
// root may be a historical directory whose owner is still the elevated
// installer account or whose DACL is inherited. Reuse the formal prepare
// entry here so all downstream reads/writes get the same one-shot UAC
// repair and post-repair verification, instead of failing later at the
// first individual sidecar read.
#[cfg(windows)]
crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?;
match fs::symlink_metadata(root) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err("项目目录不能是符号链接".to_string());
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
return Err("项目目录不能是符号链接或 Windows reparse point".to_string());
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
@@ -822,6 +844,20 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> {
Ok(())
}
fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
}
#[cfg(not(windows))]
{
let _ = metadata;
false
}
}
pub(crate) fn project_path_has_control_chars(root: &Path) -> bool {
root.to_string_lossy().chars().any(char::is_control)
}
@@ -253,6 +253,8 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, String
.lock()
.map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?;
let lock_path = manifest_lock_path(path);
let existed =
crate::prepare_game_creator_private_path_for_read(&lock_path, false, "manifest 锁")?;
let mut options = fs::OpenOptions::new();
options
.create(true)
@@ -277,6 +279,9 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, String
lock_path.display()
));
}
if !existed {
crate::harden_new_game_creator_private_path(&lock_path, false, "manifest 锁")?;
}
file.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|error| format!("收紧 manifest 锁权限失败:{}: {error}", lock_path.display()))?;
let path_metadata = fs::symlink_metadata(&lock_path)
@@ -340,6 +345,18 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, String
));
}
}
let existed = match fs::symlink_metadata(&lock_path) {
Ok(_) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => {
// An inherited/foreign ACL can hide an existing lock from the
// normal token. Prepare it through the formal elevation gate so
// the subsequent exclusive open does not misclassify access
// denial as a stale lock or a generic failure.
crate::prepare_game_creator_private_path_for_read(&lock_path, false, "manifest 锁")?;
true
}
};
match fs::OpenOptions::new()
.create(true)
.read(true)
@@ -350,12 +367,21 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, String
{
Ok(file) => {
validate_windows_regular_file_handle(&file, "manifest 锁")?;
// 提升权限运行时,Windows 可能用 TokenOwner=Administrators 创建新文件。
// 独占句柄与普通文件检查通过后,将这个固定锁文件收归当前 TokenUser,
// 再复核句柄并按既有规则验证 owner/DACL;不放宽旧文件的安全门禁。
crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?;
validate_windows_regular_file_handle(&file, "manifest 锁")?;
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)?;
if existed {
// Existing files may have a foreign owner or inherited DACL;
// let the strict verifier request one-shot UAC repair.
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
&lock_path, false, true,
)?;
} else {
// Only this invocation's newly created lock may initialize its
// owner. It is still revalidated after initialization.
crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?;
validate_windows_regular_file_handle(&file, "manifest 锁")?;
crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation(
&lock_path, false, false,
)?;
}
Ok(Some(file))
}
Err(error)
@@ -402,19 +428,20 @@ pub(crate) fn init_local_game_project_at(
return Err("项目名称不能为空".to_string());
}
prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?;
for relative in ["game", "assets", "memory", "memory/agents", "exports"] {
fs::create_dir_all(root.join(relative)).map_err(|error| {
format!(
"创建本地项目目录失败:{}: {error}",
root.join(relative).display()
)
})?;
let path = root.join(relative);
ensure_game_creator_private_directory_tree(&path, "本地项目目录")?;
prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?;
}
let index_path = root.join("game/index.html");
if !index_path.exists() {
fs::write(&index_path, DEFAULT_GAME_INDEX_HTML)
.map_err(|error| format!("写入默认游戏入口失败:{}: {error}", index_path.display()))?;
if !prepare_game_creator_private_path_for_read(&index_path, false, "默认游戏入口")? {
crate::write_game_creator_private_file(
&index_path,
DEFAULT_GAME_INDEX_HTML.as_bytes(),
"默认游戏入口",
)?;
}
let agent_db_path = root.join(".agent/agent.db");
@@ -430,12 +457,9 @@ pub(crate) fn init_local_game_project_at(
}
for relative in [".agent/logs", ".agent/runtime"] {
fs::create_dir_all(root.join(relative)).map_err(|error| {
format!(
"创建本地项目目录失败:{}: {error}",
root.join(relative).display()
)
})?;
let path = root.join(relative);
ensure_game_creator_private_directory_tree(&path, "本地项目目录")?;
prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?;
}
let manifest_path = root.join(".agent/manifest.json");
@@ -466,8 +490,15 @@ pub(crate) fn import_local_godot_project_at(
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
if !root.is_dir() {
return Err("Godot 工作区目录不存在或不是文件夹".to_string());
// The user explicitly selected this workspace as a project root. Route it
// through the project-root ACL entry so an owner-correct inherited DACL (or
// a foreign owner that requires UAC) is repaired before discovery; once the
// AGC marker is written, descendants use the stricter managed-root policy.
prepare_game_creator_project_root_for_read(root, true, "Godot 工作区目录")?;
let root_metadata = fs::symlink_metadata(root)
.map_err(|error| format!("读取 Godot 工作区目录失败:{}: {error}", root.display()))?;
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
return Err("Godot 工作区目录不存在或不是普通文件夹".to_string());
}
let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| {
"所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()
@@ -508,12 +539,9 @@ pub(crate) fn import_local_godot_project_at(
}
for relative in [".agent/logs", ".agent/runtime"] {
fs::create_dir_all(root.join(relative)).map_err(|error| {
format!(
"创建 Godot 项目 Agent 目录失败:{}: {error}",
root.join(relative).display()
)
})?;
let path = root.join(relative);
ensure_game_creator_private_directory_tree(&path, "Godot 项目 Agent 目录")?;
prepare_game_creator_private_path_for_read(&path, true, "Godot 项目 Agent 目录")?;
}
let mut manifest = new_game_creation_app_manifest(project_id, name);
@@ -1162,8 +1190,8 @@ pub(crate) fn mutate_manifest_at<T>(
}
let manifest_path = root.join(".agent/manifest.json");
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "manifest 目录")?;
prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?;
}
let _write_lock = acquire_manifest_write_lock(&manifest_path)?;
let (_, mut manifest) = read_or_create_manifest(root)?;
@@ -1182,6 +1210,7 @@ fn manifest_backup_path(path: &Path) -> PathBuf {
}
pub(crate) fn manifest_storage_exists(path: &Path) -> Result<bool, String> {
let _ = prepare_game_creator_private_path_for_read(path, false, "manifest")?;
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
@@ -1219,6 +1248,7 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> {
pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, String> {
let backup_path = manifest_backup_path(path);
let _ = prepare_game_creator_private_path_for_read(path, false, "manifest")?;
let (source_path, metadata, is_backup) = match fs::symlink_metadata(path) {
Ok(metadata) => (path, metadata, false),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
@@ -1255,6 +1285,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
"manifest"
};
prepare_game_creator_private_path_for_read(source_path, false, label)?;
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
@@ -1364,8 +1396,8 @@ where
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
.map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?;
ensure_game_creator_private_directory_tree(parent, "manifest 目录")?;
prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?;
}
let _write_lock = acquire_manifest_write_lock(path)?;
after_lock();
@@ -1413,13 +1445,13 @@ fn write_manifest_locked(path: &Path, manifest: &GameCreationAppManifest) -> Res
.unwrap_or_default()
.as_nanos()
));
fs::write(&temp_path, format!("{payload}\n")).map_err(|error| {
format!(
"写入 manifest 临时文件失败:{}: {error}",
temp_path.display()
)
})?;
crate::write_game_creator_private_file(
&temp_path,
format!("{payload}\n").as_bytes(),
"manifest 临时文件",
)?;
install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?;
prepare_game_creator_private_path_for_read(path, false, "manifest")?;
let installed = read_manifest(path)?;
if installed != *manifest {
return Err("manifest 安装后回读与待写入内容不一致".to_string());
@@ -5,6 +5,15 @@ pub(crate) fn read_local_game_memory_at(
scope: &str,
) -> Result<LocalGameMemoryResult, String> {
let (scope, path) = memory_file_path(root, scope)?;
let prepared = prepare_game_creator_private_path_for_read(&path, false, "游戏记忆")?;
if !prepared {
return Ok(LocalGameMemoryResult {
scope: scope.to_string(),
path: path.to_string_lossy().into_owned(),
content: String::new(),
exists: false,
});
}
match fs::read_to_string(&path) {
Ok(content) => Ok(LocalGameMemoryResult {
scope: scope.to_string(),
@@ -28,6 +37,15 @@ pub(crate) fn read_local_agent_memory_at(
) -> Result<LocalAgentMemoryResult, String> {
let relative_path = agent_role_memory_relative_path_for_task(task_id)?;
let path = resolve_local_project_path(root, &relative_path)?;
let prepared = prepare_game_creator_private_path_for_read(&path, false, "Agent 记忆")?;
if !prepared {
return Ok(LocalAgentMemoryResult {
task_id: task_id.to_string(),
path: path.to_string_lossy().into_owned(),
content: String::new(),
exists: false,
});
}
match fs::read_to_string(&path) {
Ok(content) => Ok(LocalAgentMemoryResult {
task_id: task_id.to_string(),
@@ -52,12 +70,7 @@ pub(crate) fn write_local_agent_memory_at(
) -> Result<LocalAgentMemoryResult, String> {
let relative_path = agent_role_memory_relative_path_for_task(task_id)?;
let path = resolve_local_project_path(root, &relative_path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 Agent 记忆目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入 Agent 记忆失败:{}: {error}", path.display()))?;
crate::write_game_creator_private_file(&path, content.as_bytes(), "Agent 记忆")?;
Ok(LocalAgentMemoryResult {
task_id: task_id.to_string(),
path: path.to_string_lossy().into_owned(),
@@ -72,12 +85,7 @@ pub(crate) fn write_local_game_memory_at(
content: &str,
) -> Result<LocalGameMemoryResult, String> {
let (scope, path) = memory_file_path(root, scope)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建记忆目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入记忆失败:{}: {error}", path.display()))?;
crate::write_game_creator_private_file(&path, content.as_bytes(), "游戏记忆")?;
Ok(LocalGameMemoryResult {
scope: scope.to_string(),
path: path.to_string_lossy().into_owned(),
@@ -1016,7 +1016,7 @@ fn read_stable_resource_edit_file(
reject_sensitive_project_file_read(&normalized)?;
let absolute = resolve_local_project_path(root, &normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
let (mut file, initial_metadata) = open_project_private_regular_file(&absolute, label)?;
if initial_metadata.len() > max_bytes as u64 {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
@@ -1527,9 +1527,10 @@ fn write_resource_edit_staging(
) -> Result<(), String> {
let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建资源编辑 staging 目录失败:{error}"))?;
ensure_game_creator_private_directory_tree(parent, "资源编辑 staging 目录")?;
prepare_game_creator_private_path_for_read(parent, true, "资源编辑 staging 目录")?;
}
prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?;
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err("资源编辑 staging 必须是普通文件".to_string());
@@ -1553,16 +1554,30 @@ fn write_resource_edit_staging(
options.custom_flags(libc::O_NOFOLLOW);
options.mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&path)
.map_err(|error| format!("创建资源编辑 staging 失败:{error}"))?;
if let Err(error) = harden_new_game_creator_private_path(&path, false, "资源编辑 staging") {
drop(file);
let _ = fs::remove_file(&path);
return Err(error);
}
file.write_all(bytes)
.and_then(|_| file.sync_data())
.map_err(|error| format!("写入资源编辑 staging 失败:{error}"))
.map_err(|error| {
let _ = fs::remove_file(&path);
format!("写入资源编辑 staging 失败:{error}")
})
}
fn read_resource_edit_staging(root: &Path, operation_id: &str) -> Result<Vec<u8>, String> {
let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?;
prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?;
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取资源编辑 staging 失败:{error}"))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
@@ -1576,6 +1591,10 @@ fn read_optional_resource_edit_staging(
operation_id: &str,
) -> Result<Option<Vec<u8>>, String> {
let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?;
let prepared = prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?;
if !prepared {
return Ok(None);
}
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
Err("资源编辑 staging 必须是普通文件".to_string())
@@ -2322,10 +2341,7 @@ fn resource_edit_remote_request(
},
});
}
Ok((
"/api/editor/character-animations/generations",
body,
))
Ok(("/api/editor/character-animations/generations", body))
}
LocalProjectResourceEditKind::Video => {
let mut body = serde_json::json!({
@@ -2372,10 +2388,7 @@ fn resource_edit_remote_request(
"placeholder": external_canvas_placeholder("1:1"),
});
}
Ok((
"/api/editor/audios/sound-effects/generations",
body,
))
Ok(("/api/editor/audios/sound-effects/generations", body))
}
LocalProjectResourceEditKind::BackgroundMusic => {
let mut body = serde_json::json!({
@@ -2392,10 +2405,7 @@ fn resource_edit_remote_request(
"placeholder": external_canvas_placeholder("1:1"),
});
}
Ok((
"/api/editor/audios/background-music/generations",
body,
))
Ok(("/api/editor/audios/background-music/generations", body))
}
_ => Err("当前资源类型不是远端媒体派生".to_string()),
}
@@ -3861,8 +3871,10 @@ fn install_resource_edit_final_media(
) -> Result<(), String> {
let absolute_path = resolve_local_project_path(root, relative_path)?;
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent).map_err(|error| format!("创建派生资源目录失败:{error}"))?;
ensure_game_creator_private_directory_tree(parent, "派生资源目录")?;
prepare_game_creator_private_path_for_read(parent, true, "派生资源目录")?;
}
prepare_game_creator_private_path_for_read(&absolute_path, false, "派生资源")?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
@@ -3871,12 +3883,26 @@ fn install_resource_edit_final_media(
options.custom_flags(libc::O_NOFOLLOW);
options.mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = options
.open(&absolute_path)
.map_err(|error| format!("创建派生资源失败:{error}"))?;
if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "派生资源")
{
drop(file);
let _ = fs::remove_file(&absolute_path);
return Err(error);
}
file.write_all(bytes)
.and_then(|_| file.sync_data())
.map_err(|error| format!("写入派生资源失败:{error}"))
.map_err(|error| {
let _ = fs::remove_file(&absolute_path);
format!("写入派生资源失败:{error}")
})
}
fn commit_resource_edit_asset_internal(
@@ -8273,10 +8299,7 @@ mod tests {
Some(&canvas_context),
)
.expect("build create video request");
assert_eq!(
create_video_endpoint,
"/api/editor/videos/generations"
);
assert_eq!(create_video_endpoint, "/api/editor/videos/generations");
assert!(create_video_body.get("referenceVideoSrcs").is_none());
assert_eq!(
create_video_body["projectId"],
@@ -8521,9 +8544,7 @@ mod tests {
"assetObjectId": "source-video-object"
}}}),
);
} else if request_line
.starts_with("POST /api/editor/videos/generations ")
{
} else if request_line.starts_with("POST /api/editor/videos/generations ") {
assert!(request_lower
.contains("authorization: bearer resource-editor-external-key"));
assert!(request_lower.contains("idempotency-key:"));
@@ -15,6 +15,7 @@ pub(crate) fn run_limited_local_command_at(
}
let game_index_path = root.join("game/index.html");
prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?;
let html = fs::read_to_string(&game_index_path)
.map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?;
if !html.contains("<html") && !html.contains("<!doctype html") {
@@ -24,18 +25,9 @@ pub(crate) fn run_limited_local_command_at(
let output = format!("通过:game/index.html{} 字节", html.len());
let log_path = root.join(".agent/logs/command.log");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
}
let updated_at = unix_timestamp();
let line = format!("{updated_at} command.run_limited {command_id}: {output}\n");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?;
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
record_command_run(
root,
@@ -298,6 +290,7 @@ pub(crate) fn resolve_project_verification_spec_at(
PROJECT_VERIFICATION_PACKAGE_MAX_BYTES
));
}
prepare_game_creator_private_path_for_read(&package_path, false, "package.json")?;
let package_content = fs::read_to_string(&package_path).map_err(|error| {
format!(
"读取 package.json 失败:{}: {error}",
@@ -621,10 +614,6 @@ where
let command_id = format!("project.verify.{}", spec.script);
let updated_at = unix_timestamp();
let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?;
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
}
let log_entry = format!(
"{updated_at} project.verify {} {status} manager={} exitCode={} timedOut={} durationMs={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={} sandboxEstablishment={} targetExec={} launchFailureKind={}\n{}\n",
spec.script,
@@ -644,12 +633,7 @@ where
process.launch_failure_kind.as_deref().unwrap_or("none"),
process.output
);
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(log_entry.as_bytes()))
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?;
append_game_creator_private_file(&log_path, log_entry.as_bytes(), "命令日志")?;
record_command_run(
root,
GameCreationAppCommandRunState {
@@ -719,6 +703,7 @@ pub(crate) fn read_project_permission_policy_at(
policy: ProjectPermissionPolicy::default(),
});
}
prepare_game_creator_private_path_for_read(&path, false, "项目权限策略")?;
let content = fs::read_to_string(&path)
.map_err(|error| format!("读取项目权限策略失败:{}: {error}", path.display()))?;
let policy = serde_json::from_str::<ProjectPermissionPolicy>(&content)
@@ -736,14 +721,13 @@ pub(crate) fn write_project_permission_policy_at(
validate_project_root(root)?;
let policy = normalize_project_permission_policy(policy)?;
let path = root.join(PROJECT_PERMISSION_POLICY_PATH);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目权限策略目录失败:{}: {error}", parent.display()))?;
}
let content = serde_json::to_string_pretty(&policy)
.map_err(|error| format!("序列化项目权限策略失败:{error}"))?;
fs::write(&path, format!("{content}\n"))
.map_err(|error| format!("写入项目权限策略失败:{}: {error}", path.display()))?;
crate::write_game_creator_private_file(
&path,
format!("{content}\n").as_bytes(),
"项目权限策略",
)?;
append_agent_db_record(
root,
serde_json::json!({
@@ -1495,7 +1495,7 @@ fn automatic_local_game_project_rejects_symlinked_projects_root() {
let error = create_automatic_local_game_project_at(&projects_root)
.expect_err("symlinked automatic workspace root must fail");
assert!(error.contains("普通文件夹"));
assert!(error.contains("不能包含符号链接"));
assert!(fs::read_dir(&target).expect("read target").next().is_none());
fs::remove_dir_all(container).ok();
}
+18
View File
@@ -419,6 +419,7 @@ type AppProps = {
summaries: ProjectAgentRuntimeSummary[],
) => void;
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
};
export function App({
@@ -437,6 +438,7 @@ export function App({
onPreviewChange,
onAgentRuntimeSummariesChange,
onAgentResultsChange,
onMakeGameFromApprovedGdd,
}: AppProps = {}) {
const { setTitle: setWindowTitle } = useWindowChrome();
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
@@ -10915,6 +10917,14 @@ export function App({
planGddError={planGddError}
onPlanGddRefresh={() => void hydratePlanGddState()}
onPlanGddDecision={decidePlanGdd}
onMakeGameFromApprovedGdd={
onMakeGameFromApprovedGdd
? () =>
onMakeGameFromApprovedGdd(
localProject?.projectPath ?? projectPath,
)
: undefined
}
runtime={projectSupervisorRuntime}
error={projectSupervisorRuntimeError}
runtimeByAgentId={agentRuntimeById}
@@ -11013,6 +11023,14 @@ export function App({
planGddError={planGddError}
onPlanGddRefresh={() => void hydratePlanGddState()}
onPlanGddDecision={decidePlanGdd}
onMakeGameFromApprovedGdd={
onMakeGameFromApprovedGdd
? () =>
onMakeGameFromApprovedGdd(
localProject?.projectPath ?? projectPath,
)
: undefined
}
queueAgentRunControlFromPanel={queueAgentRunControlFromPanel}
queueOrExecuteProjectIndex={queueOrExecuteProjectIndex}
queuePendingCommand={queuePendingCommand}
@@ -77,6 +77,7 @@ export function WorkspaceLauncherShell({
activeProjectAgentResults,
setAgentResults: setActiveProjectAgentResults,
resetLauncherHomeDraft,
startGameFromApprovedGdd,
createHomeDraftAutomatically,
openProject,
} = homeProject;
@@ -351,6 +352,7 @@ export function WorkspaceLauncherShell({
setActiveProjectAgentRuntimeSummaries
}
onAgentResultsChange={setActiveProjectAgentResults}
onMakeGameFromApprovedGdd={startGameFromApprovedGdd}
/>
}
/>
@@ -50,6 +50,7 @@ export type ProjectSupervisorComponentProps = {
summaries: ProjectAgentRuntimeSummary[],
) => void;
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
};
export type WorkspaceLauncherShellProps = WorkspaceLauncherProps & {
@@ -19,6 +19,7 @@ import type {
LauncherProjectContext,
LocalGameProjectRevisionStatus,
LocalProjectDirectoryStatus,
LocalProjectFileResult,
PendingNonEmptyProject,
ProjectStartMode,
TauriInvoke,
@@ -48,6 +49,27 @@ type UseHomeProjectCreationOptions = {
rememberRecentWorkspace: (projectPath: string) => void;
};
const APPROVED_GDD_BUILD_PROMPT = [
'请按照附件中的已批准 GDD 开始建造这款游戏。',
'',
'这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。',
'',
'请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。',
].join('\n');
function createTextAttachmentFile(content: string) {
const file = new File([content], 'fast_gdd.md', {
type: 'text/markdown',
lastModified: Date.now(),
});
if (typeof file.arrayBuffer !== 'function') {
Object.defineProperty(file, 'arrayBuffer', {
value: async () => new TextEncoder().encode(content).buffer,
});
}
return file;
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
@@ -77,6 +99,7 @@ export function useHomeProjectCreation({
const resetLauncherHomeDraft = useLauncherHomeDraftStore(
(state) => state.reset,
);
const approvedGddStartInFlightRef = useRef(false);
function validateProjectPath(nextProjectPath: string) {
const trimmedProjectPath = nextProjectPath.trim();
@@ -91,6 +114,50 @@ export function useHomeProjectCreation({
return trimmedProjectPath;
}
async function startGameFromApprovedGdd(nextProjectPath: string) {
if (approvedGddStartInFlightRef.current) {
return;
}
approvedGddStartInFlightRef.current = true;
const invoke = resolveTauriInvoke();
try {
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const projectPath = nextProjectPath.trim();
if (!projectPath) {
throw new Error('当前项目路径无效');
}
setStatus('正在读取已批准 GDD');
const result = await invoke<LocalProjectFileResult>(
'read_local_project_file',
{
projectPath,
relativePath: 'game/fast_gdd.md',
commandId: 'file.read',
},
);
const file = createTextAttachmentFile(result.content);
await createHomeDraftAutomatically(
{
creationType: 'game',
prompt: APPROVED_GDD_BUILD_PROMPT,
attachments: [
{
id: `approved-gdd-${Date.now().toString(36)}`,
file,
},
],
},
'direct-build',
);
} finally {
approvedGddStartInFlightRef.current = false;
}
}
function enterProjectDevelopment(context: LauncherProjectContext) {
setCurrentProjectContext(context);
setActiveProjectPreview(context.manifest.preview ?? null);
@@ -600,6 +667,7 @@ export function useHomeProjectCreation({
projectBusy: projectAction !== null,
pendingNonEmptyProject,
resetLauncherHomeDraft,
startGameFromApprovedGdd,
createHomeDraft,
createHomeDraftAutomatically,
openProject,
@@ -42,6 +42,7 @@ type GddApprovalCardProps = {
action: PlanGddDecisionAction,
comment: string | null,
) => Promise<void>;
onMakeGame?: () => Promise<void>;
};
const stateLabels: Record<PlanGddStateViewV1['state'], string> = {
@@ -82,6 +83,7 @@ export function PlanGddSurface({
error,
onRefresh,
onDecision,
onMakeGame,
}: GddApprovalCardProps & { active?: boolean; projectPath: string }) {
const showProgress = stageProgressVisible(state, active);
const showCard = approvalCardVisible(state);
@@ -98,6 +100,7 @@ export function PlanGddSurface({
state={state}
active={active}
projectPath={projectPath}
onMakeGame={onMakeGame}
/>
) : null}
{showCard ? (
@@ -118,14 +121,18 @@ export function PlanGddStageProgress({
state,
active = false,
projectPath = '',
onMakeGame,
}: {
state: PlanGddStateViewV1 | null;
active?: boolean;
projectPath?: string;
onMakeGame?: () => Promise<void>;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const [openError, setOpenError] = useState('');
const [opening, setOpening] = useState(false);
const [makingGame, setMakingGame] = useState(false);
const [makeGameError, setMakeGameError] = useState('');
if (!state || !stageProgressVisible(state, active)) {
return null;
}
@@ -198,6 +205,25 @@ export function PlanGddStageProgress({
>
{opening ? '正在打开' : '打开文件'}
</button>
{onMakeGame ? (
<button
type="button"
disabled={opening || makingGame || !projectPath.trim()}
onClick={() => {
setMakeGameError('');
setMakingGame(true);
void onMakeGame()
.catch((error: unknown) =>
setMakeGameError(
error instanceof Error ? error.message : String(error),
),
)
.finally(() => setMakingGame(false));
}}
>
{makingGame ? '正在启动' : '做成游戏'}
</button>
) : null}
</div>
{openError ? (
<small
@@ -207,6 +233,14 @@ export function PlanGddStageProgress({
{openError}
</small>
) : null}
{makeGameError ? (
<small
className="plan-gdd-stage-progress__delivery-error"
role="alert"
>
{makeGameError}
</small>
) : null}
</div>
) : null}
{detailsOpen && deliveredGdd ? (
@@ -68,6 +68,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
action: PlanGddDecisionAction,
comment: string | null,
) => Promise<void>;
onMakeGameFromApprovedGdd?: () => Promise<void>;
};
export function ProjectSupervisorView({
@@ -99,6 +100,7 @@ export function ProjectSupervisorView({
planGddError,
onPlanGddRefresh,
onPlanGddDecision,
onMakeGameFromApprovedGdd,
...runtimePanelProps
}: ProjectSupervisorViewProps) {
const submitLabel = needsUserInput
@@ -121,6 +123,7 @@ export function ProjectSupervisorView({
error={planGddError}
onRefresh={onPlanGddRefresh}
onDecision={onPlanGddDecision}
onMakeGame={onMakeGameFromApprovedGdd}
/>
<div
ref={messagesRef}

Some files were not shown because too many files have changed in this diff Show More