Merge remote-tracking branch 'origin/master' into feat/hide-llm-router-and-stream-transport
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/config.rs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
@@ -34,6 +36,8 @@ pub(crate) use codex_cli::{
|
||||
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
|
||||
};
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
@@ -1950,8 +1950,15 @@ impl CodexAppServerConnection {
|
||||
request: LlmRunRequest,
|
||||
on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None)
|
||||
.await
|
||||
self.run_turn_with_direct_observer(
|
||||
snapshot,
|
||||
llm,
|
||||
request,
|
||||
on_agent_message_delta,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_turn_with_direct_observer(
|
||||
@@ -1961,6 +1968,7 @@ impl CodexAppServerConnection {
|
||||
request: LlmRunRequest,
|
||||
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
mut audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
let _turn_guard = self.inner.turn_gate.lock().await;
|
||||
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
|
||||
@@ -2130,6 +2138,11 @@ impl CodexAppServerConnection {
|
||||
completed,
|
||||
¶ms,
|
||||
);
|
||||
if completed {
|
||||
if let Some(audit) = audit.as_mut() {
|
||||
audit.observe_item(¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
if item_type == "agentMessage" {
|
||||
if let Some(text) = item
|
||||
@@ -2841,8 +2854,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
) -> Result<String, String> {
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None)
|
||||
.await
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
@@ -2856,6 +2875,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
Some(observer),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2906,11 +2926,12 @@ fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8])
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root: &std::path::Path,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<String, String> {
|
||||
// Resolve project authority before deriving the pool/thread identity. A
|
||||
// caller may hold a stable symlink path whose target changes between
|
||||
@@ -2962,7 +2983,7 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
connection
|
||||
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer)
|
||||
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
|
||||
.await
|
||||
.map(|value| value.text)
|
||||
.map_err(|error| error.to_string())
|
||||
@@ -4290,6 +4311,7 @@ while IFS= read -r line; do :; done
|
||||
tool_request(),
|
||||
Some(&mut on_delta),
|
||||
Some(&mut observer),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("run fake app-server turn");
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Direct Codex 本轮附件 sidecar:Home 与 Project 共用同一 DTO 和渲染函数。
|
||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||
|
||||
const HOME_ATTACHMENT_HEADER: &str =
|
||||
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
|
||||
const PROJECT_ATTACHMENT_HEADER: &str =
|
||||
"[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]";
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexTurnAttachment {
|
||||
pub(crate) name: String,
|
||||
pub(crate) media_type: String,
|
||||
#[serde(default)]
|
||||
pub(crate) size: u64,
|
||||
#[serde(default)]
|
||||
pub(crate) local_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) status: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_attachment_name(value: &str) -> String {
|
||||
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
|
||||
let sanitized = basename
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS)
|
||||
.collect::<String>();
|
||||
if sanitized.is_empty() {
|
||||
"未命名附件".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_attachment_media_type(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty()
|
||||
|| value.chars().any(|character| {
|
||||
!(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_'))
|
||||
})
|
||||
{
|
||||
"application/octet-stream".to_string()
|
||||
} else {
|
||||
value
|
||||
.chars()
|
||||
.take(MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> {
|
||||
match value.map(str::trim) {
|
||||
Some("imported") => Some("imported"),
|
||||
Some("failed") => Some("failed"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS
|
||||
|| trimmed.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let normalized = trimmed.replace('\\', "/");
|
||||
if normalized.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut chars = normalized.chars();
|
||||
if let (Some(letter), Some(':')) = (chars.next(), chars.next()) {
|
||||
if letter.is_ascii_alphabetic() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut segments = Vec::new();
|
||||
for segment in normalized.split('/') {
|
||||
if segment.is_empty() || segment == "." {
|
||||
continue;
|
||||
}
|
||||
if segment == ".." {
|
||||
return None;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
let first = segments.first()?;
|
||||
if *first == ".agent" || *first == ".git" {
|
||||
return None;
|
||||
}
|
||||
let path = segments.join("/");
|
||||
if path.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS {
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool {
|
||||
attachments.iter().any(|attachment| {
|
||||
attachment
|
||||
.local_path
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
|| sanitize_attachment_status(attachment.status.as_deref()).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
fn render_project_attachment_line(attachment: &DirectCodexTurnAttachment) -> String {
|
||||
let name = sanitize_attachment_name(&attachment.name);
|
||||
let media_type = sanitize_attachment_media_type(&attachment.media_type);
|
||||
let raw_path = attachment
|
||||
.local_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let sanitized_path = raw_path.and_then(sanitize_attachment_local_path);
|
||||
let path_rejected = raw_path.is_some() && sanitized_path.is_none();
|
||||
let status = if path_rejected {
|
||||
Some("failed")
|
||||
} else {
|
||||
sanitize_attachment_status(attachment.status.as_deref())
|
||||
};
|
||||
|
||||
let mut parts = vec![format!("原文件名:{name}")];
|
||||
if let Some(path) = sanitized_path {
|
||||
parts.push(format!("项目路径:{path}"));
|
||||
}
|
||||
parts.push(format!("类型:{media_type}"));
|
||||
parts.push(format!("大小:{} 字节", attachment.size));
|
||||
if let Some(status) = status {
|
||||
parts.push(format!("状态:{status}"));
|
||||
}
|
||||
format!("- {}", parts.join(";"))
|
||||
}
|
||||
|
||||
pub(crate) fn render_direct_codex_user_prompt(
|
||||
prompt: &str,
|
||||
attachments: &[DirectCodexTurnAttachment],
|
||||
) -> Result<String, String> {
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() && attachments.is_empty() {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
if attachments.is_empty() {
|
||||
return Ok(prompt.to_string());
|
||||
}
|
||||
|
||||
let mut sections = Vec::new();
|
||||
if !prompt.is_empty() {
|
||||
sections.push(prompt.to_string());
|
||||
sections.push(String::new());
|
||||
}
|
||||
if attachments_use_project_mapping(attachments) {
|
||||
sections.push(PROJECT_ATTACHMENT_HEADER.to_string());
|
||||
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
|
||||
sections.push(render_project_attachment_line(attachment));
|
||||
}
|
||||
} else {
|
||||
sections.push(HOME_ATTACHMENT_HEADER.to_string());
|
||||
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
|
||||
sections.push(format!(
|
||||
"- {};类型:{};大小:{} 字节",
|
||||
sanitize_attachment_name(&attachment.name),
|
||||
sanitize_attachment_media_type(&attachment.media_type),
|
||||
attachment.size,
|
||||
));
|
||||
}
|
||||
}
|
||||
if attachments.len() > MAX_DIRECT_CODEX_ATTACHMENTS {
|
||||
sections.push(format!(
|
||||
"- 另有 {} 个附件未展开",
|
||||
attachments.len() - MAX_DIRECT_CODEX_ATTACHMENTS
|
||||
));
|
||||
}
|
||||
Ok(sections.join("\n"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn home_attachment(name: &str, media_type: &str, size: u64) -> DirectCodexTurnAttachment {
|
||||
DirectCodexTurnAttachment {
|
||||
name: name.to_string(),
|
||||
media_type: media_type.to_string(),
|
||||
size,
|
||||
local_path: None,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_attachment(
|
||||
name: &str,
|
||||
media_type: &str,
|
||||
size: u64,
|
||||
local_path: Option<&str>,
|
||||
status: Option<&str>,
|
||||
) -> DirectCodexTurnAttachment {
|
||||
DirectCodexTurnAttachment {
|
||||
name: name.to_string(),
|
||||
media_type: media_type.to_string(),
|
||||
size,
|
||||
local_path: local_path.map(str::to_string),
|
||||
status: status.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_prompt_is_trimmed_and_empty_prompt_without_attachments_is_rejected() {
|
||||
assert_eq!(
|
||||
render_direct_codex_user_prompt(" 你好 ", &[]).expect("plain prompt"),
|
||||
"你好"
|
||||
);
|
||||
assert_eq!(
|
||||
render_direct_codex_user_prompt("", &[]).expect_err("empty prompt"),
|
||||
"聊天内容不能为空"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() {
|
||||
let attachments = vec![home_attachment(
|
||||
r"C:\Users\secret\角色参考.png",
|
||||
"image/png\nBearer secret",
|
||||
3,
|
||||
)];
|
||||
|
||||
let prompt = render_direct_codex_user_prompt(" 先看看这个附件 ", &attachments)
|
||||
.expect("home prompt");
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"先看看这个附件\n\n[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]\n- 角色参考.png;类型:application/octet-stream;大小:3 字节"
|
||||
);
|
||||
assert!(!prompt.contains("C:\\Users"));
|
||||
assert!(!prompt.contains("\nBearer secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() {
|
||||
assert_eq!(
|
||||
render_direct_codex_user_prompt("你好", &[]).expect("plain prompt"),
|
||||
"你好"
|
||||
);
|
||||
let attachments = (0..MAX_DIRECT_CODEX_ATTACHMENTS + 2)
|
||||
.map(|index| home_attachment(&format!("asset-{index}.png"), "image/png", index as u64))
|
||||
.collect::<Vec<_>>();
|
||||
let prompt =
|
||||
render_direct_codex_user_prompt("看看素材", &attachments).expect("bounded attachments");
|
||||
assert!(prompt.contains("asset-7.png"));
|
||||
assert!(!prompt.contains("asset-8.png"));
|
||||
assert!(prompt.contains("另有 2 个附件未展开"));
|
||||
assert!(render_direct_codex_user_prompt("", &attachments).is_ok());
|
||||
assert!(render_direct_codex_user_prompt("", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_json_without_path_or_status_still_deserializes() {
|
||||
let attachment: DirectCodexTurnAttachment =
|
||||
serde_json::from_str(r#"{"name":"a.png","mediaType":"image/png","size":3}"#)
|
||||
.expect("home json");
|
||||
assert!(attachment.local_path.is_none());
|
||||
assert!(attachment.status.is_none());
|
||||
assert_eq!(attachment.size, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_prompt_keeps_user_text_and_maps_original_name_to_project_path() {
|
||||
let attachments = vec![project_attachment(
|
||||
"fast_gdd.md",
|
||||
"text/markdown",
|
||||
7944,
|
||||
Some("assets/uploads/upload-1788083777445-fast_gdd.md"),
|
||||
Some("imported"),
|
||||
)];
|
||||
let prompt = render_direct_codex_user_prompt("请根据附件做游戏", &attachments)
|
||||
.expect("project prompt");
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"请根据附件做游戏\n\n[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]\n- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported"
|
||||
);
|
||||
assert!(!prompt.contains("GDD"));
|
||||
assert!(!prompt.contains("规格"));
|
||||
assert!(!prompt.contains("权威"));
|
||||
assert!(!prompt.contains("必须读取"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_png_and_markdown_share_the_same_line_shape() {
|
||||
let attachments = vec![
|
||||
project_attachment(
|
||||
"角色参考.png",
|
||||
"image/png",
|
||||
12,
|
||||
Some("assets/uploads/upload-1-角色参考.png"),
|
||||
Some("imported"),
|
||||
),
|
||||
project_attachment(
|
||||
"notes.md",
|
||||
"text/markdown",
|
||||
80,
|
||||
Some("assets/uploads/upload-2-notes.md"),
|
||||
Some("imported"),
|
||||
),
|
||||
];
|
||||
let prompt =
|
||||
render_direct_codex_user_prompt("看这两个附件", &attachments).expect("mixed types");
|
||||
let lines: Vec<_> = prompt
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("- 原文件名:"))
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
for line in &lines {
|
||||
assert!(line.contains(";项目路径:assets/uploads/"));
|
||||
assert!(line.contains(";类型:"));
|
||||
assert!(line.contains(";大小:"));
|
||||
assert!(line.contains(";状态:imported"));
|
||||
}
|
||||
assert!(lines[0].contains("角色参考.png"));
|
||||
assert!(lines[0].contains("image/png"));
|
||||
assert!(lines[1].contains("notes.md"));
|
||||
assert!(lines[1].contains("text/markdown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_attachment_without_path_has_status_and_no_error_body() {
|
||||
let attachments = vec![project_attachment(
|
||||
"lost.bin",
|
||||
"application/octet-stream",
|
||||
2,
|
||||
None,
|
||||
Some("failed"),
|
||||
)];
|
||||
let prompt =
|
||||
render_direct_codex_user_prompt("附件失败了", &attachments).expect("failed prompt");
|
||||
assert!(prompt.contains(PROJECT_ATTACHMENT_HEADER));
|
||||
assert!(prompt.contains("原文件名:lost.bin"));
|
||||
assert!(prompt.contains("状态:failed"));
|
||||
assert!(!prompt.contains("项目路径:"));
|
||||
assert!(!prompt.contains("error"));
|
||||
assert!(!prompt.contains("失败原因"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_local_paths_are_omitted_and_marked_failed() {
|
||||
let attachments = vec![
|
||||
project_attachment(
|
||||
"up.md",
|
||||
"text/markdown",
|
||||
1,
|
||||
Some("../secret.md"),
|
||||
Some("imported"),
|
||||
),
|
||||
project_attachment(
|
||||
"agent.md",
|
||||
"text/markdown",
|
||||
1,
|
||||
Some(".agent/conversations/x.md"),
|
||||
Some("imported"),
|
||||
),
|
||||
project_attachment(
|
||||
"abs.md",
|
||||
"text/markdown",
|
||||
1,
|
||||
Some(r"C:\tmp\abs.md"),
|
||||
Some("imported"),
|
||||
),
|
||||
project_attachment(
|
||||
"unix.md",
|
||||
"text/markdown",
|
||||
1,
|
||||
Some("/tmp/unix.md"),
|
||||
Some("imported"),
|
||||
),
|
||||
];
|
||||
let prompt =
|
||||
render_direct_codex_user_prompt("非法路径", &attachments).expect("illegal paths");
|
||||
assert!(!prompt.contains("../secret.md"));
|
||||
assert!(!prompt.contains(".agent/conversations/x.md"));
|
||||
assert!(!prompt.contains("C:\\tmp\\abs.md"));
|
||||
assert!(!prompt.contains("/tmp/unix.md"));
|
||||
assert!(!prompt.contains("项目路径:"));
|
||||
assert_eq!(prompt.matches("状态:failed").count(), 4);
|
||||
assert!(!prompt.contains("状态:imported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_with_project_attachments_still_renders() {
|
||||
let attachments = vec![project_attachment(
|
||||
"ref.png",
|
||||
"image/png",
|
||||
4,
|
||||
Some("assets/uploads/upload-1-ref.png"),
|
||||
Some("imported"),
|
||||
)];
|
||||
let prompt = render_direct_codex_user_prompt(" ", &attachments).expect("empty user text");
|
||||
assert!(prompt.starts_with(PROJECT_ATTACHMENT_HEADER));
|
||||
assert!(prompt.contains("项目路径:assets/uploads/upload-1-ref.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_error_field_is_not_forwarded_to_the_model() {
|
||||
let attachment: DirectCodexTurnAttachment = serde_json::from_str(
|
||||
r#"{"name":"a.md","mediaType":"text/markdown","size":1,"status":"failed","error":"secret boom"}"#,
|
||||
)
|
||||
.expect("extra error field");
|
||||
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
|
||||
assert!(!prompt.contains("secret boom"));
|
||||
assert!(!prompt.contains("error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_status_keeps_home_attachment_metadata_shape() {
|
||||
let attachment =
|
||||
project_attachment("pending.md", "text/markdown", 1, None, Some("pending"));
|
||||
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
|
||||
assert!(prompt.contains(HOME_ATTACHMENT_HEADER));
|
||||
assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER));
|
||||
assert!(!prompt.contains("状态:"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,6 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
|
||||
const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8;
|
||||
const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||
const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
|
||||
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
|
||||
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
|
||||
@@ -3766,14 +3763,6 @@ pub(crate) fn build_direct_codex_home_system_prompt() -> String {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexHomeAttachment {
|
||||
name: String,
|
||||
media_type: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexHomeReply {
|
||||
@@ -3806,78 +3795,11 @@ fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply {
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_home_attachment_name(value: &str) -> String {
|
||||
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
|
||||
let sanitized = basename
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS)
|
||||
.collect::<String>();
|
||||
if sanitized.is_empty() {
|
||||
"未命名附件".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_home_attachment_media_type(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty()
|
||||
|| value.chars().any(|character| {
|
||||
!(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_'))
|
||||
})
|
||||
{
|
||||
"application/octet-stream".to_string()
|
||||
} else {
|
||||
value
|
||||
.chars()
|
||||
.take(MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_direct_codex_home_user_prompt(
|
||||
prompt: &str,
|
||||
attachments: &[DirectCodexHomeAttachment],
|
||||
) -> Result<String, String> {
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() && attachments.is_empty() {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
if attachments.is_empty() {
|
||||
return Ok(prompt.to_string());
|
||||
}
|
||||
|
||||
let mut sections = Vec::new();
|
||||
if !prompt.is_empty() {
|
||||
sections.push(prompt.to_string());
|
||||
sections.push(String::new());
|
||||
}
|
||||
sections.push(
|
||||
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]".to_string(),
|
||||
);
|
||||
for attachment in attachments.iter().take(MAX_DIRECT_HOME_ATTACHMENTS) {
|
||||
sections.push(format!(
|
||||
"- {};类型:{};大小:{} 字节",
|
||||
direct_codex_home_attachment_name(&attachment.name),
|
||||
direct_codex_home_attachment_media_type(&attachment.media_type),
|
||||
attachment.size,
|
||||
));
|
||||
}
|
||||
if attachments.len() > MAX_DIRECT_HOME_ATTACHMENTS {
|
||||
sections.push(format!(
|
||||
"- 另有 {} 个附件未展开",
|
||||
attachments.len() - MAX_DIRECT_HOME_ATTACHMENTS
|
||||
));
|
||||
}
|
||||
Ok(sections.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) async fn run_direct_game_creator_home_turn(
|
||||
prompt: &str,
|
||||
attachments: &[DirectCodexHomeAttachment],
|
||||
attachments: &[DirectCodexTurnAttachment],
|
||||
) -> Result<DirectCodexHomeReply, String> {
|
||||
let user_prompt = render_direct_codex_home_user_prompt(prompt, attachments)?;
|
||||
let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?;
|
||||
direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt)
|
||||
.await
|
||||
.map(parse_direct_codex_home_reply)
|
||||
@@ -3907,6 +3829,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
|
||||
prompt,
|
||||
creation_type,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3916,6 +3839,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
prompt: &str,
|
||||
creation_type: Option<&str>,
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<String, String> {
|
||||
if !root.is_absolute() || !root.is_dir() {
|
||||
return Err("当前项目目录不存在或不是绝对路径".to_string());
|
||||
@@ -3931,7 +3855,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
if let Some(emitter) = turn_emitter {
|
||||
emitter.emit("accepted", Some("request-accepted"), None);
|
||||
}
|
||||
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await {
|
||||
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await
|
||||
{
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(failure) => {
|
||||
let error = record_direct_codex_turn_failure(root, failure);
|
||||
@@ -3948,6 +3873,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
prompt: &str,
|
||||
creation_type: Option<&str>,
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<String, DirectCodexTurnFailure> {
|
||||
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
|
||||
if let Some(emitter) = turn_emitter {
|
||||
@@ -3986,15 +3912,23 @@ async fn run_direct_game_creator_turn_inner(
|
||||
);
|
||||
}
|
||||
};
|
||||
direct_game_creator_codex_chat_at_with_observer(
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
&mut observer,
|
||||
Some(&mut observer),
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
None,
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
|
||||
let visible_reply = project_direct_codex_visible_text(root, &reply).ok_or_else(|| {
|
||||
@@ -4331,6 +4265,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
prompt: String,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
@@ -4339,21 +4274,47 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||
})?;
|
||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||
let reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
let mut audit = DirectCodexTurnAudit::start(
|
||||
root,
|
||||
&turn_id,
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let user_prompt = match render_direct_codex_user_prompt(
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
creation_type.as_deref(),
|
||||
Some(&turn_emitter),
|
||||
Some(&mut audit),
|
||||
)
|
||||
.await?;
|
||||
persist_direct_codex_assistant_reply_at(root, &turn_id, &reply).map_err(|error| {
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
|
||||
audit.finish(false);
|
||||
turn_emitter.emit("failed", Some("none"), None);
|
||||
redact_agent_runtime_error(
|
||||
return Err(redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
|
||||
500,
|
||||
)
|
||||
})?;
|
||||
));
|
||||
}
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
||||
Ok(reply)
|
||||
}
|
||||
@@ -4361,7 +4322,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_home_direct_codex(
|
||||
prompt: String,
|
||||
attachments: Option<Vec<DirectCodexHomeAttachment>>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<DirectCodexHomeReply, String> {
|
||||
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
|
||||
}
|
||||
@@ -4563,47 +4524,6 @@ mod tests {
|
||||
assert!(!prompt.contains("game/index.html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() {
|
||||
let attachments = vec![DirectCodexHomeAttachment {
|
||||
name: r"C:\Users\secret\角色参考.png".to_string(),
|
||||
media_type: "image/png\nBearer secret".to_string(),
|
||||
size: 3,
|
||||
}];
|
||||
|
||||
let prompt = render_direct_codex_home_user_prompt(" 先看看这个附件 ", &attachments)
|
||||
.expect("home prompt");
|
||||
|
||||
assert!(prompt.starts_with("先看看这个附件\n\n[首页附件说明"));
|
||||
assert!(prompt.contains("角色参考.png"));
|
||||
assert!(prompt.contains("类型:application/octet-stream"));
|
||||
assert!(prompt.contains("大小:3 字节"));
|
||||
assert!(!prompt.contains("C:\\Users"));
|
||||
assert!(!prompt.contains("\nBearer secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() {
|
||||
assert_eq!(
|
||||
render_direct_codex_home_user_prompt("你好", &[]).expect("plain prompt"),
|
||||
"你好"
|
||||
);
|
||||
let attachments = (0..MAX_DIRECT_HOME_ATTACHMENTS + 2)
|
||||
.map(|index| DirectCodexHomeAttachment {
|
||||
name: format!("asset-{index}.png"),
|
||||
media_type: "image/png".to_string(),
|
||||
size: index as u64,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let prompt = render_direct_codex_home_user_prompt("看看素材", &attachments)
|
||||
.expect("bounded attachments");
|
||||
assert!(prompt.contains("asset-7.png"));
|
||||
assert!(!prompt.contains("asset-8.png"));
|
||||
assert!(prompt.contains("另有 2 个附件未展开"));
|
||||
assert!(render_direct_codex_home_user_prompt("", &attachments).is_ok());
|
||||
assert!(render_direct_codex_home_user_prompt("", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_create_marker_is_accepted_only_as_the_first_reply_token() {
|
||||
let requested = parse_direct_codex_home_reply(format!(
|
||||
|
||||
+82
-72
@@ -715,36 +715,6 @@ fn build_game_creator_agent_background_tool_plan_request_at(
|
||||
))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
task: &str,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
loop_index: usize,
|
||||
) -> Result<
|
||||
(
|
||||
GameCreatorLlmConfig,
|
||||
String,
|
||||
LlmRunRequest,
|
||||
String,
|
||||
AgentRuntimeToolPlanRequestSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
build_game_creator_agent_background_tool_plan_request_at(
|
||||
root,
|
||||
None,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
task,
|
||||
observations,
|
||||
loop_index,
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked(
|
||||
root: &Path,
|
||||
project_lock: &ProjectWriteLock,
|
||||
@@ -972,17 +942,20 @@ mod tests {
|
||||
use crate::{update_manifest_task_status_at, GameCreationAppTaskStatus};
|
||||
|
||||
use super::{
|
||||
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
|
||||
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
|
||||
build_game_creator_agent_background_final_reply_request,
|
||||
build_game_creator_agent_background_tool_plan_request,
|
||||
build_game_creator_agent_background_tool_plan_request_locked,
|
||||
game_creator_agent_context_preload_notice,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at,
|
||||
new_game_creation_app_seed_tasks, provider_command_exec_contract,
|
||||
provider_command_start_contract, render_relaxed_autonomous_manifest_ready_task_background_prompt,
|
||||
provider_command_start_contract,
|
||||
render_relaxed_autonomous_manifest_ready_task_background_prompt,
|
||||
required_runtime_prompt_section, start_game_creator_agent_runtime_task_at,
|
||||
AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft,
|
||||
AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan,
|
||||
AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig,
|
||||
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT,
|
||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
|
||||
AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND,
|
||||
@@ -996,6 +969,40 @@ mod tests {
|
||||
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
|
||||
};
|
||||
|
||||
fn build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
root: &std::path::Path,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
task: &str,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
loop_index: usize,
|
||||
) -> Result<
|
||||
(
|
||||
GameCreatorLlmConfig,
|
||||
String,
|
||||
platform_llm::LlmRunRequest,
|
||||
String,
|
||||
AgentRuntimeToolPlanRequestSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
|
||||
root,
|
||||
"test.provider_request.build.tool_plan",
|
||||
)?;
|
||||
build_game_creator_agent_background_tool_plan_request_locked(
|
||||
root,
|
||||
&lock,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
task,
|
||||
observations,
|
||||
loop_index,
|
||||
)
|
||||
}
|
||||
|
||||
fn native_input_required_fields(
|
||||
request: &platform_llm::LlmRunRequest,
|
||||
tool: &str,
|
||||
@@ -1066,7 +1073,7 @@ mod tests {
|
||||
summary: "结构化计划更新被 Runtime 拒绝".to_string(),
|
||||
detail: Some("计划状态回退".to_string()),
|
||||
};
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
@@ -1155,16 +1162,17 @@ mod tests {
|
||||
// Relaxed orchestration does not convert an idle planning counter into
|
||||
// a tool-removal gate; the Provider remains free to choose its next
|
||||
// action.
|
||||
let (_, _, baseline, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
&state.run_id,
|
||||
&state.current_task,
|
||||
&[],
|
||||
1,
|
||||
)
|
||||
.expect("build baseline request");
|
||||
let (_, _, baseline, _, _) =
|
||||
build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
&state.run_id,
|
||||
&state.current_task,
|
||||
&[],
|
||||
1,
|
||||
)
|
||||
.expect("build baseline request");
|
||||
assert!(baseline
|
||||
.function_tools
|
||||
.iter()
|
||||
@@ -1175,7 +1183,7 @@ mod tests {
|
||||
crate::agent::write_game_creator_agent_runtime_state(&root, &idle_state)
|
||||
.expect("persist idle rounds");
|
||||
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
@@ -1256,7 +1264,7 @@ mod tests {
|
||||
vec!["交付当前 manifest task".to_string()],
|
||||
)
|
||||
.expect("start autonomous ready child task");
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
agent_id,
|
||||
&state.session_id,
|
||||
@@ -1506,7 +1514,7 @@ mod tests {
|
||||
vec!["冻结 Goal Contract".to_string()],
|
||||
)
|
||||
.expect("start trusted root");
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
let (_, _, request, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
@@ -1566,7 +1574,7 @@ mod tests {
|
||||
)
|
||||
.expect("start plan root");
|
||||
|
||||
let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
let (_, _, first, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
@@ -1633,7 +1641,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.expect("freeze plan contract");
|
||||
let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
let (_, _, later, _, _) = build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
@@ -1719,7 +1727,7 @@ mod tests {
|
||||
)
|
||||
.expect("start supervisor runtime state");
|
||||
let (_, _, supervisor_request, _, _) =
|
||||
build_game_creator_agent_background_tool_plan_request(
|
||||
build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&supervisor_state.session_id,
|
||||
@@ -1865,16 +1873,17 @@ mod tests {
|
||||
vec!["核对普通说明".to_string()],
|
||||
)
|
||||
.expect("start ordinary runtime state");
|
||||
let (_, _, ordinary_request, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
&root,
|
||||
"code-prototype",
|
||||
&ordinary_state.session_id,
|
||||
&ordinary_state.run_id,
|
||||
&ordinary_state.current_task,
|
||||
&[],
|
||||
0,
|
||||
)
|
||||
.expect("build ordinary planning request");
|
||||
let (_, _, ordinary_request, _, _) =
|
||||
build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
"code-prototype",
|
||||
&ordinary_state.session_id,
|
||||
&ordinary_state.run_id,
|
||||
&ordinary_state.current_task,
|
||||
&[],
|
||||
0,
|
||||
)
|
||||
.expect("build ordinary planning request");
|
||||
assert!(ordinary_request.messages[0]
|
||||
.content
|
||||
.contains("你正在使用 Genarrative AI 游戏创作多智能体 Runtime"));
|
||||
@@ -1989,16 +1998,17 @@ mod tests {
|
||||
vec!["读取需求并准备澄清".to_string()],
|
||||
)
|
||||
.expect("start planning child");
|
||||
let (_, _, planning_request, _, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&planning_state.session_id,
|
||||
&planning_state.run_id,
|
||||
&planning_state.current_task,
|
||||
&[],
|
||||
0,
|
||||
)
|
||||
.expect("build planning request");
|
||||
let (_, _, planning_request, _, _) =
|
||||
build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&planning_state.session_id,
|
||||
&planning_state.run_id,
|
||||
&planning_state.current_task,
|
||||
&[],
|
||||
0,
|
||||
)
|
||||
.expect("build planning request");
|
||||
let planning_system_prompt = &planning_request.messages[0].content;
|
||||
let planning_brief_marker = "你是“立项策划 Agent”(`agentId=project-planning`)";
|
||||
assert!(planning_system_prompt.contains(planning_brief_marker));
|
||||
@@ -2074,7 +2084,7 @@ mod tests {
|
||||
)
|
||||
.expect("start supervisor");
|
||||
let (_, _, supervisor_request, _, _) =
|
||||
build_game_creator_agent_background_tool_plan_request(
|
||||
build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&supervisor_state.session_id,
|
||||
@@ -2183,7 +2193,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
let (_, _, request, _, request_snapshot) =
|
||||
build_game_creator_agent_background_tool_plan_request(
|
||||
build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&state.session_id,
|
||||
@@ -2209,7 +2219,7 @@ mod tests {
|
||||
),
|
||||
});
|
||||
let (_, _, _, _, settled_request_snapshot) =
|
||||
build_game_creator_agent_background_tool_plan_request(
|
||||
build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&state.session_id,
|
||||
|
||||
@@ -216,23 +216,6 @@ pub(crate) fn pending_matches_receipt(
|
||||
/// Construct the independent planning pending projection after an external
|
||||
/// acceptance gate has succeeded. M1C-1 does not decide whether the gate
|
||||
/// passed; the caller must supply that fact and the exact GDD identity.
|
||||
pub(crate) fn create_plan_gdd_approval_pending_at(
|
||||
root: &Path,
|
||||
gdd: &PlanGddV1,
|
||||
) -> Result<(), PlanningStorageError> {
|
||||
if !crate::config::game_creator_planning_capability_enabled()
|
||||
.map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))?
|
||||
{
|
||||
return Err(approval_error(
|
||||
"PLAN_CAPABILITY_DISABLED",
|
||||
"立项策划能力当前已停用",
|
||||
));
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "planning.approval-pending.create")
|
||||
.map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?;
|
||||
create_plan_gdd_approval_pending_locked(root, gdd)
|
||||
}
|
||||
|
||||
pub(crate) fn create_plan_gdd_approval_pending_locked(
|
||||
root: &Path,
|
||||
gdd: &PlanGddV1,
|
||||
@@ -2357,13 +2340,6 @@ pub(crate) fn plan_gdd_typed_completion_blocker_at_locked(
|
||||
),
|
||||
));
|
||||
}
|
||||
if session.phase == "recovery_required" {
|
||||
return Some(plan_gdd_completion_blocker(
|
||||
"needs-reconciliation",
|
||||
"planning session 仍处于 recovery_required,不能收束任务",
|
||||
format!("gddVersion={}", latest.version),
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
+13
-25
@@ -2408,8 +2408,7 @@ pub(crate) fn validate_plan_session_successor(
|
||||
&& next.active_run_id.is_some()
|
||||
&& next.last_run_id == next.active_run_id.clone().unwrap_or_default();
|
||||
if next.run_profile_binding_fingerprint != previous.run_profile_binding_fingerprint
|
||||
&& (!active_run_changed
|
||||
|| !matches!(next.phase.as_str(), "collecting" | "revision_requested"))
|
||||
&& !active_run_changed
|
||||
{
|
||||
return Err(conflict(
|
||||
"session 只有在绑定新的 active planning child 时才能更换 Run Profile binding fingerprint",
|
||||
@@ -2712,18 +2711,6 @@ pub(crate) fn canonical_plan_submit_gdd_input_bytes(
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_plan_submit_gdd_input_bytes(
|
||||
bytes: &[u8],
|
||||
) -> Result<PlanSubmitGddInputV1, PlanningStorageError> {
|
||||
let value = parse_strict_canonical::<PlanSubmitGddInputV1>(
|
||||
bytes,
|
||||
"plan.submit_gdd input",
|
||||
PLAN_GDD_MAX_BYTES,
|
||||
)?;
|
||||
validate_plan_submit_gdd_input(&value)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_plan_gdd_chain(values: &[PlanGddV1]) -> Result<(), PlanningStorageError> {
|
||||
if values.len() > PLAN_MAX_VERSIONS as usize {
|
||||
return Err(PlanningStorageError::new(
|
||||
@@ -5067,22 +5054,23 @@ mod tests {
|
||||
fn submit_input_has_strict_canonical_parser_and_runtime_field_boundary() {
|
||||
let value = golden_submit_input();
|
||||
let bytes = canonical_plan_submit_gdd_input_bytes(&value).expect("submit input bytes");
|
||||
assert_eq!(
|
||||
parse_plan_submit_gdd_input_bytes(&bytes).expect("parse input"),
|
||||
value
|
||||
);
|
||||
let parse = |bytes: &[u8]| -> Result<PlanSubmitGddInputV1, PlanningStorageError> {
|
||||
let value = parse_strict_canonical::<PlanSubmitGddInputV1>(
|
||||
bytes,
|
||||
"plan.submit_gdd input",
|
||||
PLAN_GDD_MAX_BYTES,
|
||||
)?;
|
||||
validate_plan_submit_gdd_input(&value)?;
|
||||
Ok(value)
|
||||
};
|
||||
assert_eq!(parse(&bytes).expect("parse input"), value);
|
||||
let mut newline = bytes.clone();
|
||||
newline.push(b'\n');
|
||||
assert_eq!(
|
||||
parse_plan_submit_gdd_input_bytes(&newline)
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
"PLAN_NON_CANONICAL_BYTES"
|
||||
);
|
||||
assert_eq!(parse(&newline).unwrap_err().code(), "PLAN_NON_CANONICAL_BYTES");
|
||||
let mut object = serde_json::from_slice::<serde_json::Value>(&bytes).expect("input json");
|
||||
object["projectId"] = serde_json::Value::String("forged-project".to_string());
|
||||
let forged = serde_json::to_vec(&object).expect("forged input");
|
||||
assert!(parse_plan_submit_gdd_input_bytes(&forged).is_err());
|
||||
assert!(parse(&forged).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+25
-27
@@ -1166,15 +1166,7 @@ fn validate_current_session_cas(
|
||||
"plan.submit_gdd 必须绑定当前活跃策划子 Run",
|
||||
));
|
||||
}
|
||||
// 这条 phase 判据实际只可能看到 `collecting`:上面的 activeRunId 判据要求
|
||||
// session 绑着当前策划子 run,而 schema 不变量禁止 `awaiting_user_input`、
|
||||
// `awaiting_gdd_approval`、`revision_requested`、`approved`、`rejected`、
|
||||
// `recovery_required` 保留 activeRunId(planning_storage.rs 的
|
||||
// 「session 进入审批/终态/recovery_required 后不得保留 activeRunId」)。
|
||||
// 因此 revise/reject 之后能不能重做,不由这条门决定,而由 M1C-2b 的 continuation
|
||||
// 起点 writer 决定——它必须以新 activeRunId 写 revision+1 successor,phase 只能落回
|
||||
// `collecting`。这里保留 `revision_requested` 作为既有冗余,不再新增更多不可达分支。
|
||||
if !matches!(session.phase.as_str(), "collecting" | "revision_requested") {
|
||||
if session.phase != "collecting" {
|
||||
return Err(submit_error(
|
||||
"PLAN_PENDING_GDD_EXISTS",
|
||||
"当前 planning session 仍有未决 GDD",
|
||||
@@ -1641,11 +1633,7 @@ fn project_submit_successors_locked(
|
||||
let source_session_matches_gdd = session_identity_matches_gdd
|
||||
&& previous_session.session_revision == gdd.source_session_revision
|
||||
&& previous_session.session_fingerprint == gdd.source_session_fingerprint
|
||||
&& previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str())
|
||||
&& matches!(
|
||||
previous_session.phase.as_str(),
|
||||
"collecting" | "revision_requested"
|
||||
);
|
||||
&& previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str());
|
||||
if !session_identity_matches_gdd {
|
||||
recovery_pending = true;
|
||||
} else if same_ref {
|
||||
@@ -1896,6 +1884,15 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_plan_gdd_approval_pending_for_test(
|
||||
root: &std::path::Path,
|
||||
gdd: &PlanGddV1,
|
||||
) -> Result<(), PlanningStorageError> {
|
||||
let _lock = acquire_project_write_lock(root, "test.planning.approval-pending.create")
|
||||
.map_err(|error| PlanningStorageError::new("PLAN_DURABILITY_FAILED", error))?;
|
||||
create_plan_gdd_approval_pending_locked(root, gdd)
|
||||
}
|
||||
|
||||
fn valid_input() -> PlanSubmitGddInputV1 {
|
||||
PlanSubmitGddInputV1 {
|
||||
schema_version: PLAN_SUBMIT_INPUT_SCHEMA.to_string(),
|
||||
@@ -3628,7 +3625,7 @@ mod tests {
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
let decision_input = approval_input(
|
||||
&gdd,
|
||||
"approve",
|
||||
@@ -3654,13 +3651,13 @@ mod tests {
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
let pending = read_plan_gdd_approval_pending_locked(&root)
|
||||
.expect("read approval pending")
|
||||
.expect("pending exists");
|
||||
assert_eq!(pending.status, "awaiting_decision");
|
||||
// Recreating the exact card is an idempotent replay.
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("replay approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("replay approval pending");
|
||||
|
||||
let mut forged_next = gdd.clone();
|
||||
forged_next.version = 2;
|
||||
@@ -3669,7 +3666,7 @@ mod tests {
|
||||
"gdd-approval-00000000-0000-4000-8000-000000000003".to_string();
|
||||
forged_next.action_fingerprint = "4".repeat(64);
|
||||
forged_next.fingerprint = plan_gdd_fingerprint(&forged_next).expect("next fingerprint");
|
||||
let stale = create_plan_gdd_approval_pending_at(&root, &forged_next)
|
||||
let stale = create_plan_gdd_approval_pending_for_test(&root, &forged_next)
|
||||
.expect_err("a non-latest GDD cannot receive an approval card");
|
||||
assert_eq!(stale.code(), "PLAN_STALE_APPROVAL");
|
||||
|
||||
@@ -3684,7 +3681,7 @@ mod tests {
|
||||
)
|
||||
.expect("commit approval receipt");
|
||||
assert_eq!(decision.outcome, "committed");
|
||||
let after_receipt = create_plan_gdd_approval_pending_at(&root, &gdd)
|
||||
let after_receipt = create_plan_gdd_approval_pending_for_test(&root, &gdd)
|
||||
.expect_err("a receipt must close awaiting_decision recreation");
|
||||
assert_eq!(after_receipt.code(), "PLAN_STALE_APPROVAL");
|
||||
cleanup_fixture(root);
|
||||
@@ -3698,7 +3695,7 @@ mod tests {
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
|
||||
let task_path =
|
||||
game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID);
|
||||
@@ -3738,7 +3735,7 @@ mod tests {
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"planning":{"capabilityEnabled":false}}"#.to_string(),
|
||||
);
|
||||
@@ -3802,7 +3799,7 @@ mod tests {
|
||||
assert_eq!(blocker.status, "needs-reconciliation");
|
||||
assert!(blocker.summary.contains("审批 pending"));
|
||||
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
let blocker = plan_gdd_completion_blocker_at_locked(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
@@ -3955,7 +3952,7 @@ mod tests {
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
decide_plan_gdd_at(
|
||||
&root,
|
||||
&approval_input(
|
||||
@@ -4089,7 +4086,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_revision_comment_reaches_the_supervisor_conversation_once() {
|
||||
let (root, gdd, _root_runtime) = acceptance_gate_fixture(true);
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
decide_plan_gdd_at(
|
||||
&root,
|
||||
&approval_input(
|
||||
@@ -4143,7 +4140,7 @@ mod tests {
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
decide_plan_gdd_at(
|
||||
&root,
|
||||
&approval_input(
|
||||
@@ -4230,7 +4227,8 @@ mod tests {
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd)
|
||||
.expect("create approval pending");
|
||||
let first_input = approval_input(
|
||||
&gdd,
|
||||
action,
|
||||
@@ -4461,7 +4459,7 @@ mod tests {
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
create_plan_gdd_approval_pending_for_test(&root, &gdd).expect("create approval pending");
|
||||
let decision_input = approval_input(
|
||||
&gdd,
|
||||
"approve",
|
||||
|
||||
@@ -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
@@ -1903,6 +1903,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 挡住优化,让递归深度直接换算成栈用量。
|
||||
@@ -1940,6 +1995,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(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user