修复 Web 预检浏览器失败恢复
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m45s
Project CI / Frontend tests (pull_request) Successful in 2m14s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m13s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m55s
Project CI / Native shell tests (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m45s
Project CI / Frontend tests (pull_request) Successful in 2m14s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m13s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m55s
Project CI / Native shell tests (pull_request) Successful in 5m44s
- WS 握手与 CDP 连接瞬态失败在确认进程树清理后使用新 Profile 重试一次\n- 加强 Windows Edge 进程身份、命令行 Profile 与 Job 子树归属校验\n- 透传恢复阶段诊断并补充预检规范、里程碑计划和定向测试
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use std::fs;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
#[cfg(windows)]
|
||||
@@ -120,8 +121,19 @@ enum OwnedBrowserLaunchError {
|
||||
}
|
||||
|
||||
impl OwnedBrowserLaunchError {
|
||||
fn is_timeout(self) -> bool {
|
||||
matches!(self, Self::WsTimeout | Self::ConnectTimeout)
|
||||
fn stage(self) -> BrowserLaunchStage {
|
||||
match self {
|
||||
Self::SpawnFailed => BrowserLaunchStage::Spawn,
|
||||
Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake,
|
||||
Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_recoverable(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed
|
||||
)
|
||||
}
|
||||
|
||||
fn code(self) -> &'static str {
|
||||
@@ -135,6 +147,118 @@ impl OwnedBrowserLaunchError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BrowserLaunchStage {
|
||||
Setup,
|
||||
Spawn,
|
||||
WsHandshake,
|
||||
CdpConnect,
|
||||
}
|
||||
|
||||
impl BrowserLaunchStage {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Setup => "setup",
|
||||
Self::Spawn => "spawn",
|
||||
Self::WsHandshake => "ws-handshake",
|
||||
Self::CdpConnect => "cdp-connect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct BrowserLaunchFailure {
|
||||
code: &'static str,
|
||||
stage: BrowserLaunchStage,
|
||||
recoverable: bool,
|
||||
subprocess_exited: bool,
|
||||
cleanup_confirmed: bool,
|
||||
}
|
||||
|
||||
impl BrowserLaunchFailure {
|
||||
fn setup(code: &'static str) -> Self {
|
||||
Self {
|
||||
code,
|
||||
stage: BrowserLaunchStage::Setup,
|
||||
recoverable: false,
|
||||
subprocess_exited: true,
|
||||
cleanup_confirmed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_launch_error(
|
||||
error: OwnedBrowserLaunchError,
|
||||
subprocess_exited: bool,
|
||||
cleanup_confirmed: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: error.code(),
|
||||
stage: error.stage(),
|
||||
recoverable: error.is_recoverable(),
|
||||
subprocess_exited,
|
||||
cleanup_confirmed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct BrowserRecoveryFailure {
|
||||
first: BrowserLaunchFailure,
|
||||
final_attempt: Option<BrowserLaunchFailure>,
|
||||
}
|
||||
|
||||
impl BrowserRecoveryFailure {
|
||||
fn diagnostic(self) -> String {
|
||||
match self.final_attempt {
|
||||
Some(final_attempt) => format!(
|
||||
"browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}",
|
||||
self.first.stage.as_str(),
|
||||
final_attempt.stage.as_str(),
|
||||
final_attempt.subprocess_exited,
|
||||
final_attempt.cleanup_confirmed,
|
||||
self.first.code,
|
||||
final_attempt.code,
|
||||
),
|
||||
None => format!(
|
||||
"{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}",
|
||||
if self.first.recoverable {
|
||||
"browser-recovery-blocked"
|
||||
} else {
|
||||
"browser-launch-failed"
|
||||
},
|
||||
self.first.stage.as_str(),
|
||||
self.first.subprocess_exited,
|
||||
self.first.cleanup_confirmed,
|
||||
self.first.code,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn launch_with_one_recovery<T, F, Fut>(mut launch: F) -> Result<T, BrowserRecoveryFailure>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T, BrowserLaunchFailure>>,
|
||||
{
|
||||
let first = match launch().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(error) => error,
|
||||
};
|
||||
if !first.recoverable || !first.subprocess_exited || !first.cleanup_confirmed {
|
||||
return Err(BrowserRecoveryFailure {
|
||||
first,
|
||||
final_attempt: None,
|
||||
});
|
||||
}
|
||||
match launch().await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(final_attempt) => Err(BrowserRecoveryFailure {
|
||||
first,
|
||||
final_attempt: Some(final_attempt),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type BrowserStderrReader = futures::io::BufReader<BrowserChildStderr>;
|
||||
|
||||
/// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到
|
||||
@@ -224,22 +348,22 @@ impl BrowserProcessGuard {
|
||||
async fn confirm_reaped(&mut self) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(job) = &self.job {
|
||||
let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT;
|
||||
loop {
|
||||
if job
|
||||
.is_empty()
|
||||
.map_err(|_| "browser-tree-reap-unconfirmed".to_string())?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("browser-tree-reap-unconfirmed".into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let Some(job) = &self.job else {
|
||||
return Err("browser-tree-reap-unconfirmed".into());
|
||||
};
|
||||
let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT;
|
||||
loop {
|
||||
if job
|
||||
.is_empty()
|
||||
.map_err(|_| "browser-tree-reap-unconfirmed".to_string())?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("browser-tree-reap-unconfirmed".into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
@@ -281,7 +405,10 @@ impl OwnedBrowser {
|
||||
let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await;
|
||||
self.handler_task.abort();
|
||||
self.drain_task.abort();
|
||||
if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) {
|
||||
if matches!(close, Ok(Ok(_)))
|
||||
&& matches!(waited, Ok(Ok(_)))
|
||||
&& self.process.confirm_reaped().await.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if !self.process.reap().await {
|
||||
@@ -310,6 +437,24 @@ impl Drop for OwnedBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_failed_launch(
|
||||
mut process: BrowserProcessGuard,
|
||||
temp: TempDir,
|
||||
error: OwnedBrowserLaunchError,
|
||||
) -> BrowserLaunchFailure {
|
||||
let subprocess_exited = process.reap().await;
|
||||
let tree_reaped = subprocess_exited && process.confirm_reaped().await.is_ok();
|
||||
drop(process);
|
||||
let cleanup_confirmed = if tree_reaped {
|
||||
temp.close().is_ok()
|
||||
} else {
|
||||
// 保留 Profile 和 owner.json;后续清扫不得因证据丢失猜测归属。
|
||||
let _ = temp.keep();
|
||||
false
|
||||
};
|
||||
BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed)
|
||||
}
|
||||
|
||||
/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出
|
||||
/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出;
|
||||
/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子
|
||||
@@ -319,15 +464,27 @@ async fn launch_owned_browser(
|
||||
config: BrowserConfig,
|
||||
executable: &std::path::Path,
|
||||
temp: TempDir,
|
||||
) -> Result<OwnedBrowser, OwnedBrowserLaunchError> {
|
||||
let child = config
|
||||
.launch()
|
||||
.map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?;
|
||||
// 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。
|
||||
) -> Result<OwnedBrowser, BrowserLaunchFailure> {
|
||||
let child = match config.launch() {
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
return Err(BrowserLaunchFailure::from_launch_error(
|
||||
OwnedBrowserLaunchError::SpawnFailed,
|
||||
true,
|
||||
true,
|
||||
));
|
||||
}
|
||||
};
|
||||
// 无法证明整树可收割时拒绝放行浏览器;先收束 root,但不宣称 Job 子树已清空。
|
||||
#[cfg(windows)]
|
||||
let job = match WindowsProcessJob::assign_tokio(&child.inner) {
|
||||
Ok(job) => Some(job),
|
||||
Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed),
|
||||
Err(_) => {
|
||||
let process = BrowserProcessGuard { child, job: None };
|
||||
return Err(
|
||||
cleanup_failed_launch(process, temp, OwnedBrowserLaunchError::SpawnFailed).await,
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut process = BrowserProcessGuard {
|
||||
child,
|
||||
@@ -336,17 +493,13 @@ async fn launch_owned_browser(
|
||||
};
|
||||
// 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。
|
||||
if let Some(pid) = process.child.inner.id() {
|
||||
super::sweep::write_browser_process_owner(temp.path(), pid, executable);
|
||||
let _ = super::sweep::write_browser_process_owner(temp.path(), pid, executable);
|
||||
}
|
||||
let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(error) => {
|
||||
// 收割未确认时保留目录与 owner.json,交给跨会话清扫。
|
||||
if !process.reap().await {
|
||||
std::mem::forget(temp);
|
||||
}
|
||||
return Err(error);
|
||||
return Err(cleanup_failed_launch(process, temp, error).await);
|
||||
}
|
||||
};
|
||||
let drain_task = spawn_stderr_drain(reader);
|
||||
@@ -359,17 +512,21 @@ async fn launch_owned_browser(
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(_)) => {
|
||||
drain_task.abort();
|
||||
if !process.reap().await {
|
||||
std::mem::forget(temp);
|
||||
}
|
||||
return Err(OwnedBrowserLaunchError::ConnectFailed);
|
||||
return Err(cleanup_failed_launch(
|
||||
process,
|
||||
temp,
|
||||
OwnedBrowserLaunchError::ConnectFailed,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
Err(_) => {
|
||||
drain_task.abort();
|
||||
if !process.reap().await {
|
||||
std::mem::forget(temp);
|
||||
}
|
||||
return Err(OwnedBrowserLaunchError::ConnectTimeout);
|
||||
return Err(cleanup_failed_launch(
|
||||
process,
|
||||
temp,
|
||||
OwnedBrowserLaunchError::ConnectTimeout,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
let handler_task = tokio::spawn(async move {
|
||||
@@ -388,22 +545,31 @@ async fn launch_owned_browser(
|
||||
})
|
||||
}
|
||||
|
||||
async fn launch_browser_attempt(
|
||||
executable: &std::path::Path,
|
||||
proxy_bypass_list: &str,
|
||||
) -> Result<OwnedBrowser, BrowserLaunchFailure> {
|
||||
let temporary = create_browser_process_temp_dir()
|
||||
.map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?;
|
||||
let config = browser_config(executable, &temporary, proxy_bypass_list)
|
||||
.map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?;
|
||||
launch_owned_browser(config, executable, temporary).await
|
||||
}
|
||||
|
||||
async fn launch_browser_with_recovery(
|
||||
executable: &std::path::Path,
|
||||
proxy_bypass_list: &str,
|
||||
) -> Result<OwnedBrowser, String> {
|
||||
launch_with_one_recovery(|| launch_browser_attempt(executable, proxy_bypass_list))
|
||||
.await
|
||||
.map_err(|failure| failure.diagnostic())
|
||||
}
|
||||
|
||||
/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。
|
||||
/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。
|
||||
pub(crate) async fn check_browser_health() -> Result<BrowserIdentity, String> {
|
||||
let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?;
|
||||
let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?;
|
||||
let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>")
|
||||
.map_err(|_| "browser-config-invalid")?;
|
||||
let owned = launch_owned_browser(config, &discovered.executable_path, temporary)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"browser-start-timeout"
|
||||
} else {
|
||||
"browser-start-failed"
|
||||
}
|
||||
})?;
|
||||
let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?;
|
||||
let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await;
|
||||
if owned.shutdown().await.is_err() {
|
||||
return Err("browser-cleanup-failed".into());
|
||||
@@ -460,23 +626,19 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
|
||||
let preview_url = validate_input(&input)?;
|
||||
prepare_evidence_root(&input.evidence_root)?;
|
||||
let browser_executable = discover_chrome_or_edge()?;
|
||||
let browser_temp = create_browser_process_temp_dir()?;
|
||||
let proxy_bypass_list = preview_proxy_bypass_list(&preview_url);
|
||||
let config = browser_config(
|
||||
&browser_executable.executable_path,
|
||||
&browser_temp,
|
||||
&proxy_bypass_list,
|
||||
)?;
|
||||
|
||||
let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"启动浏览器超时".to_string()
|
||||
} else {
|
||||
format!("启动浏览器失败:{}", error.code())
|
||||
}
|
||||
})?;
|
||||
let owned =
|
||||
launch_browser_with_recovery(&browser_executable.executable_path, &proxy_bypass_list)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.starts_with("browser-recovery-")
|
||||
|| error.starts_with("browser-launch-failed")
|
||||
{
|
||||
error
|
||||
} else {
|
||||
format!("启动浏览器失败:{error}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let work = run_browser_validation(
|
||||
owned.browser(),
|
||||
@@ -583,6 +745,88 @@ mod health_tests {
|
||||
assert_eq!(config.viewport, Some(Viewport::default()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_ws_failure_retries_after_confirmed_cleanup_with_new_profile() {
|
||||
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let profiles = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let attempts_for_launch = std::sync::Arc::clone(&attempts);
|
||||
let profiles_for_launch = std::sync::Arc::clone(&profiles);
|
||||
let result = launch_with_one_recovery(move || {
|
||||
let attempt = attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
let profiles = std::sync::Arc::clone(&profiles_for_launch);
|
||||
async move {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
profiles
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.push(temporary.path().to_path_buf());
|
||||
if attempt == 0 {
|
||||
drop(temporary);
|
||||
Err(BrowserLaunchFailure::from_launch_error(
|
||||
OwnedBrowserLaunchError::WsFailed,
|
||||
true,
|
||||
true,
|
||||
))
|
||||
} else {
|
||||
drop(temporary);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 2);
|
||||
let profiles = profiles
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
assert_eq!(profiles.len(), 2);
|
||||
assert_ne!(profiles[0], profiles[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_recovery_does_not_retry_when_cleanup_is_unconfirmed() {
|
||||
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let attempts_for_launch = std::sync::Arc::clone(&attempts);
|
||||
let failure = launch_with_one_recovery(move || {
|
||||
attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
async {
|
||||
Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error(
|
||||
OwnedBrowserLaunchError::ConnectTimeout,
|
||||
false,
|
||||
false,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let diagnostic = failure.diagnostic();
|
||||
assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 1);
|
||||
assert!(diagnostic.contains("stage=cdp-connect"));
|
||||
assert!(diagnostic.contains("subprocess-exited=false"));
|
||||
assert!(diagnostic.contains("cleanup-confirmed=false"));
|
||||
assert!(diagnostic.contains("recovery-retried=false"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() {
|
||||
let failure = launch_with_one_recovery(|| async {
|
||||
Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error(
|
||||
OwnedBrowserLaunchError::WsFailed,
|
||||
true,
|
||||
true,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let diagnostic = failure.diagnostic();
|
||||
assert!(diagnostic.contains("initial-stage=ws-handshake"));
|
||||
assert!(diagnostic.contains("final-stage=ws-handshake"));
|
||||
assert!(diagnostic.contains("subprocess-exited=true"));
|
||||
assert!(diagnostic.contains("cleanup-confirmed=true"));
|
||||
assert!(diagnostic.contains("recovery-retried=true"));
|
||||
assert!(!diagnostic.contains("/tmp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"]
|
||||
async fn real_browser_health_checks_can_run_concurrently() {
|
||||
|
||||
@@ -34,13 +34,17 @@ pub(super) struct BrowserProcessOwner {
|
||||
|
||||
/// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写,
|
||||
/// 该目录之后按旧残留只删不杀。
|
||||
pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) {
|
||||
pub(super) fn write_browser_process_owner(
|
||||
temp_root: &Path,
|
||||
browser_pid: u32,
|
||||
executable: &Path,
|
||||
) -> bool {
|
||||
let identity =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(browser_pid);
|
||||
let owner_identity =
|
||||
crate::process_identity::external_agent_runner_process_start_identity(std::process::id());
|
||||
let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let owner = BrowserProcessOwner {
|
||||
schema_version: OWNER_SCHEMA.into(),
|
||||
@@ -52,9 +56,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex
|
||||
created_unix_ms: super::evidence::unix_time_ms(),
|
||||
};
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&owner) else {
|
||||
return;
|
||||
return false;
|
||||
};
|
||||
let _ = fs::write(temp_root.join(OWNER_FILE), bytes);
|
||||
fs::write(temp_root.join(OWNER_FILE), bytes).is_ok()
|
||||
}
|
||||
|
||||
fn read_browser_process_owner(dir: &Path) -> Option<BrowserProcessOwner> {
|
||||
@@ -115,34 +119,109 @@ fn trusted_browser_executable(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行
|
||||
/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造
|
||||
/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。
|
||||
/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置
|
||||
/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。
|
||||
/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC
|
||||
/// 实例或用户 Edge 误认成本轮浏览器。
|
||||
#[cfg(windows)]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool {
|
||||
// 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对
|
||||
// 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。
|
||||
fn windows_process_command_line(pid: u32) -> Option<String> {
|
||||
use std::ffi::c_void;
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
struct UnicodeString {
|
||||
length: u16,
|
||||
maximum_length: u16,
|
||||
buffer: *const u16,
|
||||
}
|
||||
|
||||
#[link(name = "ntdll")]
|
||||
unsafe extern "system" {
|
||||
fn NtQueryInformationProcess(
|
||||
process: *mut c_void,
|
||||
information_class: u32,
|
||||
information: *mut c_void,
|
||||
information_length: u32,
|
||||
return_length: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) };
|
||||
if process.is_null() {
|
||||
return None;
|
||||
}
|
||||
let mut required = 0;
|
||||
let _ =
|
||||
unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) };
|
||||
if required == 0 || required > 64 * 1024 {
|
||||
unsafe { CloseHandle(process) };
|
||||
return None;
|
||||
}
|
||||
let mut buffer = vec![0u8; required as usize];
|
||||
let status = unsafe {
|
||||
NtQueryInformationProcess(
|
||||
process,
|
||||
60,
|
||||
buffer.as_mut_ptr().cast(),
|
||||
required,
|
||||
&mut required,
|
||||
)
|
||||
};
|
||||
unsafe { CloseHandle(process) };
|
||||
if status != 0 {
|
||||
return None;
|
||||
}
|
||||
let command_line = unsafe { &*(buffer.as_ptr().cast::<UnicodeString>()) };
|
||||
if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let text = unsafe {
|
||||
std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2)
|
||||
};
|
||||
String::from_utf16(text).ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool {
|
||||
const MARKER: &str = "--user-data-dir=";
|
||||
let mut remaining = command_line;
|
||||
while let Some(index) = remaining.find(MARKER) {
|
||||
let value = remaining[index + MARKER.len()..].trim_start();
|
||||
let value = if let Some(quoted) = value.strip_prefix('"') {
|
||||
quoted.split('"').next().unwrap_or_default()
|
||||
} else {
|
||||
value.split_whitespace().next().unwrap_or_default()
|
||||
};
|
||||
if same_executable_path(Path::new(value), profile_dir) {
|
||||
return true;
|
||||
}
|
||||
remaining = &remaining[index + MARKER.len()..];
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
// SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
let mut buffer = [0u16; 1024];
|
||||
let mut length = buffer.len() as u32;
|
||||
// SAFETY: buffer 可写,length 先传入容量、返回实际长度。
|
||||
let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) };
|
||||
// SAFETY: handle 是本函数持有的合法句柄。
|
||||
unsafe { CloseHandle(handle) };
|
||||
if okay == 0 || length == 0 {
|
||||
return false;
|
||||
}
|
||||
let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize]));
|
||||
same_executable_path(&actual, expected)
|
||||
&& windows_process_command_line(pid)
|
||||
.is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -468,6 +547,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_write_failure_leaves_no_partial_ownership_record() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let missing_root = root.path().join("missing");
|
||||
assert!(!write_browser_process_owner(
|
||||
&missing_root,
|
||||
std::process::id(),
|
||||
&std::env::current_exe().unwrap(),
|
||||
));
|
||||
assert!(!missing_root.join(OWNER_FILE).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -548,6 +639,24 @@ mod tests {
|
||||
assert!(trusted_browser_executable(&installed));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_browser_cleanup_requires_the_exact_profile_argument() {
|
||||
let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#);
|
||||
assert!(command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(!command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#,
|
||||
profile
|
||||
));
|
||||
assert!(!command_line_contains_profile(
|
||||
r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#,
|
||||
profile
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn kill_browser_process_tree_reaps_fixture_tree() {
|
||||
@@ -600,10 +709,8 @@ mod tests {
|
||||
fn live_process_executable_matches_current_process_image() {
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let profile = Path::new("C:\\fixture\\ga-browser-x\\profile");
|
||||
// Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程
|
||||
// 不具备该标记,正例只在 Windows 断言。
|
||||
#[cfg(windows)]
|
||||
assert!(live_process_executable_matches(
|
||||
// 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。
|
||||
assert!(!live_process_executable_matches(
|
||||
std::process::id(),
|
||||
&exe,
|
||||
profile
|
||||
|
||||
@@ -311,6 +311,13 @@ async fn host_npm(
|
||||
fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
if error.contains("未发现可用的") {
|
||||
"web-preflight-browser-missing"
|
||||
} else if error.starts_with("browser-recovery-") {
|
||||
"web-preflight-browser-recovery-failed"
|
||||
} else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid")
|
||||
{
|
||||
"web-preflight-browser-environment-failed"
|
||||
} else if error.starts_with("browser-launch-failed:") {
|
||||
"web-preflight-browser-launch-failed"
|
||||
} else if error.contains("启动浏览器超时") {
|
||||
"web-preflight-browser-launch-timeout"
|
||||
} else if error.contains("启动浏览器失败") {
|
||||
@@ -342,6 +349,11 @@ fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_validation_diagnostic(error: &str) -> Option<&str> {
|
||||
(error.starts_with("browser-recovery-") || error.starts_with("browser-launch-failed:"))
|
||||
.then_some(error)
|
||||
}
|
||||
|
||||
pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let started = Instant::now();
|
||||
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
|
||||
@@ -362,18 +374,60 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput {
|
||||
url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"),
|
||||
}).await;
|
||||
let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?;
|
||||
if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); }
|
||||
let result = validation
|
||||
.map_err(|error| {
|
||||
let code = browser_validation_failure_code(&error);
|
||||
browser_validation_diagnostic(&error)
|
||||
.map(|diagnostic| format!("{code};diagnostic={diagnostic}"))
|
||||
.unwrap_or_else(|| code.to_string())
|
||||
})?;
|
||||
if !result.passed || result.viewport_results.len() != 2 {
|
||||
return Err("web-preflight-page-check-failed".into());
|
||||
}
|
||||
let mut pngs = Vec::new();
|
||||
for viewport in result.viewport_results {
|
||||
let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?;
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); }
|
||||
image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?;
|
||||
pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()}));
|
||||
let bytes = fs::read(&viewport.screenshot_path)
|
||||
.map_err(|_| "web-preflight-screenshot-missing")?;
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 {
|
||||
return Err("web-preflight-screenshot-invalid".into());
|
||||
}
|
||||
image::load_from_memory(&bytes)
|
||||
.map_err(|_| "web-preflight-screenshot-invalid")?;
|
||||
pngs.push(json!({
|
||||
"viewport": viewport.viewport,
|
||||
"passed": viewport.passed,
|
||||
"pngBytes": bytes.len(),
|
||||
}));
|
||||
}
|
||||
Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}}))
|
||||
}.await;
|
||||
let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code}));
|
||||
Ok::<_, String>(json!({
|
||||
"schemaVersion": "agc-web-creation-preflight.v1",
|
||||
"status": "ready",
|
||||
"runtime": {
|
||||
"source": runtime.source,
|
||||
"nodeVersion": node,
|
||||
"npmVersion": npm,
|
||||
},
|
||||
"build": {"status": "ready", "kind": "npm-node-fixture"},
|
||||
"browser": {"status": "ready", "viewports": pngs},
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
let mut result = run.unwrap_or_else(|error| {
|
||||
let (code, diagnostic) = error
|
||||
.split_once(";diagnostic=")
|
||||
.map_or((error.as_str(), None), |(code, diagnostic)| {
|
||||
(code, Some(diagnostic))
|
||||
});
|
||||
let mut blocked = json!({
|
||||
"schemaVersion": "agc-web-creation-preflight.v1",
|
||||
"status": "blocked",
|
||||
"code": code,
|
||||
});
|
||||
if let Some(diagnostic) = diagnostic {
|
||||
blocked["diagnostic"] = json!(diagnostic);
|
||||
}
|
||||
blocked
|
||||
});
|
||||
result["elapsedMs"] = json!(started.elapsed().as_millis());
|
||||
result
|
||||
}
|
||||
@@ -382,9 +436,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
pub(crate) async fn preflight_web_game_creation() -> Result<Value, String> {
|
||||
let result = host_web_creation_preflight().await;
|
||||
if result["status"] != "ready" {
|
||||
let diagnostic = result["diagnostic"]
|
||||
.as_str()
|
||||
.map(|value| format!(";诊断:{value}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"Web 游戏环境预检未通过:{};尚未启动生成",
|
||||
result["code"].as_str().unwrap_or("web-preflight-failed")
|
||||
"Web 游戏环境预检未通过:{}{};尚未启动生成",
|
||||
result["code"].as_str().unwrap_or("web-preflight-failed"),
|
||||
diagnostic,
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
@@ -548,6 +607,27 @@ mod tests {
|
||||
browser_validation_failure_code("启动浏览器失败:2"),
|
||||
"web-preflight-browser-launch-failed"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"browser-launch-failed: stage=setup cause=browser-temp-unavailable"
|
||||
),
|
||||
"web-preflight-browser-environment-failed"
|
||||
);
|
||||
|
||||
let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true";
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(recovery_diagnostic),
|
||||
"web-preflight-browser-recovery-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic(recovery_diagnostic),
|
||||
Some(recovery_diagnostic)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_diagnostic("启动浏览器失败:原始错误"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
|
||||
"web-preflight-cancelled"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Web 预检浏览器失败恢复实现计划
|
||||
|
||||
- Version: `1`
|
||||
- Status: `active`
|
||||
- Date: `2026-10-02`
|
||||
- Milestone: [`Web 预检浏览器失败恢复`](./【里程碑】Web预检浏览器失败恢复-2026-10-02.md)
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。
|
||||
2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。
|
||||
3. 保留/加强 owner 与 sweep 的归属门禁,补齐 Windows 活进程 Profile 命令行标识核对;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。
|
||||
4. 将安全恢复诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。
|
||||
5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。
|
||||
|
||||
## 验证命令
|
||||
|
||||
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell browser::`
|
||||
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell environment_check::web_creation::tests`
|
||||
- `npx vitest run apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx`
|
||||
- `npm run typecheck --workspace apps/ai-game-creator-shell`
|
||||
- `npm run check:encoding`
|
||||
- `git diff --check`
|
||||
|
||||
可选真实环境证据:安装 Windows Edge 时运行现有 `real_browser_health_checks_can_run_concurrently` 及新增恢复 smoke;无浏览器时保持 ignored,不把缺失环境写成通过。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
- Windows 进程命令行读取失败按不匹配处理,不执行杀进程;这可能留下临时目录,但保证不误杀。
|
||||
- 首次失败进程树收束未确认时不自动重试,避免第二个 Edge 与残留树并存;错误返回安全诊断。
|
||||
- 回滚点为浏览器启动恢复入口和 owner/sweep 归属校验,不触及 Web 预检的 Node/npm 或项目写入流程。
|
||||
@@ -0,0 +1,41 @@
|
||||
# Web 预检浏览器失败恢复
|
||||
|
||||
- Version: `1`
|
||||
- Status: `active`
|
||||
- Date: `2026-10-02`
|
||||
- Parent Spec: [`AI游戏创作智能体 App 实施计划`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#自动预检与可信脚手架)
|
||||
|
||||
## 目标
|
||||
|
||||
修复 Issue #584:Web 游戏环境预检在受控 Edge/Chrome 的 WS 握手或 CDP 连接失败时,能够只针对本轮 AGC 浏览器实例完成清理、使用新的隔离 Profile 重试一次,并在仍失败时保留安全、可行动的诊断信息。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只改 AGC Rust 浏览器启动/清理、Web 预检错误投影及其定向测试、对应现行专题文档。
|
||||
- 清理对象必须同时满足 AGC 临时 Profile、进程启动身份、可信浏览器可执行文件和 Windows 进程树归属;无法确认时 fail-closed。
|
||||
- 不执行全量 `taskkill /IM msedge.exe`,不影响用户 Edge、其它 AGC 实例、其它项目或 worktree。
|
||||
- 不改变 Web 预检的失败关闭、Node/npm 检查、桌面/移动双视口检查和首次生成顺序。
|
||||
- 不新增网络、自动下载、跳过浏览器验证或伪造 ready 的旁路。
|
||||
|
||||
## 行为合同
|
||||
|
||||
1. WS 握手失败、WS 超时、CDP 连接失败或 CDP 连接超时属于可恢复的浏览器启动瞬态失败。
|
||||
2. 第一次失败后,宿主必须先确认本轮进程树已退出并清理本轮 Profile / owner 记录 / 临时目录;清理未确认时不得启动第二次浏览器。
|
||||
3. 清理确认后,第二次启动必须创建新的隔离临时目录和 Profile;第二次成功后继续现有 desktop/mobile 预检。
|
||||
4. 连续失败或清理被阻断时,结果保持 blocked,并带有阶段(WS 握手或 CDP 连接)、子进程是否退出、清理是否确认、是否执行恢复重试等安全诊断。
|
||||
5. owner.json 缺失/写入失败、目录过新、进程已退出、PID 被复用、身份未知或归属不明时只能按保守路径处理:不杀不明进程;可安全删除的临时目录才删除。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 定向测试构造 `browser-ws-failed` 后证明只在清理确认时重试,且重试使用新的 Profile;第二次成功返回成功。
|
||||
- 定向测试覆盖 WS/CDP 阶段、连续失败、清理未确认、owner.json 缺失/无效、刚创建目录、进程退出、PID 复用和非 AGC 进程跳过。
|
||||
- Windows 真实 Edge 安装版 smoke 保留在现有真实浏览器测试入口中;无 Edge 的环境明确跳过而不是伪造通过。
|
||||
- 现有首页预检、正常 Edge 使用、首次生成失败关闭和前端 IPC/宿主错误区分回归通过。
|
||||
|
||||
## 依赖
|
||||
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/browser/process.rs`
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs`
|
||||
- `apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs`
|
||||
- `apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts`
|
||||
- 对应 Rust 与 Vitest 测试。
|
||||
@@ -134,7 +134,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema
|
||||
- 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。
|
||||
- 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。
|
||||
- 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。
|
||||
- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。
|
||||
- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。浏览器 WS 握手、WS 超时和 CDP 连接失败只允许在确认本轮 AGC 浏览器进程树已退出、Profile/owner/临时目录已按归属清理后使用新的隔离 Profile 自动恢复一次;清理未确认时不得重试,连续失败必须返回包含阶段、子进程退出确认、清理确认和是否重试的安全诊断。
|
||||
- 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。
|
||||
|
||||
### 跑酷固定基线
|
||||
@@ -188,7 +188,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema
|
||||
### 环境与工作流
|
||||
|
||||
- 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。随包运行时是**单架构**官方发行版,因此 macOS 当前只构建 `aarch64-apple-darwin` 单架构包;要出 universal 必须先让 staging 支持按架构各带一份同版本运行时,在此之前 universal 目标失败关闭,不得只带宿主架构那一份糊过去。
|
||||
- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。
|
||||
- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。
|
||||
- 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。
|
||||
- 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user