放宽直连游戏验收并增加运行时素材证据
- 将固定四切片与固定 drawImage 降为推荐路径 - 在导航前注入 Canvas/WebGL 平台素材运行时观测 - 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改 - 补充直连验收、浏览器脚本与真实 Chrome fixture 测试 - 同步 AGC 直连 Runtime 实施计划
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"model": "gpt-5.6-sol",
|
||||
"apiKind": "openai_responses",
|
||||
"reasoningEffort": "max",
|
||||
"stream": false,
|
||||
"stream": true,
|
||||
"webSearchEnabled": false,
|
||||
"contextWindowTokens": 128000,
|
||||
"autoCompactTokenLimit": 64000,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ pub(crate) use canvas_generation::{
|
||||
};
|
||||
pub(in crate::agent) use canvas_generation::{
|
||||
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
|
||||
generate_platform_art_asset_with_runtime_options_at,
|
||||
platform_art_generation_error_result_unknown,
|
||||
request_platform_art_asset_with_runtime_options_at,
|
||||
validate_platform_art_png_bytes_with_limits,
|
||||
|
||||
@@ -133,7 +133,10 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr
|
||||
}
|
||||
|
||||
pub(crate) fn editor_api_key_is_configured() -> bool {
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
if external_editor_api_credentials_override_is_active() {
|
||||
return true;
|
||||
}
|
||||
if platform_editor_uses_account_routes() {
|
||||
return platform_session_is_available();
|
||||
}
|
||||
load_game_creator_app_config()
|
||||
@@ -662,7 +665,7 @@ pub(crate) async fn submit_external_generation_request(
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let mut request_body = serde_json::from_str::<serde_json::Value>(request_body_json)
|
||||
.map_err(|error| format!("解析平台图片生成请求失败:{error}"))?;
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
if platform_editor_uses_account_routes() {
|
||||
let inputs = request_body.as_object_mut().and_then(|body| {
|
||||
body.entry("generationInputs")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
|
||||
-4
@@ -517,10 +517,6 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() {
|
||||
#[test]
|
||||
fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
assert!(
|
||||
!GameCreatorLlmConfig::default().stream,
|
||||
"the production default exercises the non-stream final-reply path"
|
||||
);
|
||||
let project = tempfile::tempdir().expect("create non-stream professional reply project");
|
||||
let root = project.path();
|
||||
init_local_game_project_at(
|
||||
|
||||
@@ -260,9 +260,8 @@ pub(crate) use entrypoints::{
|
||||
chat_with_game_creator_role_agent_runtime_for_session_at,
|
||||
chat_with_game_creator_role_agent_stream_at,
|
||||
chat_with_game_creator_role_agent_stream_for_session_at,
|
||||
configure_game_creator_manifest_invalidation_event_sink,
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
|
||||
emit_direct_game_creator_progress,
|
||||
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,9 @@ pub use model::{
|
||||
BrowserValidationInput, BrowserValidationResult, BrowserValidationViewport,
|
||||
BrowserViewportValidationResult, DiscoveredBrowser, DiscoveredBrowserKind,
|
||||
};
|
||||
pub use process::validate_local_preview_in_browser;
|
||||
pub use process::{
|
||||
validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction,
|
||||
};
|
||||
|
||||
pub(crate) use playtest::browser_playtest_scenario_fingerprint;
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) struct PageSnapshot {
|
||||
pub(super) dom_character_count: usize,
|
||||
pub(super) expected_text_matches: Vec<bool>,
|
||||
pub(super) canvases: Vec<BrowserCanvasSnapshot>,
|
||||
pub(super) canvas_or_webgl_image_paths: Vec<String>,
|
||||
pub(super) blocked_popup_count: u32,
|
||||
pub(super) blocked_download_count: u32,
|
||||
pub(super) blocked_permission_count: u32,
|
||||
@@ -612,6 +613,7 @@ pub(super) fn build_snapshot_script(expected_text: &[String]) -> Result<String,
|
||||
domCharacterCount: document.documentElement?.outerHTML?.length || 0,
|
||||
expectedTextMatches: expected.map((value) => text.includes(value)),
|
||||
canvases,
|
||||
canvasOrWebglImagePaths: Array.from(new Set(security.canvasOrWebglImagePaths || [])).slice(0, 32),
|
||||
blockedPopupCount: security.blockedPopupCount || 0,
|
||||
blockedDownloadCount: security.blockedDownloadCount || 0,
|
||||
blockedPermissionCount: security.blockedPermissionCount || 0,
|
||||
@@ -702,6 +704,47 @@ pub(super) const RESTRICTION_SCRIPT: &str = r#"
|
||||
})();
|
||||
"#;
|
||||
|
||||
pub(super) const RENDERED_IMAGE_CAPTURE_SCRIPT: &str = r#"
|
||||
(() => {
|
||||
const state = window.__GENARRATIVE_PREVIEW_VALIDATION__;
|
||||
if (!state) return;
|
||||
state.canvasOrWebglImagePaths = [];
|
||||
const recordCanvasOrWebglImage = (candidate) => {
|
||||
const source = candidate && (candidate.currentSrc || candidate.src);
|
||||
if (typeof source !== 'string' || source.length === 0) return;
|
||||
try {
|
||||
const url = new URL(source, location.href);
|
||||
if (url.origin !== location.origin || !url.pathname) return;
|
||||
if (!state.canvasOrWebglImagePaths.includes(url.pathname) && state.canvasOrWebglImagePaths.length < 32) {
|
||||
state.canvasOrWebglImagePaths.push(url.pathname);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
const wrapImageRenderMethod = (prototype, method) => {
|
||||
const original = prototype && prototype[method];
|
||||
if (typeof original !== 'function') return;
|
||||
try {
|
||||
Object.defineProperty(prototype, method, {
|
||||
value: function(...args) {
|
||||
for (const candidate of args) recordCanvasOrWebglImage(candidate);
|
||||
return original.apply(this, args);
|
||||
},
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
} catch (_) {}
|
||||
};
|
||||
wrapImageRenderMethod(globalThis.CanvasRenderingContext2D?.prototype, 'drawImage');
|
||||
for (const prototype of [
|
||||
globalThis.WebGLRenderingContext?.prototype,
|
||||
globalThis.WebGL2RenderingContext?.prototype
|
||||
]) {
|
||||
wrapImageRenderMethod(prototype, 'texImage2D');
|
||||
wrapImageRenderMethod(prototype, 'texSubImage2D');
|
||||
}
|
||||
})();
|
||||
"#;
|
||||
|
||||
fn remote_object_text(object: &RemoteObject) -> String {
|
||||
if let Some(value) = &object.value {
|
||||
if let Some(value) = value.as_str() {
|
||||
|
||||
@@ -14,7 +14,8 @@ use url::Url;
|
||||
|
||||
use super::capture::{
|
||||
build_snapshot_script, canvas_validation_diagnostic, sanitize_url, start_capture_tasks,
|
||||
truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RESTRICTION_SCRIPT,
|
||||
truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT,
|
||||
RESTRICTION_SCRIPT,
|
||||
};
|
||||
use super::evidence::write_atomic;
|
||||
use super::model::{
|
||||
@@ -36,6 +37,7 @@ pub(super) async fn run_browser_validation(
|
||||
discovered: &DiscoveredBrowser,
|
||||
preview_url: &Url,
|
||||
input: &BrowserValidationInput,
|
||||
advisory_interaction: bool,
|
||||
) -> Result<BrowserValidationResult, String> {
|
||||
browser
|
||||
.execute(SetDownloadBehaviorParams::new(
|
||||
@@ -51,7 +53,8 @@ pub(super) async fn run_browser_validation(
|
||||
let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len());
|
||||
let mut playtest = None;
|
||||
for viewport in REQUIRED_VIEWPORTS {
|
||||
let outcome = validate_viewport(browser, preview_url, input, viewport).await?;
|
||||
let outcome =
|
||||
validate_viewport(browser, preview_url, input, viewport, advisory_interaction).await?;
|
||||
if outcome.playtest.is_some() {
|
||||
playtest = outcome.playtest;
|
||||
}
|
||||
@@ -116,6 +119,7 @@ async fn validate_viewport(
|
||||
preview_url: &Url,
|
||||
input: &BrowserValidationInput,
|
||||
viewport: BrowserValidationViewport,
|
||||
advisory_interaction: bool,
|
||||
) -> Result<BrowserViewportValidationOutcome, String> {
|
||||
let (width, height, mobile) = viewport.dimensions();
|
||||
let page = browser
|
||||
@@ -139,6 +143,14 @@ async fn validate_viewport(
|
||||
page.evaluate_on_new_document(RESTRICTION_SCRIPT)
|
||||
.await
|
||||
.map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?;
|
||||
page.evaluate_on_new_document(RENDERED_IMAGE_CAPTURE_SCRIPT)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"安装 {} 平台素材渲染观察失败:{error}",
|
||||
viewport.file_stem()
|
||||
)
|
||||
})?;
|
||||
|
||||
let tasks = start_capture_tasks(&page, preview_url).await?;
|
||||
let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str()))
|
||||
@@ -151,6 +163,12 @@ async fn validate_viewport(
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(input.settle_ms)).await;
|
||||
|
||||
let advisory_interaction_diagnostic = if advisory_interaction {
|
||||
Some(run_advisory_interaction_probe(&page).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let playtest = if viewport == BrowserValidationViewport::Desktop {
|
||||
match input.playtest_scenario {
|
||||
Some(scenario) => Some(run_desktop_playtest(&page, scenario).await),
|
||||
@@ -245,7 +263,15 @@ async fn validate_viewport(
|
||||
if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) {
|
||||
diagnostics.push(diagnostic.to_string());
|
||||
}
|
||||
|
||||
if !snapshot.canvas_or_webgl_image_paths.is_empty() {
|
||||
diagnostics.push(format!(
|
||||
"[advisory] rendered-local-images:{}",
|
||||
snapshot.canvas_or_webgl_image_paths.join("|")
|
||||
));
|
||||
}
|
||||
if let Some(diagnostic) = advisory_interaction_diagnostic {
|
||||
diagnostics.push(format!("[advisory] {diagnostic}"));
|
||||
}
|
||||
Ok(BrowserViewportValidationOutcome {
|
||||
result: BrowserViewportValidationResult {
|
||||
viewport,
|
||||
@@ -269,9 +295,61 @@ async fn validate_viewport(
|
||||
blocked_permission_count: snapshot.blocked_permission_count,
|
||||
blocked_service_worker_count: snapshot.blocked_service_worker_count,
|
||||
screenshot_path,
|
||||
passed: diagnostics.is_empty(),
|
||||
passed: diagnostics.is_empty()
|
||||
|| diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| diagnostic.starts_with("[advisory]")),
|
||||
diagnostics,
|
||||
},
|
||||
playtest,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_advisory_interaction_probe(page: &chromiumoxide::Page) -> String {
|
||||
const PROBE: &str = r#"function() {
|
||||
const candidates = Array.from(document.querySelectorAll(
|
||||
'button:not([disabled]), a[href], input[type="button"]:not([disabled]), input[type="submit"]:not([disabled])'
|
||||
));
|
||||
const item = candidates.find((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' &&
|
||||
rect.width > 0 && rect.height > 0 &&
|
||||
element.getAttribute('aria-disabled') !== 'true';
|
||||
});
|
||||
if (!item) return JSON.stringify({ ok: false, reason: 'no-visible-enabled-control' });
|
||||
item.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
return JSON.stringify({ ok: true, tag: item.tagName.toLowerCase() });
|
||||
}"#;
|
||||
let mut elements = match page.find_elements(
|
||||
"button:not([disabled]), a[href], input[type=button]:not([disabled]), input[type=submit]:not([disabled])",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(elements) => elements,
|
||||
Err(_) => return "真实交互探针无法查询可见控件".to_string(),
|
||||
};
|
||||
for element in elements.drain(..).take(8) {
|
||||
let evaluated = match element.call_js_fn(PROBE, false).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Some(value) = evaluated.result.value else {
|
||||
continue;
|
||||
};
|
||||
let Some(text) = value.as_str() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(probe) = serde_json::from_str::<serde_json::Value>(text) else {
|
||||
continue;
|
||||
};
|
||||
if probe.get("ok").and_then(serde_json::Value::as_bool) != Some(true) {
|
||||
continue;
|
||||
}
|
||||
return match element.click().await {
|
||||
Ok(_) => "真实交互探针已点击一个可见启用控件".to_string(),
|
||||
Err(_) => "真实交互探针发现控件但点击未完成".to_string(),
|
||||
};
|
||||
}
|
||||
"真实交互探针未发现可见启用控件".to_string()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ pub(super) fn create_browser_process_temp_dir() -> Result<TempDir, String> {
|
||||
|
||||
pub async fn validate_local_preview_in_browser(
|
||||
input: BrowserValidationInput,
|
||||
) -> Result<BrowserValidationResult, String> {
|
||||
validate_local_preview_in_browser_with_interaction(input, false).await
|
||||
}
|
||||
|
||||
/// Runs the same restricted Chromium validation, optionally adding one bounded,
|
||||
/// advisory click on a visible enabled control. The interaction is evidence for
|
||||
/// the direct Codex reviewer; it is not a fixed gameplay contract and never
|
||||
/// changes the deterministic browser safety checks.
|
||||
pub async fn validate_local_preview_in_browser_with_interaction(
|
||||
input: BrowserValidationInput,
|
||||
advisory_interaction: bool,
|
||||
) -> Result<BrowserValidationResult, String> {
|
||||
let preview_url = validate_input(&input)?;
|
||||
prepare_evidence_root(&input.evidence_root)?;
|
||||
@@ -77,8 +88,14 @@ pub async fn validate_local_preview_in_browser(
|
||||
}
|
||||
});
|
||||
|
||||
let validation =
|
||||
run_browser_validation(&browser, &browser_executable, &preview_url, &input).await;
|
||||
let validation = run_browser_validation(
|
||||
&browser,
|
||||
&browser_executable,
|
||||
&preview_url,
|
||||
&input,
|
||||
advisory_interaction,
|
||||
)
|
||||
.await;
|
||||
|
||||
let close_result = browser
|
||||
.close()
|
||||
|
||||
@@ -12,7 +12,7 @@ use url::Url;
|
||||
|
||||
use super::capture::{
|
||||
canvas_validation_diagnostic, classify_canvas_pixel_probe, preview_fetch_enable_params,
|
||||
BrowserCanvasSnapshot,
|
||||
BrowserCanvasSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT,
|
||||
};
|
||||
use super::discovery::system_browser_candidates;
|
||||
use super::evidence::browser_validation_result_for_report;
|
||||
@@ -70,6 +70,16 @@ fn canvas_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_image_capture_script_observes_canvas_and_webgl_without_cross_origin_urls() {
|
||||
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("CanvasRenderingContext2D"));
|
||||
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("drawImage"));
|
||||
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texImage2D"));
|
||||
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texSubImage2D"));
|
||||
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.origin !== location.origin"));
|
||||
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.pathname"));
|
||||
}
|
||||
|
||||
fn generic_state(sequence: u64, phase: BrowserPlaytestPhase, level: u64) -> PlayableWebGameState {
|
||||
PlayableWebGameState {
|
||||
sequence,
|
||||
@@ -2362,20 +2372,41 @@ async fn real_chrome_validation_smoke() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
|
||||
let port = listener.local_addr().expect("preview address").port();
|
||||
listener.set_nonblocking(true).expect("nonblocking preview");
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
|
||||
2,
|
||||
2,
|
||||
image::Rgba([255, 96, 32, 255]),
|
||||
))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.expect("encode browser fixture PNG");
|
||||
let (stop_tx, stop_rx) = mpsc::channel();
|
||||
let server = thread::spawn(move || {
|
||||
let html = br#"<!doctype html><html><head><title>Browser Probe</title></head><body><main>Expected local preview</main><canvas id="transparent" width="64" height="64" style="width:64px;height:64px"></canvas><canvas id="solid" width="64" height="64" style="width:64px;height:64px"></canvas><canvas id="drawn" width="64" height="64" style="width:64px;height:64px"></canvas><script>const solid=document.querySelector('#solid').getContext('2d');solid.fillStyle='#16a34a';solid.fillRect(0,0,64,64);const drawn=document.querySelector('#drawn').getContext('2d');drawn.fillStyle='#16a34a';drawn.fillRect(0,0,64,64);drawn.fillStyle='#2563eb';drawn.fillRect(0,0,32,64);console.warn('probe warning');</script></body></html>"#;
|
||||
let html = br#"<!doctype html><html><head><title>Browser Probe</title></head><body><main>Expected local preview</main><img id="side" src="/side-only.png" alt="side only"><canvas id="transparent" width="64" height="64" style="width:64px;height:64px"></canvas><canvas id="solid" width="64" height="64" style="width:64px;height:64px"></canvas><canvas id="drawn" width="64" height="64" style="width:64px;height:64px"></canvas><script>const solid=document.querySelector('#solid').getContext('2d');solid.fillStyle='#16a34a';solid.fillRect(0,0,64,64);const drawn=document.querySelector('#drawn').getContext('2d');drawn.fillStyle='#16a34a';drawn.fillRect(0,0,64,64);drawn.fillStyle='#2563eb';drawn.fillRect(0,0,32,64);const gameplayArt=new Image();gameplayArt.onload=()=>drawn.drawImage(gameplayArt,16,16,32,32);gameplayArt.src='/drawn.png';console.warn('probe warning');</script></body></html>"#;
|
||||
while stop_rx.try_recv().is_err() {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
let mut request = [0_u8; 2048];
|
||||
let _ = stream.read(&mut request);
|
||||
let count = stream.read(&mut request).unwrap_or_default();
|
||||
let request = String::from_utf8_lossy(&request[..count]);
|
||||
let is_png = request.starts_with("GET /drawn.png ")
|
||||
|| request.starts_with("GET /side-only.png ");
|
||||
let body = if is_png {
|
||||
png.as_slice()
|
||||
} else {
|
||||
html.as_slice()
|
||||
};
|
||||
let content_type = if is_png {
|
||||
"image/png"
|
||||
} else {
|
||||
"text/html; charset=utf-8"
|
||||
};
|
||||
let headers = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
html.len()
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(headers.as_bytes());
|
||||
let _ = stream.write_all(html);
|
||||
let _ = stream.write_all(body);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
@@ -2389,7 +2420,7 @@ async fn real_chrome_validation_smoke() {
|
||||
url: format!("http://127.0.0.1:{port}/"),
|
||||
viewports: REQUIRED_VIEWPORTS.to_vec(),
|
||||
expected_text: vec!["Expected local preview".to_string()],
|
||||
settle_ms: 100,
|
||||
settle_ms: 300,
|
||||
fail_on_console_error: true,
|
||||
playtest_scenario: None,
|
||||
evidence_root: evidence.path().join("evidence"),
|
||||
@@ -2425,5 +2456,12 @@ async fn real_chrome_validation_smoke() {
|
||||
);
|
||||
assert_eq!(viewport_result.canvases[1].non_empty, Some(false));
|
||||
assert_eq!(viewport_result.canvases[2].non_empty, Some(true));
|
||||
let rendered_diagnostic = viewport_result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| diagnostic.starts_with("[advisory] rendered-local-images:"))
|
||||
.expect("rendered image observation");
|
||||
assert!(rendered_diagnostic.contains("/drawn.png"));
|
||||
assert!(!rendered_diagnostic.contains("/side-only.png"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
|
||||
model: None,
|
||||
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
|
||||
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
|
||||
stream: false,
|
||||
stream: true,
|
||||
web_search_enabled: false,
|
||||
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
|
||||
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
|
||||
@@ -835,6 +835,16 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user(
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true)
|
||||
}
|
||||
|
||||
/// Initialize ownership only for a directory that was created by the current
|
||||
/// operation. Existing directories must use the strict verifier instead, so a
|
||||
/// foreign-owned path is never silently adopted.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user(
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_private_dacl_security_information(
|
||||
initialize_owner: bool,
|
||||
|
||||
@@ -1319,7 +1319,7 @@ impl Default for GameCreatorLlmConfig {
|
||||
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
|
||||
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
|
||||
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
|
||||
stream: false,
|
||||
stream: true,
|
||||
web_search_enabled: false,
|
||||
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
|
||||
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
|
||||
|
||||
@@ -182,6 +182,62 @@ pub(crate) fn platform_session_service_identity() -> Option<String> {
|
||||
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static PLATFORM_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct TestPlatformSessionGuard {
|
||||
_isolation: std::sync::MutexGuard<'static, ()>,
|
||||
previous: Option<PlatformSessionState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TestPlatformSessionGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*current = self
|
||||
.previous
|
||||
.take()
|
||||
.expect("platform session test guard must restore its prior state");
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a scoped platform-account session for tests that must exercise the
|
||||
/// production-default client authentication path. The previous process state
|
||||
/// is restored on drop so the fixture cannot leak credentials or account
|
||||
/// identity to another test.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_test_platform_session(
|
||||
user_id: &str,
|
||||
access_token: &str,
|
||||
api_base_url: &str,
|
||||
) -> TestPlatformSessionGuard {
|
||||
let isolation = PLATFORM_SESSION_TEST_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let mut current = platform_session()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let previous = std::mem::take(&mut *current);
|
||||
*current = PlatformSessionState {
|
||||
generation: 1,
|
||||
snapshot: Some(PlatformSessionSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
api_base_url: api_base_url.to_string(),
|
||||
generation: 1,
|
||||
}),
|
||||
};
|
||||
drop(current);
|
||||
TestPlatformSessionGuard {
|
||||
_isolation: isolation,
|
||||
previous: Some(previous),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -145,7 +145,9 @@ pub(super) fn process_session_command_builder(
|
||||
#[cfg(windows)]
|
||||
if is_npm_launch {
|
||||
let node_executable = launch.executable.to_string_lossy();
|
||||
let node_executable = node_executable.strip_prefix(r"\\?\").unwrap_or(&node_executable);
|
||||
let node_executable = node_executable
|
||||
.strip_prefix(r"\\?\")
|
||||
.unwrap_or(&node_executable);
|
||||
command.env("npm_node_execpath", node_executable);
|
||||
command.env("NODE", node_executable);
|
||||
command.env("npm_config_node_gyp", "");
|
||||
@@ -1187,9 +1189,7 @@ fn drain_process_session_output(
|
||||
let before = pending.len();
|
||||
ansi.push(*byte, &mut pending);
|
||||
let visible_bytes = pending.len().saturating_sub(before);
|
||||
if visible_bytes > 0
|
||||
&& !matches!(pending.last(), Some(b'\n' | b'\r'))
|
||||
{
|
||||
if visible_bytes > 0 && !matches!(pending.last(), Some(b'\n' | b'\r')) {
|
||||
pending_logical_line_bytes =
|
||||
pending_logical_line_bytes.saturating_add(visible_bytes);
|
||||
if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES {
|
||||
@@ -1225,8 +1225,8 @@ fn drain_process_session_output(
|
||||
const PROCESS_SESSION_PTY_COLS: usize = 120;
|
||||
match pending.last() {
|
||||
Some(b'\r') => {
|
||||
conpty_soft_wrap = pending_logical_line_bytes
|
||||
>= PROCESS_SESSION_PTY_COLS;
|
||||
conpty_soft_wrap =
|
||||
pending_logical_line_bytes >= PROCESS_SESSION_PTY_COLS;
|
||||
if !conpty_soft_wrap {
|
||||
pending_logical_line_bytes = 0;
|
||||
}
|
||||
|
||||
@@ -107,14 +107,50 @@ fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() {
|
||||
default_config.agent_mode,
|
||||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER
|
||||
);
|
||||
assert!(
|
||||
default_config.llm.stream,
|
||||
"新的全局 LLM 配置默认使用流式响应"
|
||||
);
|
||||
|
||||
let inherited_agent_llm =
|
||||
resolve_game_creator_llm_config_for_agent(&default_config, "design-director");
|
||||
assert!(
|
||||
inherited_agent_llm.stream,
|
||||
"未覆盖时 Agent 必须继承全局流式默认"
|
||||
);
|
||||
|
||||
let mut explicitly_non_streaming_config = default_config.clone();
|
||||
explicitly_non_streaming_config.agent_llm.insert(
|
||||
"design-director".to_string(),
|
||||
GameCreatorLlmConfigFile {
|
||||
stream: Some(false),
|
||||
..GameCreatorLlmConfigFile::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
!resolve_game_creator_llm_config_for_agent(
|
||||
&explicitly_non_streaming_config,
|
||||
"design-director",
|
||||
)
|
||||
.stream,
|
||||
"Agent 显式关闭流式请求时必须覆盖全局默认"
|
||||
);
|
||||
|
||||
let root = unique_project_path();
|
||||
fs::create_dir_all(&root).expect("runtime config dir");
|
||||
let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||||
fs::write(&config_path, r#"{"agentMode":"provider"}"#).expect("write explicit provider mode");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{"agentMode":"provider","llm":{"stream":false}}"#,
|
||||
)
|
||||
.expect("write explicit provider mode and non-stream override");
|
||||
let mut config = GameCreatorAppConfig::default();
|
||||
merge_game_creator_config_file(&mut config, &config_path).expect("merge provider mode");
|
||||
assert_eq!(config.agent_mode, GAME_CREATOR_AGENT_MODE_PROVIDER);
|
||||
assert!(
|
||||
!config.llm.stream,
|
||||
"已有配置显式关闭流式请求时必须保留该选择"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER)
|
||||
.expect("app-server mode"),
|
||||
|
||||
@@ -1729,6 +1729,58 @@ export function isMudPointInsufficientRuntimeError(message: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function directCodexDiagnosticFailureDetail(message: string) {
|
||||
const trimmed = message.trim();
|
||||
const match =
|
||||
/^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec(
|
||||
trimmed,
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, stage, retryable, rawSummary, rawHint] = match;
|
||||
if (!stage || !retryable || !rawSummary || !rawHint) {
|
||||
return null;
|
||||
}
|
||||
const redactMarkerForDisplay = (value: string) =>
|
||||
value
|
||||
.replace(/<redacted-url>/gi, '[已隐藏链接]')
|
||||
.replace(/\$PROJECT_ROOT|<absolute-path>/g, '[已隐藏路径]')
|
||||
.replace(/\[redacted-secret\]/gi, '[已隐藏凭据]');
|
||||
const summary = redactMarkerForDisplay(rawSummary)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 320);
|
||||
const hint = redactMarkerForDisplay(rawHint)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180);
|
||||
const combined = `${summary} ${hint}`;
|
||||
if (
|
||||
/(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test(
|
||||
combined,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const stageLabel = {
|
||||
request: '智能创作请求失败',
|
||||
'art-preparation': '平台资源准备失败',
|
||||
'code-generation': '代码生成失败',
|
||||
'browser-validation': '真实试玩未通过',
|
||||
'version-registration': '项目版本登记失败',
|
||||
}[stage];
|
||||
if (!stageLabel) {
|
||||
return null;
|
||||
}
|
||||
if (!summary || !hint) {
|
||||
return null;
|
||||
}
|
||||
return `${stageLabel}:${summary}。${hint}${
|
||||
retryable === 'true' ? '(可直接重试)' : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
function directPlatformFailureDetail(message: string) {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) {
|
||||
@@ -1829,6 +1881,10 @@ export function projectRuntimeVisibleError(
|
||||
if (isMudPointInsufficientRuntimeError(message)) {
|
||||
return MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE;
|
||||
}
|
||||
const directDiagnosticDetail = directCodexDiagnosticFailureDetail(message);
|
||||
if (directDiagnosticDetail) {
|
||||
return `${subject}:${directDiagnosticDetail}`;
|
||||
}
|
||||
const directFailureDetail = directPlatformFailureDetail(message);
|
||||
if (directFailureDetail) {
|
||||
return `${subject}:${directFailureDetail}`;
|
||||
|
||||
@@ -39,6 +39,11 @@ export function WorkspaceLauncherShell({
|
||||
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
|
||||
const [launcherView, setLauncherView] = useState<LauncherView>(initialView);
|
||||
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
|
||||
const [playRequest, setPlayRequest] = useState<{
|
||||
projectPath: string;
|
||||
requestId: number;
|
||||
} | null>(null);
|
||||
const playRequestIdRef = useRef(0);
|
||||
const [launcherNotice, setLauncherNotice] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -188,6 +193,20 @@ export function WorkspaceLauncherShell({
|
||||
[applyManifestSnapshot, setCurrentProjectContext],
|
||||
);
|
||||
|
||||
const requestCurrentProjectPlay = useCallback((nextProjectPath: string) => {
|
||||
playRequestIdRef.current += 1;
|
||||
setPlayRequest({
|
||||
projectPath: nextProjectPath,
|
||||
requestId: playRequestIdRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePlayRequestHandled = useCallback((requestId: number) => {
|
||||
setPlayRequest((current) =>
|
||||
current?.requestId === requestId ? null : current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
function showLauncherNotice(title: string) {
|
||||
setLauncherNotice({
|
||||
title,
|
||||
@@ -289,6 +308,9 @@ export function WorkspaceLauncherShell({
|
||||
preview={activeProjectPreview}
|
||||
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
|
||||
agentResults={activeProjectAgentResults}
|
||||
onPlay={() =>
|
||||
requestCurrentProjectPlay(currentProjectContext.projectPath)
|
||||
}
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onHomeOpen={() => setLauncherView('home')}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
@@ -301,6 +323,8 @@ export function WorkspaceLauncherShell({
|
||||
initialSupervisorMessage={currentProjectContext.initialPrompt}
|
||||
orchestrationMode="single-supervisor"
|
||||
projectSupervisorOnly
|
||||
playRequest={playRequest}
|
||||
onPlayRequestHandled={handlePlayRequestHandled}
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onPreviewChange={setActiveProjectPreview}
|
||||
onAgentRuntimeSummariesChange={
|
||||
|
||||
@@ -38,6 +38,11 @@ export type ProjectSupervisorComponentProps = {
|
||||
initialSupervisorMessage?: string;
|
||||
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
||||
projectSupervisorOnly?: boolean;
|
||||
playRequest?: {
|
||||
projectPath: string;
|
||||
requestId: number;
|
||||
} | null;
|
||||
onPlayRequestHandled?: (requestId: number) => void;
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
manifest: GameCreationAppManifest,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import type {
|
||||
AgentStatusCard,
|
||||
ChatMessage,
|
||||
PendingCommand,
|
||||
PendingUiConfirmation,
|
||||
} from '../../app/types';
|
||||
import {
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
} from '../agent-runtime';
|
||||
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
|
||||
import { taskStatusLabels } from '../project-summary/projectSummary';
|
||||
import {
|
||||
pendingCommandDetail,
|
||||
pendingCommandTitle,
|
||||
} from './pendingCommandPresentation';
|
||||
import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
|
||||
|
||||
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
|
||||
|
||||
@@ -30,12 +36,16 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
hiddenConversationCount: number;
|
||||
needsUserInput: boolean;
|
||||
onCancelConfirmation: () => void;
|
||||
onCancelPendingCommand: () => void;
|
||||
onChatInputChange: Dispatch<SetStateAction<string>>;
|
||||
onConfirmConfirmation: () => void;
|
||||
onConfirmPendingCommand: () => void;
|
||||
onScroll: UIEventHandler<HTMLDivElement>;
|
||||
onShowEarlierMessages: () => void;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
pendingConfirmation: PendingUiConfirmation | null;
|
||||
pendingCommand: PendingCommand | null;
|
||||
projectPath: string;
|
||||
showProfessionalCollaboration?: boolean;
|
||||
transientReply: string;
|
||||
visibleMessages: ChatMessage[];
|
||||
@@ -49,12 +59,16 @@ export function ProjectSupervisorView({
|
||||
hiddenConversationCount,
|
||||
needsUserInput,
|
||||
onCancelConfirmation,
|
||||
onCancelPendingCommand,
|
||||
onChatInputChange,
|
||||
onConfirmConfirmation,
|
||||
onConfirmPendingCommand,
|
||||
onScroll,
|
||||
onShowEarlierMessages,
|
||||
onSubmit,
|
||||
pendingConfirmation,
|
||||
pendingCommand,
|
||||
projectPath,
|
||||
showProfessionalCollaboration = true,
|
||||
transientReply,
|
||||
visibleMessages,
|
||||
@@ -104,6 +118,29 @@ export function ProjectSupervisorView({
|
||||
{directCodex ? null : (
|
||||
<ProjectSupervisorRuntimePanel {...runtimePanelProps} />
|
||||
)}
|
||||
{pendingCommand ? (
|
||||
<div className="pending-command">
|
||||
<span>
|
||||
{pendingCommandTitle(pendingCommand)}
|
||||
<small>
|
||||
{pendingCommandDetail(
|
||||
pendingCommand,
|
||||
resolvePendingCommandProjectPath(
|
||||
pendingCommand,
|
||||
{ projectPath },
|
||||
projectPath,
|
||||
),
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
<button type="button" onClick={onCancelPendingCommand}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={onConfirmPendingCommand}>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingConfirmation ? (
|
||||
<div className="pending-command">
|
||||
<span>
|
||||
|
||||
@@ -273,6 +273,7 @@ export type ProjectDevelopmentViewProps = {
|
||||
supervisor: ReactNode;
|
||||
onHomeOpen: () => void;
|
||||
onProjectsOpen: () => void;
|
||||
onPlay?: () => void;
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
manifest: GameCreationAppManifest,
|
||||
@@ -710,6 +711,7 @@ export default function ProjectDevelopmentView({
|
||||
agentResults = emptyProjectAgentResults,
|
||||
supervisor,
|
||||
onManifestChange,
|
||||
onPlay,
|
||||
}: ProjectDevelopmentViewProps) {
|
||||
const professionalDagVisible = orchestrationMode === 'professional-dag';
|
||||
const [mode, setMode] = useState<WorkbenchMode>('resources');
|
||||
@@ -2995,6 +2997,21 @@ export default function ProjectDevelopmentView({
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-workbench-view-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-play-button"
|
||||
disabled={!runAvailable || !onPlay}
|
||||
onClick={() => {
|
||||
showRunView();
|
||||
onPlay?.();
|
||||
}}
|
||||
title={
|
||||
runAvailable ? '运行当前游戏' : '首个可运行原型完成后才能播放'
|
||||
}
|
||||
>
|
||||
<Play size={15} aria-hidden="true" />
|
||||
播放
|
||||
</button>
|
||||
{mode === 'resources' &&
|
||||
!focusedResource &&
|
||||
!assetCanvasRoute &&
|
||||
@@ -3736,7 +3753,7 @@ export default function ProjectDevelopmentView({
|
||||
<div className="game-run-preview-empty">
|
||||
<Sparkles size={28} aria-hidden="true" />
|
||||
<strong>客户端运行画面尚未载入</strong>
|
||||
<span>发送 /run 或 /preview 后将在这里直接运行游戏</span>
|
||||
<span>点击顶部播放按钮后将在这里直接运行游戏</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -956,6 +956,45 @@ describe('Agent Runtime Provider 状态投影', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('直连智能创作展示脱敏的阶段诊断与重试建议', () => {
|
||||
expect(
|
||||
projectRuntimeVisibleError(
|
||||
'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败:HTTP 503;建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
'陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:HTTP 503。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)',
|
||||
);
|
||||
expect(
|
||||
projectRuntimeVisibleError(
|
||||
'direct-codex-failure:v1 stage=art-preparation retryable=false summary=陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复;建议:历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集;已保存脱敏项目诊断',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
'陶泥儿智能创作:平台资源准备失败:陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复。历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集',
|
||||
);
|
||||
expect(
|
||||
projectRuntimeVisibleError(
|
||||
'direct-codex-failure:v1 stage=art-preparation retryable=false summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据;建议:请检查当前 Windows 用户对本机私有凭据目录的权限后重试;已保存脱敏项目诊断',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
'陶泥儿智能创作:平台资源准备失败:本机开发者凭据存储目录未安全初始化;未创建远端凭据。请检查当前 Windows 用户对本机私有凭据目录的权限后重试',
|
||||
);
|
||||
expect(
|
||||
projectRuntimeVisibleError(
|
||||
'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败:<redacted-url> <absolute-path> [redacted-secret];建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
'陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接] [已隐藏路径] [已隐藏凭据]。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)',
|
||||
);
|
||||
});
|
||||
|
||||
test('直连 Codex 失败保留安全原因并隐藏链接与路径', () => {
|
||||
expect(
|
||||
projectRuntimeVisibleError(
|
||||
|
||||
@@ -105,9 +105,9 @@
|
||||
|
||||
### 现行契约
|
||||
|
||||
1. 直连 AGC 项目首次生成、或当前项目还没有完整已登记的陶泥儿美术包时,客户端必须按标准三阶段调用陶泥儿平台:先生成统一规范图 `assets/art-spec.png`,再以该规范图为唯一参考生成 16:9 场景背景图 `assets/direct-game-background.png`,最后以同一规范图生成透明主图集 `assets/art-spritesheet.png`。标准图集必须请求并回执 `sliceLayout=grid-2x2`,由平台按左上、右上、左下、右下四个固定槽位原子产出四份受合同保护的切片:`assets/art-spritesheet-slices/player.png`、`blocks-and-targets.png`、`obstacles-and-scene.png`、`feedback-effects.png`,以及对应切片清单与私有合同回执;不得继续把自由图集的连通域结果、客户端猜测等分裁剪或刚好四个部件冒充这四类素材。三份主图片都必须以 `source.kind=canvas` 登记到 manifest,且规范图、背景、图集必须属于同一 Canvas 项目;任一阶段失败或切片合同不完整都不得启动代码生成回合。
|
||||
2. 客户端把规范图、背景图、主图集和四份切片的相对路径、来源和用途作为不可变执行上下文提供给执行引擎。规范图只作为风格根;执行引擎必须将背景图实际用于游戏场景,并在 `game/game.js` 中加载四份切片,分别通过 Canvas `drawImage` 绘制玩家/可操作物、核心目标或道具、障碍或场景元素、反馈或界面装饰等核心画面元素;不能只在回复中声明使用,也不能用 emoji、纯 CSS 或 Canvas 色块替代这些核心视觉。
|
||||
3. 回合结束时客户端复核:三份平台主图片、四份切片、切片清单和合同回执均存在;manifest 来源、路由、规范图引用和同 Canvas 项目关系均成立;`game/index.html` 存在;游戏源码引用背景图,并在可执行 Canvas 渲染路径中加载四份切片且至少执行四次 `drawImage`。任一条件不满足就不登记 `code-prototype=completed`、不创建完成版本,并把明确原因返回给用户。
|
||||
1. 直连 AGC 项目首次生成、或当前项目还没有已登记陶泥儿美术时,客户端按标准三阶段优先调用陶泥儿平台:先生成统一规范图 `assets/art-spec.png`,再以该规范图为参考生成 16:9 场景背景图 `assets/direct-game-background.png`,最后以同一规范图生成透明主图集 `assets/art-spritesheet.png`。`sliceLayout=grid-2x2` 与四份受合同保护切片继续作为标准美术包的推荐路径;不得把客户端猜测裁剪或自由连通域结果冒充平台切片。平台返回的主图片必须以 `source.kind=canvas` 登记,资源身份、图片可解码性和同 Canvas 项目关系继续失败关闭;图集源图可信可用但透明化或切片后处理缺失时,按第 13 节安全复用源图并继续同一 Codex thread,不因固定切片缺失终止整个游戏。
|
||||
2. 客户端把实际可用的规范图、背景图、主图集与切片相对路径、来源和用途作为不可变执行上下文提供给唯一 Codex。规范图作为风格根,其余平台素材应进入核心可玩画面;Codex 可以根据游戏结构选择合理的图集、切片、Canvas 或 WebGL 组织方式,不要求固定文件组合、固定 `drawImage` 次数或单一代码形态。平台素材不能只在回复中声明使用或只显示为旁侧缩略图,核心视觉也不能退化为 emoji、纯 CSS 或纯色占位图。
|
||||
3. 回合结束时客户端只确定性复核安全与最低来源边界:可信平台图片、身份与同 Canvas 关系成立,`game/index.html` 存在,游戏源码至少引用一份已登记陶泥儿图片。随后由受限 Chromium 对 desktop/mobile 做真实运行、截图、异常、交互及 Canvas/WebGL 图片渲染观察,并把缺口回灌同一 Codex thread 有界整改。固定四切片、固定文件名和固定 `drawImage` 次数不再阻断完成;最终仍未观察到平台素材进入任一视口核心渲染时才拒绝登记版本。详细契约以第 13 节为准。
|
||||
4. 资源画布增加“游戏代码”分区。HTML、CSS、JavaScript 和其它可执行源码进入该分区;玩法说明、配置和 Agent 文本回执仍归“文档”。已有资源布局若没有代码分区位置,将由现有布局 reconcile 自动补位。
|
||||
5. 产品界面只展示“陶泥儿”“智能创作”等产品术语;Codex app-server 仅保留为内部执行实现和开发诊断名称,不在普通项目聊天、首页创建状态或设置说明中直接暴露。
|
||||
6. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。
|
||||
@@ -115,11 +115,11 @@
|
||||
|
||||
### 验收
|
||||
|
||||
- Rust 单测覆盖:缺规范图、背景、标准图集或任一四类切片时必须请求平台生成或拒绝完成;代码回收必须拒绝未分别引用背景图和四类切片的项目;只有同一平台规范根派生的完整图片合同、文件存在和渲染引用同时满足时才能完成 `code-prototype` 与初始版本登记。
|
||||
- Rust 单测覆盖:可信完整图集缺少固定切片仍可进入 Codex;只有规范图和背景图但历史图集不可安全恢复时不得重复发起付费生成;缺可信平台图片、来源身份不成立、PNG 不可解码或源码完全没有平台素材引用时继续拒绝完成。浏览器证据测试必须区分旁侧 `<img>` 与 Canvas/WebGL 实际渲染,并证明 desktop/mobile 任一视口缺少核心素材观察都会回灌同 thread 整改。
|
||||
- 资源投影与 AppSurface 测试覆盖:`game/index.html`、`game/style.css`、`game/game.js` 显示于“游戏代码”,不再显示于“文档”。
|
||||
- 连续两次直连回合必须保留同一批游戏代码资产 ID,project revision 单调推进,并形成 `initial-* -> agent-*` 的父子版本链;外层资源管理在新 revision 到达后显示游戏代码和项目版本,不能停留在生成前快照。
|
||||
- 已有项目修改测试必须证明系统提示词包含有界当前游戏源码、敏感行被过滤,基于该上下文的 file-change 可以命中;Codex 明确未修改文件时,revision 与版本数量保持不变。
|
||||
- 真实客户端验收必须在新建空项目中看到规范图、背景图和标准核心图集三张 `source.kind=canvas` PNG 位于“美术资源”,四类切片及其合同真实落盘,代码文件位于“游戏代码”,并在运行画面中实际加载背景图和四类切片。
|
||||
- 真实客户端验收必须在新建空项目中看到实际成功生成或安全恢复的 `source.kind=canvas` PNG 位于“美术资源”,代码文件位于“游戏代码”,并在 desktop/mobile 运行画面的核心 Canvas/WebGL 路径中观察到至少一份已登记陶泥儿素材;标准四切片存在时应优先使用,但不存在时不能伪造,也不能据此单独判定游戏失败。
|
||||
|
||||
## 9. 直连创作阶段反馈与资源首屏预览(2026-08-17)
|
||||
|
||||
@@ -198,24 +198,50 @@
|
||||
|
||||
直连 Runtime 只把安全、权限、来源和不可逆副作用留在确定性门禁内:项目工作区与权限边界、凭据隔离、平台生成请求的幂等账本与未知结果恢复、持久文件事务、HTTP 完成包基本结构、图片下载/PNG 解码/可见像素、`source.kind=canvas`、资源身份与同 Canvas 项目关系。上述条件不满足时必须失败关闭,不能交给模型猜测或覆盖。
|
||||
|
||||
`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。只有平台主图集本身无法下载、解码、无可见像素、来源身份不可信或没有任何真实平台素材引用时才阻断。
|
||||
`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。若项目已有同一画布、身份可信且可解码的规范图和背景图,但历史核心图集无法恢复,客户端必须直接复用这两张素材继续 Codex 代码生成与试玩,不重新发起图集付费生成,也不为只读恢复失败阻断本轮。只有已用平台图片本身无法下载、解码、无可见像素、来源身份不可信或最终没有任何真实平台素材引用时才阻断。
|
||||
|
||||
### 13.2 同一 Codex thread 的有界自主验收
|
||||
|
||||
每个直连回合按以下最多三次 Codex turn 执行,不恢复 Supervisor、专业 Agent 或 harness:
|
||||
|
||||
1. 首轮由 Codex 生成或修改当前工作区文件。
|
||||
2. 客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。
|
||||
3. 客户端把脱敏、结构化的浏览器证据发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。
|
||||
2. 客户端先复核最低完成证明:`game/index.html` 是否存在,以及源码是否引用至少一个已登记的平台图片。该证明不满足时,不在版本登记阶段直接报错;客户端把脱敏的具体缺口回灌给同一 Codex thread,待其修复后才启动受限本地预览。该回灌不允许 Codex 修改 manifest、伪造素材或覆盖来源身份。
|
||||
3. 最低证明满足后,客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。
|
||||
4. 客户端把脱敏、结构化的浏览器证据及仍存在的最低完成证明缺口发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。
|
||||
|
||||
最终回复必须包含:检查过的文件、真实启动/试玩动作、desktop/mobile 观察、陶泥儿平台素材如何被实际使用、已修复问题和剩余风险。客户端只展示摘要,不把绝对路径、Token、签名 URL、Provider 原文或内部队列信息传给用户。
|
||||
|
||||
### 13.3 最低使用证明与完成条件
|
||||
|
||||
客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。游戏质量、布局、可玩性、素材是否真的出现在画面中由 Codex 结合真实浏览器截图和结果自行判断。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。
|
||||
客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。该最低证明失败必须先进入同 thread 有界整改,而不是在浏览器成功后才由版本登记阶段突然终止;若最终仍失败,才拒绝登记。
|
||||
|
||||
受限 Chromium 还会对已登记平台图片采集运行时使用观察:图片是否在真实 Canvas / WebGL 渲染调用中出现,以及是否只作为页面可见图片出现。该观察不依赖固定切片、固定文件名、特定棋盘结构或字符串计数,也不替代 Codex 对截图和玩法质量的判断;但若只观察到旁侧预览、未观察到核心渲染路径,客户端必须把这一明确缺口回灌给同一 Codex thread 要求整改,不能把“源码引用”或“旁侧缩略图”表述为素材已进入玩法。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。
|
||||
|
||||
### 13.4 验收要求
|
||||
|
||||
- 定向 Rust 测试覆盖:完整主图集无切片可通过;来源/身份/PNG 解码/同 Canvas 约束仍失败关闭;源码合理引用任一平台素材可登记;无平台素材引用不能完成;系统提示词包含自主试玩与结构化报告要求。
|
||||
- fake app-server 测试覆盖:连续 direct turn 复用同一 thread,浏览器证据和整改反馈形成后续 `turn/start`,无 Supervisor/child/harness。
|
||||
- 真实客户端验收覆盖:当前 checkout 的 AGC 新建或打开直连项目,真实本地预览在 desktop/mobile 运行并至少执行一次可见交互;截图与结构化证据写入项目 `.agent/runtime` 证据目录;完成回复展示可读验收摘要。
|
||||
|
||||
### 13.5 透明后处理失败时的图集源图安全复用(2026-08-18)
|
||||
|
||||
当陶泥儿 `art-spritesheet` 的远端生成已完成、源 PNG 已落在同一画布,但透明化或切片后处理失败时,直连 Runtime 可以在不重新提交、不重复扣费的前提下只读恢复该源图。恢复仅适用于规范图唯一绑定的核心图集:候选必须属于当前 Canvas、生成路由和种类匹配,且要么 `sourceResourceId` 精确指向当前规范图,要么只包含当前规范图的一项不可变参考。多个候选、外部参考、多参考、不可下载、非 PNG、透明或无可见像素一律失败关闭。
|
||||
|
||||
恢复成功后将完整图集作为可信平台素材继续交给同一 Codex thread;客户端不得伪造切片、不得把未生成的切片登记为存在。源图已确认由平台完成且只读恢复成功后,客户端清理同一 `agentId/runId` 的已完成生成账本;只读恢复失败、身份不唯一或结果仍未知时继续保留账本供对账,绝不删除后重发。若没有可安全恢复的历史图集、但规范图和背景图已经可信可用,直连生成可继续使用这两张素材并完成上述 LLM 自主验收,不再为只读恢复失败重发图集请求。
|
||||
|
||||
### 13.6 隔离验收进程的陶泥儿开发者凭据(2026-08-17)
|
||||
|
||||
真实 CLI / Chromium 验收进程不能依赖已打开 GUI 的内存登录态,也不能复制 GUI 的 Cookie、access token 或 Runner 会话。直连 Runtime 首先只读取当前用户私有的 `~/.config/genarrative/external-editor-api.json`:其中保存一次性显示过的开发者 API Key,和可选的受信任陶泥儿 API origin;仓库、项目目录、`game-creator.config.json`、诊断、命令行参数和 Codex 系统提示词均不得出现该 Key。
|
||||
|
||||
当私有 Key 缺失但当前 GUI 已有陶泥儿账号会话时,客户端可按用户授权仅调用受保护的 `/api/profile/api-keys` 创建一个名称固定的本机直连 Key,并以原子写入和当前用户私有权限保存到上述文件。创建后,direct Runtime 的美术准备、只读图集恢复、素材换签、生成提交和轮询统一使用 External v1 路由及该 Key;不得混用账号 JWT 路由。若 Key 缺失且没有 GUI 会话,必须给出“先在客户端登录一次以创建本机开发者 Key”的可行动错误,而不是误报普通试玩失败。
|
||||
|
||||
开发者 Key 创建是唯一允许借用 GUI 登录态的受控设置动作;平台图像生成继续由既有幂等账本、同一 idempotency key 和未知结果恢复保护。真实验收必须至少用一个不带 GUI 内存登录态的新进程恢复同一项目,以证明该私钥链路可独立生成、下载并完成 Chromium desktop/mobile 试玩。
|
||||
|
||||
### 13.7 首次私钥引导的本机安全存储前置与可行动失败说明(2026-08-17)
|
||||
|
||||
首次真实客户端验收表明,失败不是 GUI 登录态未同步:首次本机开发者凭据创建已经拿到远端响应,但 Windows 新建的私有目录 owner 是 `Administrators`,随后 TokenUser 私有 DACL 校验拒绝落盘。一次性显示的远端凭据无法恢复,旧顺序会留下孤儿凭据,因此不能用刷新登录态或自动重放掩盖。
|
||||
|
||||
1. 缺失本机开发者凭据时,客户端必须先准备并验证精确的私有存储目录,再请求远端创建凭据。仅当该精确目录由当前调用以原子创建成功时,才允许初始化 owner 和私有 DACL 为当前 TokenUser;已有目录一律按严格 owner/DACL 校验,owner 不匹配时失败关闭,不自动接管、改 ACL、覆盖或删除。
|
||||
2. 若本机目录预检失败,客户端返回稳定的“本机开发者凭据存储目录未安全初始化;未创建远端凭据”分类,不请求远端创建接口、不发起美术生成、不写 operation 或账本,也不刷新登录态或自动重试。
|
||||
3. 远端响应后原子写入仍可能因并发或磁盘故障失败;该极窄路径必须单独分类为“凭据已创建但未能安全保存”,提示用户在账户开发者凭据页面撤销后再试,不能自动创建第二把凭据。诊断和正式 UI 只展示上述安全摘要与恢复建议,不包含 access token、开发者凭据、响应正文、绝对路径或签名 URL。
|
||||
4. 验收先在当前失败项目上恢复:对遗留的空且 owner 不匹配目录做可恢复隔离后,再复用同一项目发送“继续完成此前三消游戏”。成功标准包括本机私钥存在但不读取其内容、平台素材与版本登记完成、desktop/mobile Chromium 试玩证据和隔离进程恢复;此前已创建但无法恢复的远端孤儿凭据作为明确剩余风险,绝不自动撤销。
|
||||
|
||||
@@ -14235,3 +14235,17 @@
|
||||
- 决策:`npm run agc` 及普通 debug 启动只打开标题为“陶泥儿”的 `client` 客户端窗口,不再由 Tauri setup 自动创建 Agent 聊天开发窗口。普通创作、直连 Codex Runtime 和项目工作台行为均不受影响。
|
||||
- 保留:`?agent-chat` 仅作为显式前端调试路由;`supervisor-chat` 和 `--game-chat` 继续是独立的显式开发验收入口,不会随着普通启动同时弹出。
|
||||
- 守卫:原生壳配置检查必须拒绝恢复 `open_developer_window(app.handle())?` 自动调用;真实开发 smoke 以普通客户端单窗口为准。
|
||||
|
||||
## 2026-08-17 直连 Codex 使用 LLM 自主试玩替代固定美术产物门
|
||||
|
||||
- 直连 Runtime 的确定性门禁只保护工作区权限、凭据隔离、平台生成幂等与账本、文件事务、图片下载/PNG 解码、平台来源身份和同 Canvas 关系。`grid-2x2`、固定四切片、固定文件名及固定 `drawImage` 次数降为陶泥儿标准推荐路径,不再阻断所有合理的游戏产物。
|
||||
- Codex 是唯一执行主体。客户端在同一 Codex thread 内最多追加两次结构化浏览器证据/整改 turn;受限 Chromium 负责 desktop/mobile 页面、Canvas、控制台/网络、截图和有限真实交互探针,禁止恢复 Supervisor、专业 Agent 或 harness,也不把固定玩法状态机当成 direct 完成合同。
|
||||
- 完成登记前至少要有 `game/index.html` 和一个已登记、真实、平台来源的图片被源码引用;游戏质量与视觉实际使用由 Codex 根据真实试玩证据自行判断,最终回复必须说明检查文件、试玩动作、双视口观察、素材使用、修复和剩余风险。
|
||||
|
||||
## 2026-08-17 AGC 直连平台资源失败诊断
|
||||
|
||||
- 普通客户端使用陶泥儿平台账号会话时,直连 Runtime 的只读画布恢复继续以 External Editor 形状构造请求,再统一经 `resolve_platform_editor_api_route` 映射到 `/api/editor/...` 与 `/api/assets/...`;不得把平台 access token 直接送到未映射的 `/api/external/v1/...` 路由,也不得为排障改用或落盘开发者 API Key。
|
||||
- 直连回合的请求准备、平台美术准备、Codex 代码生成、真实浏览器试玩和版本登记失败,统一投影为 `direct-codex-failure:v1`:仅含稳定阶段、脱敏摘要、是否可重试和下一步建议。每次失败尽力写入项目 `.agent/runtime/direct-codex-diagnostics/<nonce>/failure.json`,不保存 token、Cookie、完整 URL、绝对路径或原始服务端正文;写诊断失败不得遮蔽原失败。
|
||||
- 普通客户端必须展示上述安全摘要与建议,不能把可解释的资源恢复失败降级成“执行失败,请稍后重试”。平台账号失效提示重新登录;资源身份冲突、多个同源图集或透明图集明确要求先在资源画布核对,而非盲目重复生成或扣费。
|
||||
- 已有同一画布、身份可信且可解码的规范图与背景图时,历史核心图集的只读恢复只是可选增强:未找到该图集不得阻断直连 Codex 生成、浏览器试玩或版本登记,也不得触发重复付费生成;最终源码仍须实际引用至少一个已登记的平台图片。
|
||||
- 透明后处理失败但平台已保留源图时,只有同一画布身份的只读恢复成功后才清理对应 `agentId/runId` 生成账本;恢复失败、身份不唯一或结果未知继续保留 `accepted` / `operationId` 供对账,禁止因清理过早而重复扣费。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user