From 1ea0e48113d3ded14a187e4b148097d2cd8354e8 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 5 Nov 2026 12:41:08 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E7=83=AD=E9=87=8D=E8=BD=BD=E5=8D=8F=E8=B0=83?= =?UTF-8?q?=E4=B8=8E=E7=8A=B6=E6=80=81=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/codex-plus-launcher/src/main.rs | 97 +- .../src/user_script_reload.rs | 859 ++++++++++++ .../src-tauri/src/commands.rs | 58 +- .../src/extension_creator_commands.rs | 55 +- apps/codex-plus-manager/src/App.tsx | 29 +- .../src/extension-host-model.ts | 1 + .../src/renderer-inject.test.ts | 68 +- .../src/script-market-model.test.ts | 3 + .../src/script-market-model.ts | 6 +- assets/inject/renderer-inject.js | 130 +- .../renderer-inject/40-backend-settings.js | 87 +- .../renderer-inject/41-extension-detail.js | 2 +- .../renderer-inject/91-extension-runtime.js | 41 +- assets/inject/renderer-inject/manifest.json | 4 +- assets/inject/user-scripts-bootstrap.js | 16 +- assets/inject/user-scripts-runtime.js | 111 +- crates/codex-plus-core/Cargo.toml | 3 + .../codex-plus-core/src/extension_backend.rs | 447 ++++++- .../codex-plus-core/src/extension_creator.rs | 44 +- crates/codex-plus-core/src/lib.rs | 1 + crates/codex-plus-core/src/routes.rs | 42 +- .../src/user_script_control.rs | 842 ++++++++++++ crates/codex-plus-core/src/user_scripts.rs | 1168 ++++++++++++++++- .../tests/all/bridge_routes.rs | 65 +- .../tests/all/extension_backend.rs | 144 +- .../tests/all/extension_creator.rs | 3 +- .../tests/all/user_scripts_reload.rs | 278 +++- scripts/user-script-control-clients.test.mjs | 108 ++ scripts/user-scripts-runtime.test.mjs | 124 ++ 29 files changed, 4572 insertions(+), 264 deletions(-) create mode 100644 apps/codex-plus-launcher/src/user_script_reload.rs create mode 100644 crates/codex-plus-core/src/user_script_control.rs create mode 100644 scripts/user-script-control-clients.test.mjs create mode 100644 scripts/user-scripts-runtime.test.mjs diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index d22c92ebda..5420227cbe 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -14,6 +14,8 @@ use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock, mpsc}; +mod user_script_reload; + type NavigationHandler = Arc Result<()> + Send + Sync>; static NAVIGATION_HANDLER: OnceLock>> = OnceLock::new(); @@ -1415,6 +1417,7 @@ impl LaunchHooks for LauncherHooks { app_dir: &Path, ) -> anyhow::Result> { self.runtime.set_debug_port(debug_port); + self.runtime.start_native_control(debug_port)?; let ctx = BridgeContext::core_with_data_and_app_dir( self.runtime.clone(), self.data.clone(), @@ -1490,6 +1493,7 @@ impl LaunchHooks for LauncherHooks { } async fn shutdown_helper(&self, helper_port: u16) { + self.runtime.stop_native_control(); self.core.shutdown_helper(helper_port).await; } @@ -1720,16 +1724,20 @@ impl LauncherDataService { struct LauncherRuntimeService { debug_port: Mutex, - websocket_url: Mutex>, + websocket_url: Arc>>, user_scripts: UserScriptManager, + reload_owner: user_script_reload::ReloadOwner, + native_control: Mutex>, } impl LauncherRuntimeService { fn new(debug_port: u16, user_scripts: UserScriptManager) -> Self { Self { debug_port: Mutex::new(debug_port), - websocket_url: Mutex::new(None), + websocket_url: Arc::new(Mutex::new(None)), user_scripts, + reload_owner: user_script_reload::ReloadOwner::default(), + native_control: Mutex::new(None), } } @@ -1740,6 +1748,40 @@ impl LauncherRuntimeService { fn set_websocket_url(&self, websocket_url: &str) { *self.websocket_url.lock().unwrap() = Some(websocket_url.to_string()); } + + fn start_native_control(self: &Arc, debug_port: u16) -> anyhow::Result<()> { + use codex_plus_core::user_script_control::{self as control, ControlAction, ControlIdentity}; + let mut current = self.native_control.lock().unwrap(); + if current.as_ref().is_some_and(|(port, _)| *port == debug_port) { + return Ok(()); + } + anyhow::ensure!(current.is_none(), "重载协调器已绑定另一个实例端口"); + let identity = ControlIdentity::new( + &codex_plus_core::paths::default_app_state_dir(), debug_port, &self.user_scripts, + )?; + let runtime = Arc::downgrade(self); + let handler = Arc::new(move |action| { + let runtime = runtime.upgrade().context("重载会话已退出")?; + let (operation, verification) = match action { + ControlAction::Submit(payload) => { + let operation = runtime.reload_owner.submit( + &payload, runtime.websocket_url.clone(), runtime.user_scripts.clone(), + )?; + (operation, payload["verificationId"].as_str().map(str::to_owned)) + } + ControlAction::Status(id) => (runtime.reload_owner.control_status(&id), None), + ControlAction::StatusWithVerification(id, verification) => + (runtime.reload_owner.control_status(&id), Some(verification)), + }; + Ok(runtime.reload_owner.control_response(operation, verification.as_deref(), &runtime.user_scripts)) + }); + *current = Some((debug_port, control::start_owner(identity, self.reload_owner.epoch().to_owned(), handler)?)); + Ok(()) + } + + fn stop_native_control(&self) { + self.native_control.lock().unwrap().take(); + } } #[async_trait::async_trait] @@ -1777,23 +1819,43 @@ impl BridgeRuntimeService for LauncherRuntimeService { } async fn load_user_scripts(&self) -> anyhow::Result { + let _guard = self.reload_owner.lock_initial_load().await; let websocket_url = self .websocket_url .lock() .unwrap() .clone() .ok_or_else(|| anyhow::anyhow!("Codex 页面尚未连接"))?; - codex_plus_core::user_scripts::load_scripts_at(&websocket_url, &self.user_scripts).await + codex_plus_core::user_scripts::load_scripts_at( + &websocket_url, &self.user_scripts, user_script_reload::next_sequence() + ).await } async fn reload_user_scripts(&self) -> anyhow::Result { - let websocket_url = self - .websocket_url - .lock() - .unwrap() - .clone() - .ok_or_else(|| anyhow::anyhow!("Codex 页面尚未连接"))?; - codex_plus_core::user_scripts::reload_scripts_at(&websocket_url, &self.user_scripts).await + self.begin_user_script_reload(json!({})).await + } + + async fn begin_user_script_reload(&self, payload: Value) -> anyhow::Result { + let mut inventory = self.user_scripts.inventory()?; + let operation = self.reload_owner.submit(&payload, self.websocket_url.clone(), self.user_scripts.clone())?; + inventory["reloadOperation"] = operation; + Ok(inventory) + } + + async fn begin_user_script_reload_no_page(&self, mut payload: Value) -> anyhow::Result { + let payload = payload.as_object_mut().ok_or_else(|| anyhow::anyhow!("重载参数必须是对象"))?; + payload.insert("allowPage".to_string(), json!(false)); + self.begin_user_script_reload(json!(payload)).await + } + + async fn user_script_reload_status(&self, payload: Value) -> anyhow::Result { + if payload.get("capabilities").and_then(Value::as_bool) == Some(true) { + return Ok(json!({ "capabilities": { "noPageReload": true, "operationProtocol": 1 }, + "ownerEpoch": self.reload_owner.epoch() })); + } + let id = payload.get("operationId").and_then(Value::as_str).unwrap_or_default(); + let operation = self.reload_owner.control_status(id); + Ok(self.reload_owner.control_response(operation, payload["verificationId"].as_str(), &self.user_scripts)) } async fn cdp_targets(&self) -> anyhow::Result { @@ -1999,6 +2061,21 @@ fn default_user_script_manager() -> UserScriptManager { mod tests { use super::*; + #[tokio::test] + async fn no_page_reload_route_overrides_a_conflicting_payload() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + dir.path().join("user"), + dir.path().join("settings.json"), + ); + let runtime = LauncherRuntimeService::new(0, manager); + let result = runtime.begin_user_script_reload_no_page(json!({ + "allowPage": true + })).await.unwrap(); + assert_eq!(result["reloadOperation"]["allowPage"], false); + } + #[test] fn first_index_repair_preserves_startup_sync_even_if_setting_changes() { let hooks = LauncherHooks::default(); diff --git a/apps/codex-plus-launcher/src/user_script_reload.rs b/apps/codex-plus-launcher/src/user_script_reload.rs new file mode 100644 index 0000000000..1dd0692e0d --- /dev/null +++ b/apps/codex-plus-launcher/src/user_script_reload.rs @@ -0,0 +1,859 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::Context; +use codex_plus_core::user_scripts::UserScriptManager; +use serde_json::{Value, json}; + +const PAGE_REQUEST_RECEIPT_KEY: &str = "__codexPlusUserScriptsPageRequest"; + +static NEXT_SEQUENCE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + AtomicU64::new(time.saturating_mul(1024)) +}); + +pub(super) fn next_sequence() -> u64 { + NEXT_SEQUENCE.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Clone)] +pub(super) struct ReloadOwner { + state: Arc>>, + epoch: String, + retired_before: Arc, + operations: Arc>, + execution: Arc>, + desired_probes: Arc>, +} + +#[derive(Default)] +struct DesiredProbes { + running: bool, + completed: HashMap)>, + sequence: u64, +} + +impl Default for ReloadOwner { + fn default() -> Self { + Self { + state: Arc::new(Mutex::new(HashMap::new())), + epoch: codex_plus_core::user_script_control::new_owner_epoch(), + retired_before: Arc::new(AtomicU64::new(0)), + operations: Arc::new(tokio::sync::Mutex::new(())), + execution: Arc::new(tokio::sync::Mutex::new(())), + desired_probes: Arc::new(Mutex::new(DesiredProbes::default())), + } + } +} + +impl ReloadOwner { + pub(super) fn epoch(&self) -> &str { &self.epoch } + + pub(super) fn control_response(&self, operation: Value, verification: Option<&str>, manager: &UserScriptManager) -> Value { + let (desired, pending) = if operation["phase"] == "applied" { + let manager = manager.clone(); + self.desired_revision(&operation, verification, move || manager.reload_snapshot().ok().map(|snapshot| snapshot.1)) + } else { (None, false) }; + json!({ "reloadOperation": operation, "desiredRevision": desired, "desiredRevisionPending": pending }) + } + + fn desired_revision( + &self, + operation: &Value, + verification: Option<&str>, + read: impl FnOnce() -> Option + Send + 'static, + ) -> (Option, bool) { + let Some(verification) = verification.filter(|id| id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit())) else { + return (None, false); + }; + let key = format!("{}\0{verification}", operation["operationId"].as_str().unwrap_or_default()); + let mut probes = self.desired_probes.lock().unwrap(); + if let Some((_, result)) = probes.completed.get(&key) { + return (result.clone(), false); + } + if probes.running { return (None, true); } + probes.running = true; + let state = self.desired_probes.clone(); + // 全 owner 同时只有一次文件探测。等待文件锁的任务不随客户端超时反复创建。 + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(read).await.ok().flatten(); + let mut probes = state.lock().unwrap(); + probes.running = false; + probes.sequence = probes.sequence.wrapping_add(1); + let sequence = probes.sequence; + if probes.completed.len() >= 256 { + if let Some(oldest) = probes.completed.iter().min_by_key(|(_, value)| value.0).map(|(key, _)| key.clone()) { + probes.completed.remove(&oldest); + } + } + probes.completed.insert(key, (sequence, result)); + }); + (None, true) + } + + pub(super) fn submit( + &self, + payload: &Value, + websocket: Arc>>, + manager: UserScriptManager, + ) -> anyhow::Result { + use codex_plus_core::user_script_control::{new_operation_id, now_ms, operation_issued_at}; + let id = payload.get("operationId").and_then(Value::as_str) + .filter(|id| !id.is_empty()).map(str::to_owned).unwrap_or_else(|| new_operation_id(&self.epoch)); + let issued_at = operation_issued_at(&self.epoch, &id).context("重载操作编号无效或属于旧 owner")?; + anyhow::ensure!(issued_at <= now_ms().saturating_add(30_000), "重载操作编号时间无效"); + let allow_page = payload.get("allowPage").and_then(Value::as_bool).unwrap_or(true); + let record = json!({ "operationId": id, "ownerEpoch": self.epoch, "allowPage": allow_page, "phase": "accepted", "active": true, + "issuedAt": issued_at, + "createdAt": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 }); + { + let mut state = self.state.lock().unwrap(); + if let Some(previous) = state.get(&id) { + anyhow::ensure!(previous["allowPage"] == allow_page, "重载操作编号与已有请求冲突"); + return Ok(previous.clone()); + } + anyhow::ensure!(issued_at > self.retired_before.load(Ordering::Relaxed), + "重载操作编号已过期;未重新执行"); + anyhow::ensure!(state.values().filter(|value| value["active"] == true).count() < 32, + "等待确认的重载操作过多,请稍后再试"); + if state.len() >= 4096 { + let oldest = state.iter().filter(|(_, value)| value["active"] != true) + .min_by_key(|(_, value)| value["issuedAt"].as_u64().unwrap_or_default()) + .map(|(key, _)| key.clone()); + if let Some(oldest) = oldest { + if let Some(retired) = state.remove(&oldest) { + // 高水位墓碑永久保留在本 owner 内;淘汰详情不能让旧编号重新执行。 + self.retired_before.fetch_max(retired["issuedAt"].as_u64().unwrap_or_default(), Ordering::Relaxed); + } + } + } + anyhow::ensure!(issued_at > self.retired_before.load(Ordering::Relaxed), + "重载操作编号已过期;未重新执行"); + anyhow::ensure!(state.len() < 4096, "重载操作过多,请稍后再试"); + state.insert(id.clone(), record.clone()); + } + let owner = self.clone(); + tokio::spawn(async move { + owner.observe(&id, Duration::from_secs(45), owner.run(&id, allow_page, &websocket, &manager)).await; + }); + Ok(record) + } + + async fn observe(&self, id: &str, warning_after: Duration, operation: impl std::future::Future) { + tokio::pin!(operation); + tokio::select! { + _ = &mut operation => {} + _ = tokio::time::sleep(warning_after) => { + self.update(id, "unknown", json!({ "reason": "等待确认超时;原操作仍在协调中", "active": true })); + operation.await; + } + } + } + + pub(super) fn status(&self, id: &str) -> Option { + self.state.lock().unwrap().get(id).cloned() + } + + pub(super) fn control_status(&self, id: &str) -> Value { + if let Some(record) = self.status(id) { return record; } + let retired = codex_plus_core::user_script_control::operation_issued_at(&self.epoch, id) + .is_none_or(|issued_at| issued_at <= self.retired_before.load(Ordering::Relaxed)); + json!({ + "operationId": id, "ownerEpoch": self.epoch, "active": false, + "phase": if retired { "rejected" } else { "not_found" }, + "reason": if retired { "此操作编号已过期或属于旧 owner;未重新执行" } else { "此操作尚未登记" }, + }) + } + + pub(super) async fn lock_initial_load(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.execution.lock().await + } + + fn update(&self, id: &str, phase: &str, fields: Value) { + let mut state = self.state.lock().unwrap(); + let Some(record) = state.get_mut(id) else { return }; + record["phase"] = json!(phase); + if matches!(phase, "applied" | "failed" | "blocked" | "degraded" | "rejected") { + record["active"] = json!(false); + } + if let Some(fields) = fields.as_object() { + for (key, value) in fields { + record[key] = value.clone(); + } + } + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "user_scripts.reload_stage", json!({ "operationId": id, "phase": phase, "details": fields }), + ); + } + + async fn run(&self, id: &str, allow_page: bool, websocket: &Arc>>, manager: &UserScriptManager) { + // Keep other reloads out even while a new document's bootstrap is allowed to run. + let _operation_guard = self.operations.lock().await; + let mut attempt = 0_u64; + loop { + let guard = self.execution.lock().await; + attempt += 1; + let operation_id = format!("{id}-{attempt:x}"); + let sequence = next_sequence(); + let (bundle, revision) = match snapshot(manager).await { + Ok(snapshot) => snapshot, + Err(error) => { + self.update(id, "failed", json!({ "reason": format!("{error:#}") })); + return; + } + }; + self.update(id, "accepted", json!({ "revision": revision, "attempt": attempt })); + let before = self.wait_probe(id, websocket, &operation_id).await; + let document = before["documentId"].as_str().unwrap_or_default().to_string(); + if document.is_empty() { continue; } + let mut generation = codex_plus_core::extension_backend::backend_generation(manager); + let source_revoked = allow_page || before["cleanupSupported"] == true; + if source_revoked { + match codex_plus_core::extension_backend::revoke_backend_document(manager, generation).await { + Some(next) => generation = next, + None => { + self.update(id, "degraded", json!({ "reason": "后台代际已变化,未开始页面清理" })); + return; + } + } + } + self.update(id, "quiescing", json!({ "documentId": document })); + let preparation = json!({ + "id": operation_id, "documentId": document, "revision": revision, + "allowPage": allow_page, "sequence": sequence, + }); + let prepare = build_prepare_with_page_receipt(&preparation); + // A timeout does not cancel CDP execution. Never resend this prepare. + let acknowledged = evaluate(websocket, &prepare).await.ok(); + if let Some(response) = &acknowledged { + if response["phase"] == "rejected" || response["phase"] == "failed" { + self.update(id, "failed", json!({ "reason": response["reason"] })); + return; + } + } + let prepared = self.wait_preparation_with_probe(id, &preparation, acknowledged, || { + self.wait_probe(id, websocket, &operation_id) + }).await; + let Some(prepared) = prepared else { + // Navigation can destroy the prepare response before it is read. + // Let the new document's bootstrap acquire the same execution permit. + drop(guard); + if self.verify_navigation(id, websocket, &operation_id, &document, "navigation", manager).await { + return; + } + continue; + }; + match prepared["phase"].as_str().unwrap_or_default() { + "blocked" if source_revoked => { + self.update(id, "degraded", json!({ "reason": "页面清理能力发生变化,旧后台来源已撤销;需要重新加载页面" })); + return; + } + "blocked" | "degraded" | "failed" | "rejected" => { + self.update(id, prepared["phase"].as_str().unwrap_or("failed"), json!({ "reason": prepared["reason"] })); + return; + } + "pageRequested" => { + self.update(id, "pageRequested", json!({ "mode": "page", "reason": prepared["reason"] })); + drop(guard); + if self.verify_navigation(id, websocket, &operation_id, &document, "page", manager).await { + return; + } + continue; + } + "stopped" => {} + _ => { + self.update(id, "failed", json!({ "reason": "无法确认页面清理状态" })); + return; + } + } + self.update(id, "stopped", json!({})); + if snapshot(manager).await.ok().map(|snapshot| snapshot.1) != Some(revision.clone()) { + continue; + } + if !codex_plus_core::extension_backend::stop_all_if_generation(manager, generation).await { + self.update(id, "degraded", json!({ "reason": "后台代际已变化,页面已清理但未重新加载" })); + return; + } + let (staging_manager, staging_revision) = (manager.clone(), revision.clone()); + let staged = match tokio::task::spawn_blocking(move || staging_manager.stage_backend_snapshot(&staging_revision)) + .await.context("后台快照准备任务失败").and_then(|result| result) + { + Ok(staged) => staged, + Err(error) => { + self.update(id, "degraded", json!({ "reason": format!("无法固定后台快照:{error:#}") })); + return; + } + }; + let backend_source = match codex_plus_core::extension_backend::bind_backend_snapshot( + manager, generation.wrapping_add(1), &document, staged, + ).await { + Ok(source) => source, + Err(error) => { + self.update(id, "degraded", json!({ "reason": format!("无法绑定页面后台代际:{error:#}") })); + return; + } + }; + self.update(id, "applying", json!({})); + let apply = UserScriptManager::build_apply_operation(&operation_id, &document, &revision, &bundle, &backend_source); + // As with prepare, a lost response is resolved by querying the same operation. + if let Ok(response) = evaluate(websocket, &apply).await { + if response["operation"]["phase"] == "rejected" || response["operation"]["phase"] == "failed" { + self.update(id, "failed", json!({ "reason": response["operation"]["reason"] })); + return; + } + } + loop { + let probe = self.wait_probe(id, websocket, &operation_id).await; + if probe["documentId"] != document { break; } + let phase = probe["operation"]["phase"].as_str().unwrap_or_default(); + if phase == "failed" || phase == "rejected" || phase == "superseded" { + self.update(id, "failed", json!({ "reason": probe["operation"]["reason"] })); + return; + } + if phase == "verifying" && probe["revision"] == revision && probe["ready"] == true { + if !codex_plus_core::extension_backend::backend_source_is_current(manager, &probe["backendSource"]) { + self.update(id, "degraded", json!({ "reason": "页面脚本已执行,但后台来源已失效" })); + return; + } + if snapshot(manager).await.ok().map(|snapshot| snapshot.1) != Some(revision.clone()) { + break; + } + self.finish(id, "scripts", probe, manager).await; + return; + } + self.update(id, "unknown", json!({ "reason": "等待页面确认应用操作" })); + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } + + async fn wait_preparation_with_probe( + &self, + id: &str, + preparation: &Value, + acknowledged: Option, + probe: F, + ) -> Option + where + F: Fn() -> Fut, + Fut: std::future::Future, + { + // 已确认的页面刷新不能被下一次探测的新文档覆盖。 + if let Some(operation) = acknowledged.filter(|value| page_receipt_matches(value, preparation)) { + return Some(operation); + } + loop { + let observed = probe().await; + let receipt = &observed["pageRequestReceipt"]; + if page_receipt_matches(receipt, preparation) { + return Some(receipt.clone()); + } + if observed["documentId"] != preparation["documentId"] { + return None; + } + if observed["operation"].is_object() { + return Some(observed["operation"].clone()); + } + self.update(id, "unknown", json!({ "reason": "等待页面确认清理操作" })); + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + async fn verify_navigation( + &self, + id: &str, + websocket: &Arc>>, + operation_id: &str, + previous_document: &str, + mode: &str, + manager: &UserScriptManager, + ) -> bool { + self.verify_navigation_with_probe(id, previous_document, mode, manager, || { + self.wait_probe(id, websocket, operation_id) + }).await + } + + async fn verify_navigation_with_probe( + &self, + id: &str, + previous_document: &str, + mode: &str, + manager: &UserScriptManager, + probe: F, + ) -> bool + where + F: Fn() -> Fut, + Fut: std::future::Future, + { + loop { + let observed = probe().await; + if observed["documentId"] != previous_document && observed["initialized"] != true && observed["initialError"].is_string() { + self.update(id, "blocked", json!({ + "mode": mode, "reason": "新页面初始化未完成;请检查拓展状态或重新设置下次启动跳过选项", + })); + return true; + } + if observed["documentId"] != previous_document && observed["initialized"] == true && observed["ready"] == true { + let _guard = self.execution.lock().await; + let confirmed = probe().await; + if confirmed["documentId"] != observed["documentId"] + || confirmed["initialized"] != true || confirmed["ready"] != true + { + continue; + } + if confirmed["initialMode"] == "skipped" { + self.update(id, "blocked", json!({ "mode": mode, "reason": "新页面按设置跳过了本次拓展加载" })); + return true; + } + if !codex_plus_core::extension_backend::backend_source_is_current(manager, &confirmed["backendSource"]) { + self.update(id, "degraded", json!({ "mode": mode, "reason": "新页面后台来源与当前会话不一致" })); + return true; + } + let current = match snapshot(manager).await { + Ok((_, revision)) => revision, + Err(error) => { + self.update(id, "degraded", json!({ "reason": format!("无法确认当前拓展快照:{error:#}") })); + return true; + } + }; + if confirmed["revision"].as_str() == Some(current.as_str()) { + self.finish(id, mode, confirmed, manager).await; + return true; + } + return false; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + async fn wait_probe(&self, id: &str, websocket: &Arc>>, operation_id: &str) -> Value { + loop { + if let Ok(value) = evaluate(websocket, &build_probe_with_page_receipt(operation_id)).await { + if value["documentId"].is_string() { return value; } + } + self.update(id, "unknown", json!({ "reason": "页面暂未响应,操作结果待确认" })); + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + async fn finish(&self, id: &str, mode: &str, probe: Value, manager: &UserScriptManager) { + let (inventory_manager, runtime) = (manager.clone(), probe.get("scripts").cloned()); + let inventory = match tokio::task::spawn_blocking(move || inventory_manager.inventory_with_runtime_status(runtime.as_ref())) + .await.context("拓展清单确认任务失败").and_then(|result| result) + { + Ok(inventory) => inventory, + Err(error) => { + self.update(id, "degraded", json!({ "reason": format!("无法确认拓展清单:{error:#}") })); + return; + } + }; + let (phase, reason) = inventory_verdict(&inventory, probe.get("scripts")); + self.update(id, phase, json!({ + "mode": mode, "documentId": probe["documentId"], "revision": probe["revision"], "inventory": inventory, + "reason": reason, + })); + } +} + +fn page_receipt_matches(receipt: &Value, preparation: &Value) -> bool { + receipt["phase"] == "pageRequested" && receipt["allowPage"] == true + && ["id", "documentId", "revision", "allowPage", "sequence"].iter() + .all(|key| receipt.get(*key).is_some_and(|value| preparation.get(*key) == Some(value))) +} + +fn build_prepare_with_page_receipt(preparation: &Value) -> String { + let prepare = UserScriptManager::build_prepare_operation( + preparation["id"].as_str().unwrap(), + preparation["documentId"].as_str().unwrap(), + preparation["revision"].as_str().unwrap(), + preparation["allowPage"].as_bool().unwrap(), + preparation["sequence"].as_u64().unwrap(), + ); + format!(r#"(() => {{ + const encoded = {prepare}; + const operation = JSON.parse(encoded); + const expected = {preparation}; + if (operation.phase === "pageRequested" && operation.allowPage === true && + ["id", "documentId", "revision", "allowPage", "sequence"].every(key => operation[key] === expected[key])) {{ + // 单条收据随同一标签页刷新保留;旧 attempt 不得证明新操作触发了刷新。 + try {{ window.sessionStorage.setItem({key}, JSON.stringify(operation)); }} catch {{}} + }} + return encoded; +}})()"#, key = json!(PAGE_REQUEST_RECEIPT_KEY)) +} + +fn build_probe_with_page_receipt(operation_id: &str) -> String { + let probe = UserScriptManager::build_operation_probe(operation_id); + format!(r#"(() => {{ + const probe = JSON.parse({probe}); + try {{ + const receipt = window.sessionStorage.getItem({key}); + if (receipt && receipt.length <= 2048) probe.pageRequestReceipt = JSON.parse(receipt); + }} catch {{}} + return JSON.stringify(probe); +}})()"#, key = json!(PAGE_REQUEST_RECEIPT_KEY)) +} + +async fn snapshot(manager: &UserScriptManager) -> anyhow::Result<(String, String)> { + let manager = manager.clone(); + tokio::task::spawn_blocking(move || manager.reload_snapshot()).await.context("读取拓展快照任务失败")? +} + +fn inventory_verdict(inventory: &Value, runtime: Option<&Value>) -> (&'static str, &'static str) { + if runtime.and_then(Value::as_object).is_none() { + return ("degraded", "无法确认页面中的拓展状态"); + } + let Some(scripts) = inventory["scripts"].as_array() else { + return ("degraded", "无法确认拓展清单"); + }; + for script in scripts { + let configured = script["configuredEnabled"].as_bool(); + let state = script["runtimeStatus"].as_str(); + if state == Some("failed") && configured == Some(true) { + return ("failed", "页面中的拓展加载失败"); + } + if !matches!((configured, state), (Some(true), Some("loaded")) | (Some(false), Some("not_loaded"))) { + return ("degraded", "页面拓展状态与配置不一致"); + } + } + ("applied", "") +} + +async fn evaluate(websocket: &Arc>>, expression: &str) -> anyhow::Result { + let url = websocket.lock().unwrap().clone().context("Codex 页面尚未连接")?; + let response = codex_plus_core::bridge::evaluate_script(&url, expression).await?; + let encoded = response.pointer("/result/result/value").and_then(Value::as_str).context("页面没有返回操作状态")?; + Ok(serde_json::from_str(encoded)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page_preparation() -> Value { + json!({ + "id": "reload-attempt-1", "documentId": "old-document", + "revision": "current-revision", "allowPage": true, "sequence": 17, + }) + } + + fn probe_after_requested_page_reload(preparation: &Value, storage_available: bool) -> Value { + let prepare = build_prepare_with_page_receipt(preparation); + let probe = build_probe_with_page_receipt(preparation["id"].as_str().unwrap()); + let script = format!(r#" +const assert = require("node:assert/strict"); +const vm = require("node:vm"); +const storageAvailable = {storage_available}; +const entries = new Map(); +const storage = {{ + getItem(key) {{ return entries.get(key) || null; }}, + setItem(key, value) {{ entries.set(key, value); }}, +}}; +const timers = []; +const context = vm.createContext({{ + document: {{ readyState: "complete", getElementById() {{ return null; }} }}, + console, +}}); +function page(documentId, legacy) {{ + return {{ + __codexPlusUserScripts: {{ documentId, scripts: legacy ? {{ legacy: {{ status: "loaded" }} }} : {{}} }}, + __codexPlusUserScriptsBootstrap: true, + sessionStorage: storageAvailable ? storage : null, + setTimeout(callback) {{ timers.push(callback); }}, + location: {{ reload() {{ + assert.equal(entries.size, storageAvailable ? 1 : 0); + context.window = page("new-document", false); + }} }}, + }}; +}} +context.window = page("old-document", true); +assert.equal(JSON.parse(vm.runInContext({prepare}, context)).phase, "pageRequested"); +assert.equal(timers.length, 1); +timers.shift()(); +const observed = JSON.parse(vm.runInContext({probe}, context)); +assert.equal(observed.documentId, "new-document"); +process.stdout.write(JSON.stringify(observed)); +"#, prepare = json!(prepare), probe = json!(probe)); + let output = std::process::Command::new("node").args(["-e", &script]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + serde_json::from_slice(&output.stdout).unwrap() + } + + #[tokio::test] + async fn acknowledged_page_request_survives_the_next_probe_being_a_new_document() { + let owner = ReloadOwner::default(); + let preparation = page_preparation(); + let mut acknowledged = preparation.clone(); + acknowledged["phase"] = json!("pageRequested"); + acknowledged["mode"] = json!("page"); + let calls = std::sync::atomic::AtomicUsize::new(0); + let prepared = owner.wait_preparation_with_probe("reload", &preparation, Some(acknowledged.clone()), || { + calls.fetch_add(1, Ordering::SeqCst); + std::future::ready(json!({ "documentId": "new-document", "operation": null })) + }).await; + assert_eq!(prepared, Some(acknowledged)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn lost_prepare_ack_recovers_page_request_from_the_new_documents_receipt() { + let owner = ReloadOwner::default(); + let preparation = page_preparation(); + let observed = probe_after_requested_page_reload(&preparation, true); + assert!(observed["operation"].is_null()); + let prepared = owner.wait_preparation_with_probe("reload", &preparation, None, || { + std::future::ready(observed.clone()) + }).await.unwrap(); + assert_eq!(prepared["phase"], "pageRequested"); + assert_eq!(prepared["mode"], "page"); + assert_eq!(prepared["reason"], "cleanup_missing"); + } + + #[tokio::test] + async fn new_document_never_uses_a_receipt_from_another_attempt_or_policy() { + let owner = ReloadOwner::default(); + let preparation = page_preparation(); + let observed = probe_after_requested_page_reload(&preparation, true); + for (field, stale_value) in [ + ("id", json!("reload-attempt-0")), + ("documentId", json!("earlier-document")), + ("revision", json!("old-revision")), + ("sequence", json!(16)), + ("allowPage", json!(false)), + ("phase", json!("stopped")), + ] { + let mut stale = observed.clone(); + stale["pageRequestReceipt"][field] = stale_value; + assert!(owner.wait_preparation_with_probe("reload", &preparation, None, || { + std::future::ready(stale.clone()) + }).await.is_none(), "stale receipt field: {field}"); + } + } + + #[tokio::test] + async fn unavailable_page_receipt_keeps_an_unacknowledged_new_document_as_navigation() { + let owner = ReloadOwner::default(); + let preparation = page_preparation(); + let observed = probe_after_requested_page_reload(&preparation, false); + assert!(observed.get("pageRequestReceipt").is_none()); + assert!(owner.wait_preparation_with_probe("reload", &preparation, None, || { + std::future::ready(observed.clone()) + }).await.is_none()); + } + + #[tokio::test] + async fn observation_timeout_does_not_cancel_the_operation() { + let owner = ReloadOwner::default(); + owner.state.lock().unwrap().insert("test".into(), json!({ "active": true })); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (finish_tx, finish_rx) = tokio::sync::oneshot::channel(); + let cloned = owner.clone(); + let task = tokio::spawn(async move { + cloned.observe("test", Duration::from_millis(10), async { + let _ = started_tx.send(()); + let _ = finish_rx.await; + }).await; + }); + started_rx.await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while owner.status("test").unwrap()["phase"] != "unknown" { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }).await.unwrap(); + assert_eq!(owner.status("test").unwrap()["active"], true); + assert!(!task.is_finished()); + finish_tx.send(()).unwrap(); + task.await.unwrap(); + } + + #[tokio::test] + async fn bootstrap_can_run_without_allowing_another_reload_to_overtake() { + let owner = ReloadOwner::default(); + let current = owner.operations.lock().await; + let execution = owner.execution.lock().await; + let next_owner = owner.clone(); + let (next_tx, mut next_rx) = tokio::sync::oneshot::channel(); + let next = tokio::spawn(async move { + let _guard = next_owner.operations.lock().await; + let _ = next_tx.send(()); + }); + let bootstrap_owner = owner.clone(); + let (bootstrap_tx, bootstrap_rx) = tokio::sync::oneshot::channel(); + let bootstrap = tokio::spawn(async move { + let _guard = bootstrap_owner.lock_initial_load().await; + let _ = bootstrap_tx.send(()); + }); + drop(execution); + tokio::time::timeout(Duration::from_secs(1), bootstrap_rx).await.unwrap().unwrap(); + assert!(tokio::time::timeout(Duration::from_millis(20), &mut next_rx).await.is_err()); + drop(current); + tokio::time::timeout(Duration::from_secs(1), next_rx).await.unwrap().unwrap(); + bootstrap.await.unwrap(); + next.await.unwrap(); + } + + #[test] + fn inventory_verdict_requires_matching_terminal_states() { + let runtime = json!({}); + assert_eq!(inventory_verdict(&json!({"scripts": []}), None).0, "degraded"); + assert_eq!(inventory_verdict(&json!({}), Some(&runtime)).0, "degraded"); + assert_eq!(inventory_verdict(&json!({"scripts": [ + {"configuredEnabled": true, "runtimeStatus": "not_loaded"} + ]}), Some(&runtime)).0, "degraded"); + assert_eq!(inventory_verdict(&json!({"scripts": [ + {"configuredEnabled": true, "runtimeStatus": "loading"} + ]}), Some(&runtime)).0, "degraded"); + assert_eq!(inventory_verdict(&json!({"scripts": [ + {"configuredEnabled": false, "runtimeStatus": "loaded"} + ]}), Some(&runtime)).0, "degraded"); + assert_eq!(inventory_verdict(&json!({"scripts": [ + {"configuredEnabled": true, "runtimeStatus": "failed"} + ]}), Some(&runtime)).0, "failed"); + assert_eq!(inventory_verdict(&json!({"scripts": [ + {"configuredEnabled": true, "runtimeStatus": "loaded"}, + {"configuredEnabled": false, "runtimeStatus": "not_loaded"} + ]}), Some(&runtime)).0, "applied"); + } + + #[tokio::test] + async fn duplicate_native_submission_and_retired_ids_never_execute_again() { + let owner = ReloadOwner::default(); + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), dir.path().join("user"), dir.path().join("scripts.json")); + let websocket = Arc::new(Mutex::new(None)); + let id = codex_plus_core::user_script_control::new_operation_id(owner.epoch()); + let payload = json!({ "operationId": id, "allowPage": false }); + let first = owner.submit(&payload, websocket.clone(), manager.clone()).unwrap(); + let duplicate = owner.submit(&payload, websocket.clone(), manager.clone()).unwrap(); + assert_eq!(first, duplicate); + assert_eq!(owner.state.lock().unwrap().len(), 1); + assert!(owner.submit(&json!({ "operationId": id, "allowPage": true }), websocket.clone(), manager.clone()).is_err()); + let issued_at = first["issuedAt"].as_u64().unwrap(); + owner.state.lock().unwrap().remove(&id); + owner.retired_before.store(issued_at, Ordering::Relaxed); + assert_eq!(owner.control_status(&id)["phase"], "rejected"); + assert!(owner.submit(&payload, websocket, manager).is_err()); + } + + #[tokio::test] + async fn native_deadline_does_not_drop_an_accepted_owner_operation() { + use codex_plus_core::user_script_control::{self as control, ControlAction, ControlIdentity}; + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), dir.path().join("user"), dir.path().join("scripts.json")); + let owner = ReloadOwner::default(); + let identity = ControlIdentity::new(dir.path(), 9336, &manager).unwrap(); + let id = control::new_operation_id(owner.epoch()); + let delayed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let handler_owner = owner.clone(); + let websocket = Arc::new(Mutex::new(None)); + let handler = Arc::new(move |action| match action { + ControlAction::Submit(payload) => { + let operation = handler_owner.submit(&payload, websocket.clone(), manager.clone())?; + if !delayed.swap(true, Ordering::SeqCst) { + // 真实 owner 已接收,但 ACK 晚于 native 通道的三秒截止时间。 + std::thread::sleep(Duration::from_millis(3200)); + } + Ok(json!({ "reloadOperation": operation })) + } + ControlAction::Status(id) | ControlAction::StatusWithVerification(id, _) => + Ok(json!({ "reloadOperation": handler_owner.control_status(&id) })), + }); + let _server = control::start_owner(identity.clone(), owner.epoch().to_owned(), handler).unwrap(); + let submit = json!({ "action": "submit", "ownerEpoch": owner.epoch(), "operationId": id, "allowPage": false }); + assert!(control::request(&identity, submit.clone()).await.is_err()); + let status = control::request(&identity, json!({ + "action": "status", "ownerEpoch": owner.epoch(), "operationId": id, + })).await.unwrap(); + assert_eq!(status["result"]["reloadOperation"]["operationId"], id); + assert_eq!(status["result"]["reloadOperation"]["active"], true); + assert_eq!(owner.state.lock().unwrap().len(), 1); + control::request(&identity, submit).await.unwrap(); + assert_eq!(owner.state.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn failed_new_document_initialization_ends_navigation_and_releases_operation() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), dir.path().join("user"), dir.path().join("scripts.json")); + let owner = ReloadOwner::default(); + owner.state.lock().unwrap().insert("navigation".to_owned(), json!({ "active": true })); + let task_owner = owner.clone(); + tokio::spawn(async move { + let _operation = task_owner.operations.lock().await; + assert!(task_owner.verify_navigation_with_probe("navigation", "old-document", "page", &manager, || { + std::future::ready(json!({ + "documentId": "new-document", "initialized": false, "ready": true, + "initialError": "skip_pending_other_doc", + })) + }).await); + }).await.unwrap(); + assert_eq!(owner.status("navigation").unwrap()["phase"], "blocked"); + assert_eq!(owner.status("navigation").unwrap()["active"], false); + let _guard = tokio::time::timeout(Duration::from_secs(1), owner.operations.lock()).await.unwrap(); + } + + #[tokio::test] + async fn desired_revision_retries_do_not_accumulate_behind_a_held_config_lock() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), dir.path().join("user"), dir.path().join("scripts.json")); + manager.save_config(&Default::default()).unwrap(); + let lock = std::fs::OpenOptions::new().read(true).write(true) + .open(dir.path().join("scripts.json.lock")).unwrap(); + lock.lock().unwrap(); + let owner = ReloadOwner::default(); + let operation = json!({ "operationId": "applied-operation", "phase": "applied" }); + let verification = "a".repeat(32); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = calls.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (first_manager, first_calls) = (manager.clone(), calls.clone()); + assert_eq!(owner.desired_revision(&operation, Some(&verification), move || { + first_calls.fetch_add(1, Ordering::SeqCst); + let _ = started_tx.send(()); + first_manager.reload_snapshot().ok().map(|snapshot| snapshot.1) + }), (None, true)); + started_rx.await.unwrap(); + for index in 0..64 { + let verification = format!("{index:032x}"); + let calls = counted.clone(); + assert_eq!(owner.desired_revision(&operation, Some(&verification), move || { + calls.fetch_add(1, Ordering::SeqCst); + Some("unexpected duplicate read".to_owned()) + }), (None, true)); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + lock.unlock().unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let (revision, pending) = owner.desired_revision(&operation, Some(&verification), || { + panic!("同一验证编号必须复用已完成结果") + }); + if !pending { + assert!(revision.is_some()); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }).await.unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let calls = counted.clone(); + let next_verification = "b".repeat(32); + assert_eq!(owner.desired_revision(&operation, Some(&next_verification), move || { + calls.fetch_add(1, Ordering::SeqCst); + Some("new intent reads a fresh snapshot".to_owned()) + }), (None, true)); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let (revision, pending) = owner.desired_revision(&operation, Some(&next_verification), || unreachable!()); + if !pending { + assert_eq!(revision.as_deref(), Some("new intent reads a fresh snapshot")); + break; + } + tokio::task::yield_now().await; + } + }).await.unwrap(); + assert_eq!(counted.load(Ordering::SeqCst), 2); + } +} diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index b922ee72e1..ec8857a16d 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1432,6 +1432,17 @@ fn launch_codex_multi_instance_blocking(request: CodexMultiLaunchRequest) -> Com worker_settings .codex_extra_args .push(format!("--user-data-dir={}", user_data_dir.display())); + worker_settings.relay_context_config_contents = match std::env::current_exe() + .map_err(anyhow::Error::from) + .and_then(|executable| crate::extension_creator_commands::rebind_multi_instance_creator( + &codex_home, &worker_state_dir, &executable, &worker_settings.relay_context_config_contents, + )) + { + Ok(context) => context, + Err(error) => return failed( + &format!("绑定多开拓展重载会话失败:{error}"), json!({ "instanceId": instance_id }), + ), + }; if let Err(error) = worker_store.save(&worker_settings) { return failed( &format!("保存多开 launcher 设置失败:{error}"), @@ -6925,8 +6936,17 @@ pub async fn refresh_user_script_inventory() -> CommandResult { ) } +fn user_script_reload_success_message(mode: &str) -> &'static str { + match mode { + "scripts" => "用户脚本已热重载。", + "page" => "已刷新 Codex 页面并确认拓展加载。", + "navigation" => "Codex 页面已切换,并确认拓展加载。", + _ => "已确认当前 Codex 页面的拓展加载。", + } +} + #[tauri::command] -pub async fn reload_user_scripts() -> CommandResult { +pub async fn reload_user_scripts(operation: Option) -> CommandResult { let debug_port = StatusStore::default() .load_latest() .ok() @@ -6936,9 +6956,15 @@ pub async fn reload_user_scripts() -> CommandResult { let manager = default_user_script_manager(); // 手动改过拓展文件后热重载:顺带让 Codex 钩子条目与拓展声明一致。 codex_plus_core::extension_hooks::sync_default(&manager); - match codex_plus_core::user_scripts::reload_live_scripts(debug_port, &manager).await { + match codex_plus_core::user_scripts::reload_native_scripts( + &codex_plus_core::paths::default_app_state_dir(), debug_port, &manager, true, operation.as_ref(), + ).await { Ok(user_scripts) => { - let page_reload = user_scripts["reload_mode"] == "page"; + let reload_mode = user_scripts["reload_mode"].as_str().unwrap_or_default().to_owned(); + let phase = user_scripts["reloadOperation"]["phase"].as_str().unwrap_or("unknown").to_owned(); + let operation_id = user_scripts["reloadOperation"]["operationId"].as_str().unwrap_or_default().to_string(); + let reason = user_scripts["reloadOperation"]["reason"].as_str().unwrap_or_default().to_string(); + let applied = phase == "applied"; let script_failed = user_scripts["scripts"] .as_array() .is_some_and(|scripts| scripts.iter().any(|script| script["status"] == "failed")); @@ -6950,17 +6976,17 @@ pub async fn reload_user_scripts() -> CommandResult { .to_string(), user_scripts, }; - if script_failed { - failed("部分脚本执行失败,请查看本地脚本状态。", payload) - } else { - ok( - if page_reload { - "已请求刷新 Codex 页面以安全重载旧脚本。" - } else { - "用户脚本已热重载。" - }, + if !applied { + failed( + &format!("用户脚本重载{}(操作 {operation_id}){}", + if phase == "unknown" { "结果待确认" } else { "未完成" }, + if reason.is_empty() { String::new() } else { format!(":{reason}") }), payload, ) + } else if script_failed { + failed("部分脚本执行失败,请查看本地脚本状态。", payload) + } else { + ok(user_script_reload_success_message(&reload_mode), payload) } } Err(error) => failed( @@ -11068,6 +11094,14 @@ fn default_log_lines() -> usize { #[cfg(test)] mod tests { + #[test] + fn user_script_reload_message_distinguishes_document_changes_from_script_reload() { + assert_eq!(super::user_script_reload_success_message("scripts"), "用户脚本已热重载。"); + assert_eq!(super::user_script_reload_success_message("page"), "已刷新 Codex 页面并确认拓展加载。"); + assert_eq!(super::user_script_reload_success_message("navigation"), "Codex 页面已切换,并确认拓展加载。"); + assert_eq!(super::user_script_reload_success_message(""), "已确认当前 Codex 页面的拓展加载。"); + } + #[test] fn multi_instance_user_data_respects_explicit_electron_root() { let temp = tempfile::tempdir().unwrap(); diff --git a/apps/codex-plus-manager/src-tauri/src/extension_creator_commands.rs b/apps/codex-plus-manager/src-tauri/src/extension_creator_commands.rs index 4423d5a48f..f21d213b2d 100644 --- a/apps/codex-plus-manager/src-tauri/src/extension_creator_commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/extension_creator_commands.rs @@ -118,12 +118,44 @@ pub fn run_mcp(args: &[OsString]) -> anyhow::Result<()> { // 拓展工具调用后台服务时,经这些端口上的 Codex 页面桥接转给托管后台的进程。 codex_plus_core::extension_backend::set_remote_bridge_ports(ports.clone()); let creator = ExtensionCreator { - manager: crate::commands::default_user_script_manager(), + manager: codex_plus_core::user_scripts::default_user_script_manager_for_settings(state_dir.join("settings.json")), debug_ports: ports, + state_dir, }; tauri::async_runtime::block_on(extension_creator::serve(creator)) } +/// 多开只重绑定确属本工具的条目;未接入、停用和其他程序的同名条目都保留原意。 +pub(crate) fn rebind_multi_instance_creator( + home: &Path, + state_dir: &Path, + executable: &Path, + context: &str, +) -> anyhow::Result { + fn rebind(contents: &str, executable: &Path, state_dir: &Path) -> anyhow::Result { + let document = contents.parse::() + .map_err(|_| anyhow::anyhow!("多开拓展创作配置不是有效 TOML"))?; + let entries = std::iter::once((extension_creator::SERVER_ID, extension_creator::MCP_FLAG)) + .chain(std::iter::once((extension_creator::LEGACY_SERVER_ID, extension_creator::LEGACY_MCP_FLAG))); + let owned = entries.filter_map(|(id, flag)| { + let entry = document.get("mcp_servers")?.get(id)?; + (entry.get("args")?.as_array()?.get(0)?.as_str()? == flag) + .then(|| entry.get("enabled").and_then(toml_edit::Item::as_bool).unwrap_or(true)) + }).next(); + let Some(enabled) = owned else { return Ok(contents.to_owned()) }; + let updated = managed_mcp::upsert_managed_context(&extension_creator::SPEC, contents, executable, state_dir)?; + if enabled { Ok(updated) } else { managed_mcp::disable_managed_context(&extension_creator::SPEC, &updated) } + } + let context = rebind(context, executable, state_dir)?; + let config_path = home.join("config.toml"); + let contents = std::fs::read_to_string(&config_path).context("读取多开拓展创作配置失败")?; + let updated = rebind(&contents, executable, state_dir)?; + if updated != contents { + codex_plus_core::settings::atomic_write(&config_path, updated.as_bytes())?; + } + Ok(context) +} + #[cfg(test)] mod tests { use super::*; @@ -139,6 +171,27 @@ mod tests { assert_eq!(state_dir_from_args(&args).unwrap(), dir.path()); } + #[test] + fn multi_creator_rebinds_only_owned_entries_and_preserves_disabled() { + let dir = tempfile::tempdir().unwrap(); + let executable = std::env::current_exe().unwrap(); + let worker_state = dir.path().join("worker-state"); + let main_state = dir.path().join("main-state"); + let original = managed_mcp::upsert_managed_context( + &extension_creator::SPEC, "", &executable, &main_state, + ).unwrap(); + let disabled = managed_mcp::disable_managed_context(&extension_creator::SPEC, &original).unwrap(); + std::fs::write(dir.path().join("config.toml"), &disabled).unwrap(); + let rebound = rebind_multi_instance_creator(dir.path(), &worker_state, &executable, &disabled).unwrap(); + let document = rebound.parse::().unwrap(); + assert_eq!(document["mcp_servers"][extension_creator::SERVER_ID]["args"][2].as_str(), worker_state.to_str()); + assert_eq!(document["mcp_servers"][extension_creator::SERVER_ID]["enabled"].as_bool(), Some(false)); + let foreign = "[mcp_servers.codex_plus_extensions]\ncommand='other'\nargs=['--other']\n"; + std::fs::write(dir.path().join("config.toml"), foreign).unwrap(); + assert_eq!(rebind_multi_instance_creator(dir.path(), &worker_state, &executable, foreign).unwrap(), foreign); + assert_eq!(std::fs::read_to_string(dir.path().join("config.toml")).unwrap(), foreign); + } + #[test] fn extension_creator_coexists_with_collaboration_in_managed_context() { let dir = tempfile::tempdir().unwrap(); diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 07b8cf0987..9789df1081 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -766,6 +766,14 @@ type UserScriptInventory = { enabled?: boolean; claude_enabled?: boolean; skip_next_start?: boolean; + reloadOperation?: { + operationId: string; + ownerEpoch: string; + allowPage: boolean; + phase: string; + active?: boolean; + reason?: string; + }; builtins?: BuiltinExtension[]; scripts?: Array<{ key: string; @@ -788,6 +796,10 @@ type UserScriptInventory = { settingsPage?: boolean; } | null; enabled: boolean; + configuredEnabled?: boolean; + runtimeStatus?: string; + applyState?: string; + missing?: boolean; claude_enabled?: boolean | null; status: string; error: string; @@ -2145,9 +2157,14 @@ export function App() { return result; }; + const pendingUserScriptReload = useRef(null); const reloadUserScripts = async (silent = false) => { - const result = await run(() => call("reload_user_scripts")); + const result = await run(() => call("reload_user_scripts", { + operation: pendingUserScriptReload.current, + })); if (result) { + const operation = result.user_scripts?.reloadOperation; + pendingUserScriptReload.current = operation?.phase === "unknown" ? operation : null; setSettings(result); setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); showResultNotice(t("本地拓展"), result, { silentSuccess: silent }); @@ -2232,6 +2249,7 @@ export function App() { setSettings(result); setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); showResultNotice(t("本地拓展"), result); + if (isSuccessStatus(result.status)) await reloadUserScripts(); await refreshUserScriptInventory(); } }; @@ -10713,7 +10731,7 @@ function UserScriptsScreen({ host, focus = null, onHostChange, settings, market, {codexHost ? : ` - : ``; + : ``; return `
+ `); // 内置脚本在只读目录里,删不掉;只给用户目录的脚本提供卸载。 if (local?.source === "user") { @@ -6257,9 +6260,77 @@ * 只改开关不会动已经跑起来的拓展实例:不重载的话关掉的拓展界面还挂着, * 打开的拓展也要等下次启动才装上。重载失败时仍刷新清单,开关状态至少准确。 */ - async function reloadUserScriptsAfterChange() { - const result = await postJson("/user-scripts/reload", {}); - if (!result?.scripts) showToast(`拓展重载失败:${result?.message || "未知错误"},重启 Codex 后生效`, { type: "error" }); + let codexPlusPendingReload = null; + let codexPlusReloadTask = null; + let codexPlusReloadDemand = 0; + function reloadUserScriptsAfterChange() { + codexPlusReloadDemand += 1; + if (codexPlusReloadTask) return codexPlusReloadTask; + codexPlusReloadTask = (async () => { + try { + let observed; + do { + observed = codexPlusReloadDemand; + await runUserScriptsAfterChange(); + } while (observed !== codexPlusReloadDemand); + } finally { + codexPlusReloadTask = null; + } + })(); + return codexPlusReloadTask; + } + + async function runUserScriptsAfterChange() { + if (!codexPlusPendingReload) { + const hello = await postJson("/user-scripts/reload-status", { capabilities: true }); + if (hello?.capabilities?.operationProtocol !== 1 || !/^[a-f0-9]{32}$/.test(hello.ownerEpoch || "")) { + showToast("当前启动器不支持可确认的重载,请更新对应 Codex++ 实例", { type: "error" }); + await loadUserScripts(); + return; + } + const nonce = crypto.randomUUID().replaceAll("-", ""); + codexPlusPendingReload = { + operationId: `${hello.ownerEpoch}-${Date.now().toString(16)}-${nonce}`, + ownerEpoch: hello.ownerEpoch, allowPage: true, phase: "not_found", + }; + } + let operation = codexPlusPendingReload; + const verificationId = crypto.randomUUID().replaceAll("-", ""); + const deadline = Date.now() + 20000; + while (!["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase) && Date.now() < deadline) { + try { + const result = operation.phase === "not_found" + ? await postJson("/user-scripts/reload", { operationId: operation.operationId, allowPage: true, verificationId }) + : await postJson("/user-scripts/reload-status", { operationId: operation.operationId, verificationId }); + // 提交响应丢失时先查询,同编号未登记才允许幂等重投。 + if (result?.reloadOperation?.operationId === operation.operationId) { + operation = result.reloadOperation; + if (operation.phase === "applied" && result.desiredRevisionPending === true) { + operation = { ...operation, phase: "verifying" }; + } else if (operation.phase === "applied" && typeof result.desiredRevision === "string" && result.desiredRevision !== operation.revision) { + const epoch = operation.ownerEpoch; + operation = { + operationId: `${epoch}-${Date.now().toString(16)}-${crypto.randomUUID().replaceAll("-", "")}`, + ownerEpoch: epoch, allowPage: true, phase: "not_found", + }; + } else if (operation.phase === "applied" && typeof result.desiredRevision !== "string") { + operation = { ...operation, phase: "degraded", reason: "原重载已完成,但当前拓展快照无法确认" }; + } + } else { + operation = { ...operation, phase: "unknown" }; + } + } catch { + operation = { ...operation, phase: "unknown" }; + } + codexPlusPendingReload = operation; + if (!["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase)) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + if (["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase)) codexPlusPendingReload = null; + if (operation.phase !== "applied") { + showToast(operation.reason || "拓展重载结果待确认,请检查运行状态", { type: "error" }); + } await loadUserScripts(); } @@ -6428,7 +6499,7 @@ kind: "installed", key: script.key, name: marketById.get(script.market_id)?.name || script.name || script.key, - meta: `${typeof codexPlusScriptSourceLabel === "function" ? codexPlusScriptSourceLabel(script) : script.source === "builtin" ? "内置" : script.market_id ? "市场" : "用户"} · ${userScriptStatusLabel(script.status)}`, + meta: `${typeof codexPlusScriptSourceLabel === "function" ? codexPlusScriptSourceLabel(script) : script.source === "builtin" ? "内置" : script.market_id ? "市场" : "用户"} · ${userScriptStatusLabel(script.status, script)}`, enabled: !!script.enabled, script, })); @@ -8275,7 +8346,7 @@ ["标识符", local.key], ["版本", local.package?.version || local.version || ""], ["来源", sourceLabel], - ["状态", userScriptStatusLabel(local.status)], + ["状态", userScriptStatusLabel(local.status, local)], ]) : ""; const market = marketItem ? table("市场", [ ["作者", marketItem.author || ""], @@ -19801,12 +19872,21 @@ */ const codexPlusBackendServiceEntries = new Map(); - function codexPlusBackendServiceProxy(name) { + function codexPlusBackendSource() { + const source = window.__codexPlusBackendSource; + if (!source || typeof source !== "object") return null; + return Object.freeze({ + ownerEpoch: source.ownerEpoch, generation: source.generation, + documentId: source.documentId, revision: source.revision, + }); + } + + function codexPlusBackendServiceProxy(name, source) { return new Proxy({}, { get: (_, method) => (typeof method !== "string" || method === "then") ? undefined : (...args) => { const bridge = window.__codexSessionDeleteBridge; if (typeof bridge !== "function") return Promise.reject(new Error("Codex 页面尚未连接,请稍后重试")); - return Promise.resolve(bridge("/extensions/services/call", { name, method, args })).then((result) => { + return Promise.resolve(bridge("/extensions/services/call", { name, method, args, source })).then((result) => { if (result?.status === "failed" || result?.error) throw new Error(result.message || result.error || `${name}.${method} 调用失败`); return result?.result; }); @@ -19815,6 +19895,8 @@ } function syncCodexPlusBackendServices() { + const source = codexPlusBackendSource(); + const sourceKey = JSON.stringify(source); const list = Array.isArray(window.__codexPlusBackendServices) ? window.__codexPlusBackendServices : []; const wanted = new Map( list @@ -19822,15 +19904,15 @@ .map((item) => [String(item.name), String(item.key || "")]), ); for (const [name, entry] of [...codexPlusBackendServiceEntries]) { - if (wanted.get(name) === entry.key) continue; + if (wanted.get(name) === entry.key && entry.sourceKey === sourceKey) continue; codexPlusBackendServiceEntries.delete(name); entry.dispose(); } for (const [name, key] of wanted) { if (codexPlusBackendServiceEntries.has(name) || codexPlusServices.has(name)) continue; try { - const dispose = codexPlusProvideService(name, codexPlusBackendServiceProxy(name), `backend:${key}`); - codexPlusBackendServiceEntries.set(name, { key, dispose }); + const dispose = codexPlusProvideService(name, codexPlusBackendServiceProxy(name, source), `backend:${key}`); + codexPlusBackendServiceEntries.set(name, { key, sourceKey, dispose }); } catch {} } } @@ -20424,6 +20506,16 @@ // 用过后台的实例卸载时通知 Codex++ 停掉它;带实例标识,热重载时不会误停新实例拉起的后台。 const backendHandlers = new Map(); const backendInstance = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const backendSource = codexPlusBackendSource(); + const scopedBackendServices = new Map(); + const getScopedService = (name) => { + if (disposed) return undefined; + const entry = codexPlusServices.get(name); + if (!entry) return undefined; + if (!codexPlusBackendServiceEntries.has(name)) return entry.value; + if (!scopedBackendServices.has(name)) scopedBackendServices.set(name, codexPlusBackendServiceProxy(name, backendSource)); + return scopedBackendServices.get(name); + }; let backendPolling = false; let backendStopTracked = false; const trackBackendStop = () => { @@ -20434,9 +20526,11 @@ }); }; const backendBridge = (path, payload) => { + if (disposed && path !== "/extensions/backend/stop") return Promise.reject(new Error("拓展实例已卸载")); const bridge = window.__codexSessionDeleteBridge; if (typeof bridge !== "function") return Promise.reject(new Error("Codex 页面尚未连接,请稍后重试")); - return Promise.resolve(bridge(path, { key: scriptKey, instance: backendInstance, ...payload })).then((result) => { + return Promise.resolve(bridge(path, { key: scriptKey, instance: backendInstance, ...payload, source: backendSource })).then((result) => { + if (disposed && path !== "/extensions/backend/stop") throw new Error("拓展实例已卸载"); if (result?.status === "failed" || result?.error) throw new Error(result.message || result.error || `${path} 调用失败`); return result; }); @@ -20455,6 +20549,7 @@ await new Promise((resolve) => setTimeout(resolve, 2000)); continue; } + if (disposed) break; for (const item of result.events || []) { for (const name of [item.event, "*"]) { for (const handler of [...(backendHandlers.get(name) || [])]) { @@ -20554,11 +20649,12 @@ }, /** 取一个服务,不存在时返回 undefined(可选依赖用这个;必需依赖请在 inject 里声明)。 */ get(name) { - return codexPlusServices.get(String(name))?.value; + return getScopedService(String(name)); }, /** 子作用域:deps 里的服务齐全时以子 ctx 调用 apply,服务消失时卸载、回来时重新应用。 */ inject(deps, apply) { if (typeof apply !== "function") throw new Error("inject 需要 apply(ctx) 函数"); + if (disposed) throw new Error("拓展实例已卸载"); const child = codexPlusCreateExtensionFiber({ apply, scriptKey, manifest, extensionId, inject: codexPlusNormalizeInject(deps), required: codexPlusRequiredInject(deps), }); @@ -20782,7 +20878,7 @@ // 声明过的服务挂到 ctx 上(读的是当前提供者的值;换了提供者时整个实例会被重建)。 for (const name of inject) { if (Object.prototype.hasOwnProperty.call(ctx, name)) continue; - Object.defineProperty(ctx, name, { enumerable: true, get: () => codexPlusServices.get(name)?.value }); + Object.defineProperty(ctx, name, { enumerable: true, get: () => getScopedService(name) }); } if (fiber) Object.defineProperty(ctx, "fiber", { value: fiber }); codexPlusExtensionInstances.add(ctx); diff --git a/assets/inject/renderer-inject/40-backend-settings.js b/assets/inject/renderer-inject/40-backend-settings.js index 58986a50dc..81dafe11a6 100644 --- a/assets/inject/renderer-inject/40-backend-settings.js +++ b/assets/inject/renderer-inject/40-backend-settings.js @@ -343,7 +343,10 @@ checkBackendStatus(); } - function userScriptStatusLabel(status) { + function userScriptStatusLabel(status, script) { + if (script?.configuredEnabled === false && script.runtimeStatus === "unknown") return "状态未确认"; + if (script?.configuredEnabled === false && + script.runtimeStatus && !["unknown", "not_loaded"].includes(script.runtimeStatus)) return "等待停用"; return { loaded: "已加载", failed: "失败", disabled: "已禁用", not_loaded: "未加载", loading: "加载中" }[status] || status || "未知"; } @@ -395,7 +398,7 @@ const select = `${entry.kind}:${escapeHtml(entry.key)}`; const action = entry.kind === "market" ? `` - : ``; + : ``; return `
+ `); // 内置脚本在只读目录里,删不掉;只给用户目录的脚本提供卸载。 if (local?.source === "user") { @@ -659,9 +662,77 @@ * 只改开关不会动已经跑起来的拓展实例:不重载的话关掉的拓展界面还挂着, * 打开的拓展也要等下次启动才装上。重载失败时仍刷新清单,开关状态至少准确。 */ - async function reloadUserScriptsAfterChange() { - const result = await postJson("/user-scripts/reload", {}); - if (!result?.scripts) showToast(`拓展重载失败:${result?.message || "未知错误"},重启 Codex 后生效`, { type: "error" }); + let codexPlusPendingReload = null; + let codexPlusReloadTask = null; + let codexPlusReloadDemand = 0; + function reloadUserScriptsAfterChange() { + codexPlusReloadDemand += 1; + if (codexPlusReloadTask) return codexPlusReloadTask; + codexPlusReloadTask = (async () => { + try { + let observed; + do { + observed = codexPlusReloadDemand; + await runUserScriptsAfterChange(); + } while (observed !== codexPlusReloadDemand); + } finally { + codexPlusReloadTask = null; + } + })(); + return codexPlusReloadTask; + } + + async function runUserScriptsAfterChange() { + if (!codexPlusPendingReload) { + const hello = await postJson("/user-scripts/reload-status", { capabilities: true }); + if (hello?.capabilities?.operationProtocol !== 1 || !/^[a-f0-9]{32}$/.test(hello.ownerEpoch || "")) { + showToast("当前启动器不支持可确认的重载,请更新对应 Codex++ 实例", { type: "error" }); + await loadUserScripts(); + return; + } + const nonce = crypto.randomUUID().replaceAll("-", ""); + codexPlusPendingReload = { + operationId: `${hello.ownerEpoch}-${Date.now().toString(16)}-${nonce}`, + ownerEpoch: hello.ownerEpoch, allowPage: true, phase: "not_found", + }; + } + let operation = codexPlusPendingReload; + const verificationId = crypto.randomUUID().replaceAll("-", ""); + const deadline = Date.now() + 20000; + while (!["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase) && Date.now() < deadline) { + try { + const result = operation.phase === "not_found" + ? await postJson("/user-scripts/reload", { operationId: operation.operationId, allowPage: true, verificationId }) + : await postJson("/user-scripts/reload-status", { operationId: operation.operationId, verificationId }); + // 提交响应丢失时先查询,同编号未登记才允许幂等重投。 + if (result?.reloadOperation?.operationId === operation.operationId) { + operation = result.reloadOperation; + if (operation.phase === "applied" && result.desiredRevisionPending === true) { + operation = { ...operation, phase: "verifying" }; + } else if (operation.phase === "applied" && typeof result.desiredRevision === "string" && result.desiredRevision !== operation.revision) { + const epoch = operation.ownerEpoch; + operation = { + operationId: `${epoch}-${Date.now().toString(16)}-${crypto.randomUUID().replaceAll("-", "")}`, + ownerEpoch: epoch, allowPage: true, phase: "not_found", + }; + } else if (operation.phase === "applied" && typeof result.desiredRevision !== "string") { + operation = { ...operation, phase: "degraded", reason: "原重载已完成,但当前拓展快照无法确认" }; + } + } else { + operation = { ...operation, phase: "unknown" }; + } + } catch { + operation = { ...operation, phase: "unknown" }; + } + codexPlusPendingReload = operation; + if (!["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase)) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + if (["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase)) codexPlusPendingReload = null; + if (operation.phase !== "applied") { + showToast(operation.reason || "拓展重载结果待确认,请检查运行状态", { type: "error" }); + } await loadUserScripts(); } @@ -830,7 +901,7 @@ kind: "installed", key: script.key, name: marketById.get(script.market_id)?.name || script.name || script.key, - meta: `${typeof codexPlusScriptSourceLabel === "function" ? codexPlusScriptSourceLabel(script) : script.source === "builtin" ? "内置" : script.market_id ? "市场" : "用户"} · ${userScriptStatusLabel(script.status)}`, + meta: `${typeof codexPlusScriptSourceLabel === "function" ? codexPlusScriptSourceLabel(script) : script.source === "builtin" ? "内置" : script.market_id ? "市场" : "用户"} · ${userScriptStatusLabel(script.status, script)}`, enabled: !!script.enabled, script, })); diff --git a/assets/inject/renderer-inject/41-extension-detail.js b/assets/inject/renderer-inject/41-extension-detail.js index 2df01cbb0d..87a1d9525d 100644 --- a/assets/inject/renderer-inject/41-extension-detail.js +++ b/assets/inject/renderer-inject/41-extension-detail.js @@ -260,7 +260,7 @@ ["标识符", local.key], ["版本", local.package?.version || local.version || ""], ["来源", sourceLabel], - ["状态", userScriptStatusLabel(local.status)], + ["状态", userScriptStatusLabel(local.status, local)], ]) : ""; const market = marketItem ? table("市场", [ ["作者", marketItem.author || ""], diff --git a/assets/inject/renderer-inject/91-extension-runtime.js b/assets/inject/renderer-inject/91-extension-runtime.js index 3afa53a0c6..40935f8565 100644 --- a/assets/inject/renderer-inject/91-extension-runtime.js +++ b/assets/inject/renderer-inject/91-extension-runtime.js @@ -441,12 +441,21 @@ */ const codexPlusBackendServiceEntries = new Map(); - function codexPlusBackendServiceProxy(name) { + function codexPlusBackendSource() { + const source = window.__codexPlusBackendSource; + if (!source || typeof source !== "object") return null; + return Object.freeze({ + ownerEpoch: source.ownerEpoch, generation: source.generation, + documentId: source.documentId, revision: source.revision, + }); + } + + function codexPlusBackendServiceProxy(name, source) { return new Proxy({}, { get: (_, method) => (typeof method !== "string" || method === "then") ? undefined : (...args) => { const bridge = window.__codexSessionDeleteBridge; if (typeof bridge !== "function") return Promise.reject(new Error("Codex 页面尚未连接,请稍后重试")); - return Promise.resolve(bridge("/extensions/services/call", { name, method, args })).then((result) => { + return Promise.resolve(bridge("/extensions/services/call", { name, method, args, source })).then((result) => { if (result?.status === "failed" || result?.error) throw new Error(result.message || result.error || `${name}.${method} 调用失败`); return result?.result; }); @@ -455,6 +464,8 @@ } function syncCodexPlusBackendServices() { + const source = codexPlusBackendSource(); + const sourceKey = JSON.stringify(source); const list = Array.isArray(window.__codexPlusBackendServices) ? window.__codexPlusBackendServices : []; const wanted = new Map( list @@ -462,15 +473,15 @@ .map((item) => [String(item.name), String(item.key || "")]), ); for (const [name, entry] of [...codexPlusBackendServiceEntries]) { - if (wanted.get(name) === entry.key) continue; + if (wanted.get(name) === entry.key && entry.sourceKey === sourceKey) continue; codexPlusBackendServiceEntries.delete(name); entry.dispose(); } for (const [name, key] of wanted) { if (codexPlusBackendServiceEntries.has(name) || codexPlusServices.has(name)) continue; try { - const dispose = codexPlusProvideService(name, codexPlusBackendServiceProxy(name), `backend:${key}`); - codexPlusBackendServiceEntries.set(name, { key, dispose }); + const dispose = codexPlusProvideService(name, codexPlusBackendServiceProxy(name, source), `backend:${key}`); + codexPlusBackendServiceEntries.set(name, { key, sourceKey, dispose }); } catch {} } } @@ -1064,6 +1075,16 @@ // 用过后台的实例卸载时通知 Codex++ 停掉它;带实例标识,热重载时不会误停新实例拉起的后台。 const backendHandlers = new Map(); const backendInstance = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const backendSource = codexPlusBackendSource(); + const scopedBackendServices = new Map(); + const getScopedService = (name) => { + if (disposed) return undefined; + const entry = codexPlusServices.get(name); + if (!entry) return undefined; + if (!codexPlusBackendServiceEntries.has(name)) return entry.value; + if (!scopedBackendServices.has(name)) scopedBackendServices.set(name, codexPlusBackendServiceProxy(name, backendSource)); + return scopedBackendServices.get(name); + }; let backendPolling = false; let backendStopTracked = false; const trackBackendStop = () => { @@ -1074,9 +1095,11 @@ }); }; const backendBridge = (path, payload) => { + if (disposed && path !== "/extensions/backend/stop") return Promise.reject(new Error("拓展实例已卸载")); const bridge = window.__codexSessionDeleteBridge; if (typeof bridge !== "function") return Promise.reject(new Error("Codex 页面尚未连接,请稍后重试")); - return Promise.resolve(bridge(path, { key: scriptKey, instance: backendInstance, ...payload })).then((result) => { + return Promise.resolve(bridge(path, { key: scriptKey, instance: backendInstance, ...payload, source: backendSource })).then((result) => { + if (disposed && path !== "/extensions/backend/stop") throw new Error("拓展实例已卸载"); if (result?.status === "failed" || result?.error) throw new Error(result.message || result.error || `${path} 调用失败`); return result; }); @@ -1095,6 +1118,7 @@ await new Promise((resolve) => setTimeout(resolve, 2000)); continue; } + if (disposed) break; for (const item of result.events || []) { for (const name of [item.event, "*"]) { for (const handler of [...(backendHandlers.get(name) || [])]) { @@ -1194,11 +1218,12 @@ }, /** 取一个服务,不存在时返回 undefined(可选依赖用这个;必需依赖请在 inject 里声明)。 */ get(name) { - return codexPlusServices.get(String(name))?.value; + return getScopedService(String(name)); }, /** 子作用域:deps 里的服务齐全时以子 ctx 调用 apply,服务消失时卸载、回来时重新应用。 */ inject(deps, apply) { if (typeof apply !== "function") throw new Error("inject 需要 apply(ctx) 函数"); + if (disposed) throw new Error("拓展实例已卸载"); const child = codexPlusCreateExtensionFiber({ apply, scriptKey, manifest, extensionId, inject: codexPlusNormalizeInject(deps), required: codexPlusRequiredInject(deps), }); @@ -1422,7 +1447,7 @@ // 声明过的服务挂到 ctx 上(读的是当前提供者的值;换了提供者时整个实例会被重建)。 for (const name of inject) { if (Object.prototype.hasOwnProperty.call(ctx, name)) continue; - Object.defineProperty(ctx, name, { enumerable: true, get: () => codexPlusServices.get(name)?.value }); + Object.defineProperty(ctx, name, { enumerable: true, get: () => getScopedService(name) }); } if (fiber) Object.defineProperty(ctx, "fiber", { value: fiber }); codexPlusExtensionInstances.add(ctx); diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index f403470b87..77d5c47a1b 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -53,7 +53,7 @@ { "name": "40-backend-settings.js", "description": "后端设置读写、心跳同步、按清单生成的设置页、用户脚本与市场状态、拓展 UI", - "lines": 2417 + "lines": 2488 }, { "name": "41-extension-detail.js", @@ -143,7 +143,7 @@ { "name": "91-extension-runtime.js", "description": "拓展运行时内核 codexPlus.extension:实例寿命、自动撤销、事件总线与 waterfall 中间件", - "lines": 1455 + "lines": 1480 }, { "name": "91-extension-rpc.js", diff --git a/assets/inject/user-scripts-bootstrap.js b/assets/inject/user-scripts-bootstrap.js index 8f8b32823d..c71b12b0d6 100644 --- a/assets/inject/user-scripts-bootstrap.js +++ b/assets/inject/user-scripts-bootstrap.js @@ -2,11 +2,23 @@ if (window.top !== window || window.self !== window || !window.electronBridge || !/^app:\/\/\-\//i.test(window.location.href)) return; if (window.__codexPlusUserScriptsBootstrap) return; window.__codexPlusUserScriptsBootstrap = true; + const failed = () => { + const runtime = window.__codexPlusUserScripts ||= { scripts: {} }; + runtime.initialError = "initialization_failed"; + }; const load = () => { // 每次页面加载都读取当前文件与开关,不保留启动时的旧脚本副本。 window.__codexSessionDeleteBridge("/user-scripts/load", {}).then((result) => { - if (result?.status === "failed") console.warn("[Codex++] user scripts:", result.message); - }).catch((error) => console.warn("[Codex++] user scripts:", error)); + if (result?.status === "failed") { + failed(); + console.warn("[Codex++] user scripts:", result.message); + } else if (window.__codexPlusUserScripts) { + window.__codexPlusUserScripts.initialError = null; + } + }).catch((error) => { + failed(); + console.warn("[Codex++] user scripts:", error); + }); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", load, { once: true }); else load(); diff --git a/assets/inject/user-scripts-runtime.js b/assets/inject/user-scripts-runtime.js index f4b0817076..69faa94339 100644 --- a/assets/inject/user-scripts-runtime.js +++ b/assets/inject/user-scripts-runtime.js @@ -1,16 +1,47 @@ (() => { const runtime = window.__codexPlusUserScripts ||= { scripts: {} }; + runtime.documentId ||= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + runtime.reloadOperations ||= {}; + runtime.nativePageReadiness = () => { + if (document.readyState === "loading") return { ready: false, reason: "document_loading" }; + const root = document.getElementById("root"); + if (!root) return { ready: false, reason: "native_root_missing" }; + const native = (selector, scope = root) => [...scope.querySelectorAll(selector)].some((element) => + !element.closest("[data-codex-plus-ext], [id^='codex-plus-']") && + !element.closest("[hidden], [aria-hidden='true']")); + if (native("main .ProseMirror[contenteditable='true'], [role='main'] .ProseMirror[contenteditable='true']")) { + return { ready: true, view: "conversation" }; + } + const hasNavigation = native("#app-shell-sidebar, .app-shell-left-panel, nav[data-app-navigation-rail], .sidebar-navigation"); + const hasContent = native("main h1, main h2, main button, main [role='tabpanel'], [role='main'] h1, [role='main'] button"); + if (hasNavigation && hasContent) return { ready: true, view: "native_shell" }; + return { ready: false, reason: "native_view_pending" }; + }; + runtime.revokeBackendSource = () => { + window.__codexPlusBackendSource = null; + window.__codexPlusBackendServices = []; + try { window.__codexPlusSyncBackendServices?.(); } catch {} + }; runtime.registerCleanup = (cleanup) => { const script = runtime.scripts[runtime.currentKey]; if (!script || typeof cleanup !== "function") throw new Error("Register cleanup during script initialization"); (script.cleanups ||= []).push(cleanup); }; - runtime.prepareReload = () => { - const refreshPage = () => { + runtime.prepareReload = (options = {}) => { + const refreshPage = (reason, degraded = false) => { + runtime.lastReloadReason = reason; + if (options.allowPage === false) { + if (degraded) { + runtime.reloadDegraded = true; + runtime.revokeBackendSource(); + } + return degraded ? "degraded" : "blocked"; + } if (!window.__codexPlusUserScriptsBootstrap) { throw new Error("请先重启 Codex++,以启用旧脚本的安全重载支持。"); } // 旧脚本没有完整的清理协议,交给浏览器释放整个页面的资源。 + runtime.revokeBackendSource(); if (!runtime.refreshPending) { runtime.refreshPending = true; window.setTimeout(() => window.location.reload(), 0); @@ -18,8 +49,14 @@ return "page"; }; if (runtime.refreshPending) return "page"; + if (runtime.reloadDegraded && options.allowPage === false) { + runtime.lastReloadReason = "previous_cleanup_incomplete"; + return "degraded"; + } + if (runtime.reloadDegraded) return refreshPage("previous_cleanup_incomplete"); const scripts = Object.values(runtime.scripts); - if (scripts.some((script) => !script.cleanups?.length)) return refreshPage(); + if (scripts.some((script) => !script.cleanups?.length)) return refreshPage("cleanup_missing"); + runtime.revokeBackendSource(); for (const script of scripts.reverse()) { for (const cleanup of script.cleanups.splice(0).reverse()) { try { @@ -27,14 +64,78 @@ // 异步清理无法在同一轮注入前完成,安全回退到页面刷新。 if (result && typeof result.then === "function") { Promise.resolve(result).catch(() => {}); - return refreshPage(); + return refreshPage("cleanup_async", true); } } catch { - return refreshPage(); + return refreshPage("cleanup_failed", true); } } } runtime.scripts = {}; + runtime.appliedRevision = null; + runtime.lastReloadReason = ""; return "scripts"; }; + // Preparation and application are separate so the owner can stop the old + // backend after cleanup without injecting a second copy on an uncertain ACK. + runtime.prepareOperation = (id, documentId, revision, allowPage, sequence) => { + if (!id || documentId !== runtime.documentId) return { phase: "rejected", reason: "document_changed" }; + const existing = runtime.reloadOperations[id]; + if (existing) return existing.revision === revision && existing.allowPage === allowPage && existing.sequence === sequence + ? existing : { phase: "rejected", reason: "operation_identity_conflict" }; + if (!Number.isSafeInteger(sequence) || sequence <= (runtime.reloadSequence || 0)) { + return { phase: "rejected", reason: "stale_operation" }; + } + const current = runtime.reloadOperations[runtime.currentReloadOperation]; + if (current && ["quiescing", "applying"].includes(current.phase)) { + return { phase: "rejected", reason: "operation_in_progress" }; + } + if (current && current.phase === "stopped") current.phase = "superseded"; + runtime.reloadSequence = sequence; + const operation = { id, documentId, revision, allowPage, sequence, phase: "quiescing" }; + runtime.reloadOperations[id] = operation; + runtime.currentReloadOperation = id; + const ids = Object.keys(runtime.reloadOperations); + if (ids.length > 256) { + for (const old of ids) { + if (Object.keys(runtime.reloadOperations).length <= 256) break; + const entry = runtime.reloadOperations[old]; + if (old !== id && !["quiescing", "stopped", "applying"].includes(entry?.phase)) delete runtime.reloadOperations[old]; + } + } + let mode; + try { + mode = runtime.prepareReload({ allowPage }); + } catch (error) { + operation.phase = "failed"; + operation.reason = String(error && (error.message || error) || error); + return operation; + } + operation.mode = mode; + operation.reason = runtime.lastReloadReason || ""; + operation.phase = mode === "scripts" ? "stopped" + : mode === "page" ? "pageRequested" + : mode === "blocked" ? "blocked" : "degraded"; + return operation; + }; + runtime.applyOperation = (id, documentId, revision, apply) => { + const operation = runtime.reloadOperations[id]; + if (!operation || documentId !== runtime.documentId || operation.documentId !== documentId || + operation.revision !== revision || runtime.currentReloadOperation !== id || + operation.sequence !== runtime.reloadSequence) { + return { phase: "rejected", reason: "operation_or_document_changed" }; + } + if (operation.phase !== "stopped") return operation; + operation.phase = "applying"; + try { + apply(); + operation.phase = "verifying"; + runtime.initialized = true; + runtime.appliedRevision = revision; + } catch (error) { + operation.phase = "failed"; + operation.reason = String(error && (error.message || error) || error); + } + return operation; + }; })(); diff --git a/crates/codex-plus-core/Cargo.toml b/crates/codex-plus-core/Cargo.toml index 0c643dc8d6..5830d7f811 100644 --- a/crates/codex-plus-core/Cargo.toml +++ b/crates/codex-plus-core/Cargo.toml @@ -50,12 +50,15 @@ objc2-screen-capture-kit = "0.3" windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_Networking_WinHttp", + "Win32_Security", + "Win32_Security_Authorization", "Win32_Storage_EnhancedStorage", "Win32_Storage_FileSystem", "Win32_Storage_Packaging_Appx", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_HiDpi", diff --git a/crates/codex-plus-core/src/extension_backend.rs b/crates/codex-plus-core/src/extension_backend.rs index abf8d67f59..d418e9e8ef 100644 --- a/crates/codex-plus-core/src/extension_backend.rs +++ b/crates/codex-plus-core/src/extension_backend.rs @@ -17,7 +17,7 @@ use std::time::Duration; use anyhow::Context; use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::{Mutex, Notify, oneshot}; +use tokio::sync::{Mutex, Notify, oneshot, watch}; use crate::user_scripts::UserScriptManager; @@ -150,6 +150,8 @@ struct Backend { /// 最近一次调用来自哪个页面实例;旧实例卸载时发来的 stop 只在它仍是最近调用方时生效。 instance: Option, _child: tokio::process::Child, + /// 后台仍可能延迟 import/readFile;直到这一代进程释放前保留实际启动的文件。 + _snapshot: Option, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -161,6 +163,113 @@ enum BackendHost { /// 在跑的后台,按 [`slot`] 登记:同一进程里不同拓展目录的同名拓展各起各的,停一个目录的后台也不波及别的目录。 static BACKENDS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); +// 与 BACKENDS 同时访问时,先持有 BACKENDS,再取得此锁。 +static BACKEND_GENERATIONS: std::sync::LazyLock>> = + std::sync::LazyLock::new(Default::default); +static BACKEND_DOCUMENTS: std::sync::LazyLock)>>> = + std::sync::LazyLock::new(Default::default); +static BACKEND_OWNER_EPOCH: std::sync::LazyLock = + std::sync::LazyLock::new(|| uuid::Uuid::new_v4().to_string()); + +fn generation_key(manager: &UserScriptManager, host: BackendHost) -> String { + match host { + BackendHost::Codex => slot_prefix(manager), + BackendHost::ClaudeDesktop => format!("claudeDesktop\u{0}{}", slot_prefix(manager)), + } +} + +fn generation(key: &str) -> u64 { + *BACKEND_GENERATIONS.lock().unwrap().get(key).unwrap_or(&0) +} + +/// 返回当前目录的后台代际,用于使迟到的停止操作不影响较新的后台。 +pub fn backend_generation(manager: &UserScriptManager) -> u64 { + generation(&generation_key(manager, BackendHost::Codex)) +} + +/// 清理开始前撤销页面来源,阻止旧回调在下一代后台启动前重新进入。 +pub async fn revoke_backend_document(manager: &UserScriptManager, expected: u64) -> Option { + let _backends = BACKENDS.lock().await; + let key = generation_key(manager, BackendHost::Codex); + let mut generations = BACKEND_GENERATIONS.lock().unwrap(); + let current = generations.entry(key.clone()).or_default(); + if *current != expected { + return None; + } + *current = current.wrapping_add(1); + let next = *current; + BACKEND_DOCUMENTS.lock().unwrap().remove(&key); + let starting = STARTING.lock().unwrap(); + for (slot, entry) in starting.iter() { + if slot.starts_with(&key) { + let _ = entry.cancel.send(true); + entry.notify.notify_waiters(); + } + } + Some(next) +} + +/// 为已停止旧代的页面绑定来源快照。此值用于生命周期隔离,不是身份认证凭据。 +pub async fn bind_backend_document(manager: &UserScriptManager, expected: u64, document: &str) -> anyhow::Result { + bind_backend_source(manager, expected, document, None).await +} + +/// 页面来源和 Node 实际执行的同一份快照共同绑定到代际,不能回退到实时原路径。 +pub async fn bind_backend_snapshot( + manager: &UserScriptManager, + expected: u64, + document: &str, + snapshot: crate::user_scripts::StagedBackendSnapshot, +) -> anyhow::Result { + bind_backend_source(manager, expected, document, Some(snapshot)).await +} + +async fn bind_backend_source( + manager: &UserScriptManager, + expected: u64, + document: &str, + snapshot: Option, +) -> anyhow::Result { + anyhow::ensure!(!document.is_empty() && document.len() <= 256, "页面文档标识无效"); + let _backends = BACKENDS.lock().await; + let key = generation_key(manager, BackendHost::Codex); + anyhow::ensure!(generation(&key) == expected, "后台代际已变化"); + let mut documents = BACKEND_DOCUMENTS.lock().unwrap(); + if let Some((bound_generation, bound_document, bound_snapshot)) = documents.get(&key) { + anyhow::ensure!(*bound_generation != expected || bound_document == document, "后台代际已绑定其他页面"); + anyhow::ensure!(*bound_generation != expected + || bound_snapshot.as_ref().map(|value| value.revision()) == snapshot.as_ref().map(|value| value.revision()), + "后台代际已绑定其他快照"); + } + let revision = snapshot.as_ref().map(|value| value.revision().to_string()); + documents.insert(key, (expected, document.to_string(), snapshot)); + Ok(json!({ "ownerEpoch": BACKEND_OWNER_EPOCH.as_str(), "generation": expected, "documentId": document, "revision": revision })) +} + +fn bound_snapshot(manager: &UserScriptManager, expected: u64) -> Option { + BACKEND_DOCUMENTS.lock().unwrap().get(&generation_key(manager, BackendHost::Codex)) + .filter(|bound| bound.0 == expected).and_then(|bound| bound.2.clone()) +} + +fn source_generation(manager: &UserScriptManager, source: Option<&Value>) -> anyhow::Result { + let source = source.context("缺少后台调用来源")?; + let owner = source.get("ownerEpoch").and_then(Value::as_str).context("缺少后台来源进程")?; + let expected = source.get("generation").and_then(Value::as_u64).context("缺少后台来源代际")?; + let document = source.get("documentId").and_then(Value::as_str).context("缺少后台来源文档")?; + let key = generation_key(manager, BackendHost::Codex); + anyhow::ensure!(owner == BACKEND_OWNER_EPOCH.as_str() + && generation(&key) == expected + && BACKEND_DOCUMENTS.lock().unwrap().get(&key).is_some_and(|bound| { + bound.0 == expected && bound.1 == document && + bound.2.as_ref().is_none_or(|snapshot| source.get("revision").and_then(Value::as_str) == Some(snapshot.revision())) + }), + "后台调用来源已过期"); + Ok(expected) +} + +pub fn backend_source_is_current(manager: &UserScriptManager, source: &Value) -> bool { + source_generation(manager, Some(source)).is_ok() +} /// 后台的登记键:拓展目录(以它的配置文件定位)+ 拓展 key。 fn slot(manager: &UserScriptManager, key: &str) -> String { @@ -208,6 +317,8 @@ async fn spawn( manager: &UserScriptManager, (extension, script, config): (String, PathBuf, Value), host: BackendHost, + expected_generation: u64, + snapshot: Option, ) -> anyhow::Result { let fingerprint = fingerprint(&script, &config); let node = crate::extension_node::require_node()?; @@ -269,7 +380,7 @@ async fn spawn( let result = if host == BackendHost::ClaudeDesktop { Err(anyhow::anyhow!("Claude Desktop 后台未开放 Codex 宿主调用")) } else { - host_call(&message, &host_manager).await + host_call_for_generation(&message, &host_manager, expected_generation).await }; let reply = match result { Ok(result) => json!({ "hostReply": host_id, "result": result }), @@ -318,7 +429,7 @@ async fn spawn( Ok(Err(_)) => anyhow::bail!("拓展后台启动后立即退出"), Err(_) => anyhow::bail!("拓展后台 15 秒内没有就绪"), } - Ok(Backend { stdin, pending, events, notify, next_id: 1, fingerprint, instance: None, _child: child }) + Ok(Backend { stdin, pending, events, notify, next_id: 1, fingerprint, instance: None, _child: child, _snapshot: snapshot }) } /// 后台、模型工具发起的宿主调用:只读会话查询(sessionQuery),以及按名字调用后台服务(services.call)。 @@ -328,10 +439,21 @@ pub(crate) fn host_call<'a>( message: &'a Value, manager: &'a UserScriptManager, ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(host_call_inner(message, manager)) + Box::pin(host_call_inner(message, manager, None)) +} + +fn host_call_for_generation<'a>( + message: &'a Value, + manager: &'a UserScriptManager, + expected: u64, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(host_call_inner(message, manager, Some(expected))) } -async fn host_call_inner(message: &Value, manager: &UserScriptManager) -> anyhow::Result { +async fn host_call_inner(message: &Value, manager: &UserScriptManager, expected: Option) -> anyhow::Result { + if let Some(expected) = expected { + anyhow::ensure!(backend_generation(manager) == expected, "后台宿主调用来源已过期"); + } let service = message.get("service").and_then(Value::as_str).unwrap_or_default().to_string(); let method = message.get("method").and_then(Value::as_str).unwrap_or_default().to_string(); let params = message.get("params").cloned().unwrap_or(Value::Null); @@ -344,20 +466,20 @@ async fn host_call_inner(message: &Value, manager: &UserScriptManager) -> anyhow // 后台都托管在处理桥接的那个进程里。本进程不是它(拓展 MCP、钩子进程)时经 Codex 页面的桥接转过去, // 否则同一个后台会在两个进程里各起一份,状态对不上。 if let Some(ports) = REMOTE_BRIDGE_PORTS.get() { - let result = remote_bridge_call(ports, "/extensions/services/call", ¶ms).await?; + let result = remote_bridge_call(ports, "/extensions/services/call-external", ¶ms).await?; return Ok(result.get("result").cloned().unwrap_or(Value::Null)); } - call_service(manager, ¶ms).await + call_service_for_generation(manager, ¶ms, expected).await } ("sessions", "list" | "create" | "fork" | "rename" | "append" | "startTurn" | "steer" | "interrupt" | "compact") => { // 会话写入经 Codex 页面里的 app-server 连接完成:在页面里调 __codexPlusSessionsCall。 let ports = REMOTE_BRIDGE_PORTS.get().cloned().unwrap_or_else(default_debug_ports); - page_call(&ports, "__codexPlusSessionsCall", &json!(method), ¶ms).await + page_call(&ports, "__codexPlusSessionsCall", &json!(method), ¶ms, expected).await } ("ui", "ask" | "toast") => { // 问用户 / 提示:在 Codex 页面里弹,等用户回答(提问最长等 10 分钟)。 let ports = REMOTE_BRIDGE_PORTS.get().cloned().unwrap_or_else(default_debug_ports); - page_call(&ports, "__codexPlusUiCall", &json!(method), ¶ms).await + page_call(&ports, "__codexPlusUiCall", &json!(method), ¶ms, expected).await } // stream 只有命令行入口([`run_host_call_cli`])能边收边吐;别的通道一次性返回同样的结果。 ("llm", "complete" | "stream") => crate::extension_llm::complete(¶ms).await, @@ -387,7 +509,7 @@ fn default_debug_ports() -> Vec { } /// 在 Codex 页面里调用一个全局函数 `window[function](name, params)`,返回它的结果。 -async fn page_call(ports: &[u16], function: &str, name: &Value, params: &Value) -> anyhow::Result { +async fn page_call(ports: &[u16], function: &str, name: &Value, params: &Value, expected: Option) -> anyhow::Result { let mut websocket = None; for port in ports { let Ok(targets) = crate::cdp::list_targets(*port).await else { @@ -399,8 +521,12 @@ async fn page_call(ports: &[u16], function: &str, name: &Value, params: &Value) } } let websocket = websocket.context("没有找到已连接的 Codex 页面")?; + let source_check = expected.map(|expected| format!( + "const source = window.__codexPlusBackendSource; if (!source || source.ownerEpoch !== {} || source.generation !== {}) throw new Error('后台宿主调用来源已过期');", + json!(BACKEND_OWNER_EPOCH.as_str()), expected + )).unwrap_or_default(); let expression = format!( - "(async () => {{ if (typeof window.{function} !== \"function\") throw new Error(\"Codex 页面还没装好 Codex++ 拓展运行时\"); return JSON.stringify(await window.{function}({name}, {params})); }})()" + "(async () => {{ {source_check} if (typeof window.{function} !== \"function\") throw new Error(\"Codex 页面还没装好 Codex++ 拓展运行时\"); return JSON.stringify(await window.{function}({name}, {params})); }})()" ); // 提问要等用户回答:按它的 timeoutMs 多给 5 秒;其余页面调用(会话写入)给 30 秒。 let timeout = if function == "__codexPlusUiCall" && name == "ask" { @@ -503,18 +629,25 @@ async fn remote_bridge_call(ports: &[u16], path: &str, payload: &Value) -> anyho /// 扫描拓展目录的短缓存:服务调用、后台调用每次都要知道「谁启用着、脚本与配置是什么」, /// 一轮对话里可能连续几十次,没必要每次都整轮扫描。改动最多 2 秒后生效。 const SCAN_CACHE_TTL: Duration = Duration::from_secs(2); -type ScanCache = std::sync::LazyLock>>; +type ScanCache = std::sync::LazyLock>>; static BACKEND_SCAN: ScanCache> = std::sync::LazyLock::new(Default::default); static SERVICE_SCAN: ScanCache> = std::sync::LazyLock::new(Default::default); +static SCAN_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); fn cached(cache: &ScanCache, key: &str, load: impl FnOnce() -> T) -> T { - if let Some((at, value)) = cache.lock().unwrap().get(key) { - if at.elapsed() < SCAN_CACHE_TTL { + let revision = SCAN_GENERATION.load(std::sync::atomic::Ordering::SeqCst); + if let Some((at, cached_revision, value)) = cache.lock().unwrap().get(key) { + if *cached_revision == SCAN_GENERATION.load(std::sync::atomic::Ordering::SeqCst) + && at.elapsed() < SCAN_CACHE_TTL + { return value.clone(); } } let value = load(); - cache.lock().unwrap().insert(key.to_string(), (std::time::Instant::now(), value.clone())); + let mut cache = cache.lock().unwrap(); + if SCAN_GENERATION.load(std::sync::atomic::Ordering::SeqCst) == revision { + cache.insert(key.to_string(), (std::time::Instant::now(), revision, value.clone())); + } value } @@ -524,25 +657,42 @@ fn declared_backend(manager: &UserScriptManager, key: &str) -> anyhow::Result<(S } fn backend_services(manager: &UserScriptManager) -> anyhow::Result> { + if let Some(snapshot) = bound_snapshot(manager, backend_generation(manager)) { + return Ok(snapshot.services.clone()); + } let cache_key = manager.config_path().display().to_string(); - if let Some((at, value)) = SERVICE_SCAN.lock().unwrap().get(&cache_key) { - if at.elapsed() < SCAN_CACHE_TTL { + let revision = SCAN_GENERATION.load(std::sync::atomic::Ordering::SeqCst); + if let Some((at, cached_revision, value)) = SERVICE_SCAN.lock().unwrap().get(&cache_key) { + if *cached_revision == SCAN_GENERATION.load(std::sync::atomic::Ordering::SeqCst) + && at.elapsed() < SCAN_CACHE_TTL + { return Ok(value.clone()); } } let value = manager.enabled_backend_services()?; - SERVICE_SCAN.lock().unwrap().insert(cache_key, (std::time::Instant::now(), value.clone())); + let mut cache = SERVICE_SCAN.lock().unwrap(); + if SCAN_GENERATION.load(std::sync::atomic::Ordering::SeqCst) == revision { + cache.insert(cache_key, (std::time::Instant::now(), revision, value.clone())); + } Ok(value) } /// 清掉扫描缓存(热重载、启停拓展后立即生效用)。 pub fn invalidate_scan_cache() { + SCAN_GENERATION.fetch_add(1, std::sync::atomic::Ordering::SeqCst); BACKEND_SCAN.lock().unwrap().clear(); SERVICE_SCAN.lock().unwrap().clear(); } /// 按服务名调用提供它的后台:`{ name, method, args, timeoutMs? }`。后台没起就先起。 pub async fn call_service(manager: &UserScriptManager, params: &Value) -> anyhow::Result { + call_service_for_generation(manager, params, None).await +} + +async fn call_service_for_generation(manager: &UserScriptManager, params: &Value, expected: Option) -> anyhow::Result { + if let Some(expected) = expected { + anyhow::ensure!(backend_generation(manager) == expected, "后台服务调用来源已过期"); + } let name = params.get("name").and_then(Value::as_str).context("缺少服务名")?; let method = params.get("method").and_then(Value::as_str).filter(|method| !method.is_empty()).context("缺少服务方法名")?; let (_, key) = backend_services(manager)? @@ -556,11 +706,19 @@ pub async fn call_service(manager: &UserScriptManager, params: &Value) -> anyhow .unwrap_or(DEFAULT_CALL_TIMEOUT) .min(Duration::from_secs(24)); let args = params.get("args").cloned().filter(Value::is_array).unwrap_or_else(|| json!([])); - call(manager, &key, "__service", json!({ "name": name, "method": method, "args": args }), timeout, None).await + call_for_host(manager, &key, "__service", json!({ "name": name, "method": method, "args": args }), timeout, None, BackendHost::Codex, expected).await } /// 桥接路由 `/extensions/services/call`:`{ name, method, args, timeoutMs? }`。 pub async fn route_service_call(payload: &Value) -> anyhow::Result { + let manager = crate::user_scripts::default_user_script_manager(); + let expected = source_generation(&manager, payload.get("source"))?; + let result = call_service_for_generation(&manager, payload, Some(expected)).await?; + Ok(json!({ "status": "ok", "result": result })) +} + +/// CLI/MCP 进程通过 CDP 中转的显式入口,不使用页面拓展实例的生命周期。 +pub async fn route_external_service_call(payload: &Value) -> anyhow::Result { let manager = crate::user_scripts::default_user_script_manager(); let result = call_service(&manager, payload).await?; Ok(json!({ "status": "ok", "result": result })) @@ -568,7 +726,12 @@ pub async fn route_service_call(payload: &Value) -> anyhow::Result { /// 正在启动中的后台:key → 启动完成的通知。启动(最多 15 秒等就绪)不占全局锁, /// 否则一个后台在启动时调用别的后台服务会和全局锁互相等待。 -static STARTING: std::sync::LazyLock>>> = std::sync::LazyLock::new(Default::default); +struct Starting { + generation: u64, + notify: Arc, + cancel: watch::Sender, +} +static STARTING: std::sync::LazyLock>> = std::sync::LazyLock::new(Default::default); /// 启动方的收尾:无论启动成功、失败还是调用方被取消(future 被丢弃),都移除启动中标记并唤醒等待者, /// 否则被取消后标记留着,之后每次调用都会等满 15 秒报「启动超时」。 @@ -579,7 +742,10 @@ struct StartingGuard { impl Drop for StartingGuard { fn drop(&mut self) { - STARTING.lock().unwrap().remove(&self.key); + let mut starting = STARTING.lock().unwrap(); + if starting.get(&self.key).is_some_and(|entry| Arc::ptr_eq(&entry.notify, &self.notify)) { + starting.remove(&self.key); + } self.notify.notify_waiters(); } } @@ -591,12 +757,18 @@ async fn ensure_started( declared: (String, PathBuf, Value), wanted: &str, host: BackendHost, + expected_generation: u64, + snapshot: Option, ) -> anyhow::Result<()> { let key = &host_slot(manager, key, host); + let generation_key = generation_key(manager, host); let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { - { + let (notify, cancel) = { let mut backends = BACKENDS.lock().await; + if generation(&generation_key) != expected_generation { + anyhow::bail!("拓展后台启动已被新的重载操作取代"); + } match backends.get(key) { Some(backend) if backend.fingerprint == wanted => return Ok(()), Some(_) => { @@ -604,29 +776,39 @@ async fn ensure_started( } None => {} } - } - let (notify, starter) = { let mut starting = STARTING.lock().unwrap(); match starting.get(key) { - Some(notify) => (notify.clone(), false), - None => { + Some(entry) if entry.generation == expected_generation => (entry.notify.clone(), None), + _ => { let notify = Arc::new(Notify::new()); - starting.insert(key.to_string(), notify.clone()); - (notify, true) + let (cancel, receiver) = watch::channel(false); + let entry = Starting { generation: expected_generation, notify: notify.clone(), cancel }; + if let Some(old) = starting.insert(key.to_string(), entry) { + let _ = old.cancel.send(true); + old.notify.notify_waiters(); + } + (notify, Some(receiver)) } } }; - if starter { + if let Some(mut cancel) = cancel { let _guard = StartingGuard { key: key.to_string(), notify }; - let backend = spawn(manager, declared, host).await?; - BACKENDS.lock().await.insert(key.to_string(), backend); + let backend = tokio::select! { + result = spawn(manager, declared, host, expected_generation, snapshot) => result?, + _ = cancel.changed() => anyhow::bail!("拓展后台启动已被新的重载操作取代"), + }; + let mut backends = BACKENDS.lock().await; + if generation(&generation_key) != expected_generation { + anyhow::bail!("拓展后台启动已被新的重载操作取代"); + } + backends.insert(key.to_string(), backend); return Ok(()); } // 别人正在启动:等它结束后再看一次(结束得早于开始等待时,下一轮直接看到结果)。 let notified = notify.notified(); tokio::pin!(notified); notified.as_mut().enable(); - if !STARTING.lock().unwrap().contains_key(key) { + if !STARTING.lock().unwrap().get(key).is_some_and(|entry| Arc::ptr_eq(&entry.notify, ¬ify)) { continue; } if tokio::time::timeout_at(deadline, notified).await.is_err() { @@ -645,7 +827,7 @@ pub async fn call( timeout: Duration, instance: Option<&str>, ) -> anyhow::Result { - call_for_host(manager, key, method, params, timeout, instance, BackendHost::Codex).await + call_for_host(manager, key, method, params, timeout, instance, BackendHost::Codex, None).await } /// Claude Mods 只调用清单显式声明的方法,后台进程、状态目录和宿主权限均与 Codex 分开。 @@ -657,7 +839,7 @@ pub(crate) async fn call_claude( timeout: Duration, instance: Option<&str>, ) -> anyhow::Result { - call_for_host(manager, key, method, params, timeout, instance, BackendHost::ClaudeDesktop).await + call_for_host(manager, key, method, params, timeout, instance, BackendHost::ClaudeDesktop, None).await } async fn call_for_host( @@ -668,26 +850,42 @@ async fn call_for_host( timeout: Duration, instance: Option<&str>, host: BackendHost, + source_generation: Option, ) -> anyhow::Result { if method.is_empty() || method.len() > 128 { anyhow::bail!("方法名无效"); } + let generation_key = generation_key(manager, host); + let expected_generation = source_generation.unwrap_or_else(|| generation(&generation_key)); + anyhow::ensure!(generation(&generation_key) == expected_generation, "拓展后台调用来源已过期"); + let snapshot = if host == BackendHost::Codex { bound_snapshot(manager, expected_generation) } else { None }; // 扫描拓展目录在锁外做;拓展已停用就停掉还在跑的后台。 let declared = match match host { - BackendHost::Codex => declared_backend(manager, key), + BackendHost::Codex => match &snapshot { + Some(snapshot) => snapshot.entries.get(key) + .map(|entry| (entry.extension.clone(), entry.script.clone(), entry.config.clone())) + .with_context(|| format!("快照 {} 中没有启用的后台 {key},未从原目录启动", snapshot.revision())), + None => declared_backend(manager, key), + }, BackendHost::ClaudeDesktop => manager.enabled_claude_extension_backend(key, method), } { Ok(declared) => declared, Err(error) => { - BACKENDS.lock().await.remove(&host_slot(manager, key, host)); + let mut backends = BACKENDS.lock().await; + if generation(&generation_key) == expected_generation { + backends.remove(&host_slot(manager, key, host)); + } return Err(error); } }; let wanted = fingerprint(&declared.1, &declared.2); - ensure_started(manager, key, declared, &wanted, host).await?; + ensure_started(manager, key, declared, &wanted, host, expected_generation, snapshot).await?; let key = &host_slot(manager, key, host); - let receiver = { + let (receiver, pending) = { let mut backends = BACKENDS.lock().await; + if generation(&generation_key) != expected_generation { + anyhow::bail!("拓展后台调用已被新的重载操作取代"); + } let Some(backend) = backends.get_mut(key) else { anyhow::bail!("拓展后台已退出,请重试"); }; @@ -708,20 +906,28 @@ async fn call_for_host( backends.remove(key); anyhow::bail!("拓展后台已退出,请重试"); } - receiver + (receiver, backend.pending.clone()) }; - match tokio::time::timeout(timeout, receiver).await { + let outcome = tokio::time::timeout(timeout, receiver).await; + anyhow::ensure!(generation(&generation_key) == expected_generation, "拓展后台调用已被新的重载操作取代"); + match outcome { Ok(Ok(Ok(value))) => Ok(value), Ok(Ok(Err(error))) => { if error == "拓展后台已退出" { - BACKENDS.lock().await.remove(key); + let mut backends = BACKENDS.lock().await; + if backends.get(key).is_some_and(|backend| Arc::ptr_eq(&backend.pending, &pending)) { + backends.remove(key); + } } anyhow::bail!("{error}") } Ok(Err(_)) => anyhow::bail!("拓展后台没有回应"), Err(_) => { if host == BackendHost::ClaudeDesktop { - BACKENDS.lock().await.remove(key); + let mut backends = BACKENDS.lock().await; + if backends.get(key).is_some_and(|backend| Arc::ptr_eq(&backend.pending, &pending)) { + backends.remove(key); + } } anyhow::bail!("调用 {method} 超时"); } @@ -730,14 +936,20 @@ async fn call_for_host( /// 取走 `since` 之后的事件;没有就最多等 `wait`。后台没起时直接返回空(不为取事件而启动后台)。 pub async fn events(manager: &UserScriptManager, key: &str, since: u64, wait: Duration) -> Value { + events_for_generation(manager, key, since, wait, None).await.expect("unfenced events") +} + +async fn events_for_generation(manager: &UserScriptManager, key: &str, since: u64, wait: Duration, expected: Option) -> anyhow::Result { let key = &slot(manager, key); + let generation_key = generation_key(manager, BackendHost::Codex); let wait = wait.min(MAX_EVENT_WAIT); let deadline = tokio::time::Instant::now() + wait; loop { let (queue, notify) = { let backends = BACKENDS.lock().await; + anyhow::ensure!(expected.is_none_or(|value| generation(&generation_key) == value), "后台事件来源已过期"); let Some(backend) = backends.get(key) else { - return json!({ "events": [], "next": since, "running": false }); + return Ok(json!({ "events": [], "next": since, "running": false })); }; (backend.events.clone(), backend.notify.clone()) }; @@ -750,32 +962,68 @@ pub async fn events(manager: &UserScriptManager, key: &str, since: u64, wait: Du .map(|(_, event)| event.clone()) .collect::>(); if !ready.is_empty() { + anyhow::ensure!(expected.is_none_or(|value| generation(&generation_key) == value), "后台事件来源已过期"); let next = ready.last().and_then(|event| event["seq"].as_u64()).unwrap_or(since); - return json!({ "events": ready, "next": next, "running": true }); + return Ok(json!({ "events": ready, "next": next, "running": true })); } if tokio::time::timeout_at(deadline, notified).await.is_err() { - return json!({ "events": [], "next": since, "running": true }); + anyhow::ensure!(expected.is_none_or(|value| generation(&generation_key) == value), "后台事件来源已过期"); + return Ok(json!({ "events": [], "next": since, "running": true })); } } } /// 后台队列里最新事件的序号;后台没起时为 None。 -async fn latest_seq(manager: &UserScriptManager, key: &str) -> Option { - let queue = BACKENDS.lock().await.get(&slot(manager, key))?.events.clone(); +async fn latest_seq(manager: &UserScriptManager, key: &str, expected: u64) -> anyhow::Result> { + let backends = BACKENDS.lock().await; + anyhow::ensure!(backend_generation(manager) == expected, "后台事件来源已过期"); + let Some(backend) = backends.get(&slot(manager, key)) else { return Ok(None) }; + let queue = backend.events.clone(); + drop(backends); let last = queue.lock().await.back().map(|(seq, _)| *seq).unwrap_or(0); - Some(last) + anyhow::ensure!(backend_generation(manager) == expected, "后台事件来源已过期"); + Ok(Some(last)) } /// 停掉这个拓展目录的全部后台(页面加载、热重载拓展时调用)。进程随 Child 释放被杀掉。 pub async fn stop_all(manager: &UserScriptManager) { - let prefix = slot_prefix(manager); - BACKENDS.lock().await.retain(|slot, _| !slot.starts_with(&prefix)); + stop_all_for_host(manager, BackendHost::Codex, None).await; +} + +/// 仅在代际仍匹配时停止;过期操作不能关闭新的后台。 +pub async fn stop_all_if_generation(manager: &UserScriptManager, expected: u64) -> bool { + stop_all_for_host(manager, BackendHost::Codex, Some(expected)).await +} + +async fn stop_all_for_host(manager: &UserScriptManager, host: BackendHost, expected: Option) -> bool { + let prefix = generation_key(manager, host); + let mut backends = BACKENDS.lock().await; + let mut generations = BACKEND_GENERATIONS.lock().unwrap(); + let current = generations.entry(prefix.clone()).or_default(); + if expected.is_some_and(|value| value != *current) { + return false; + } + *current = current.wrapping_add(1); + BACKEND_DOCUMENTS.lock().unwrap().remove(&prefix); + backends.retain(|slot, _| !slot.starts_with(&prefix)); + { + let starting = STARTING.lock().unwrap(); + for (slot, entry) in starting.iter() { + if slot.starts_with(&prefix) { + let _ = entry.cancel.send(true); + entry.notify.notify_waiters(); + } + } + } + invalidate_scan_cache(); + drop(generations); + drop(backends); + true } /// Claude MCP 断开时只停止它托管的后台,不影响 Codex 页面的后台。 pub async fn stop_all_claude(manager: &UserScriptManager) { - let prefix = format!("claudeDesktop\u{0}{}", slot_prefix(manager)); - BACKENDS.lock().await.retain(|slot, _| !slot.starts_with(&prefix)); + stop_all_for_host(manager, BackendHost::ClaudeDesktop, None).await; } pub(crate) async fn stop_claude(manager: &UserScriptManager, key: &str) { @@ -785,12 +1033,19 @@ pub(crate) async fn stop_claude(manager: &UserScriptManager, key: &str) { /// 停掉一个拓展的后台。给了 `instance` 时,只在最近一次调用也来自它时才停: /// 热重载时旧实例的 stop 可能晚于新实例的首次调用到达,不能把新后台停了。返回是否停掉。 pub async fn stop(manager: &UserScriptManager, key: &str, instance: Option<&str>) -> bool { + stop_for_generation(manager, key, instance, None).await +} + +async fn stop_for_generation(manager: &UserScriptManager, key: &str, instance: Option<&str>, expected: Option) -> bool { let key = &slot(manager, key); let mut backends = BACKENDS.lock().await; + if expected.is_some_and(|value| backend_generation(manager) != value) { + return false; + } let matches = match (backends.get(key), instance) { (None, _) => false, (Some(_), None) => true, - (Some(backend), Some(instance)) => backend.instance.as_deref().is_none_or(|last| last == instance), + (Some(backend), Some(instance)) => backend.instance.as_deref() == Some(instance), }; if matches { backends.remove(key); @@ -1094,7 +1349,8 @@ pub async fn route_call(payload: &Value) -> anyhow::Result { .min(Duration::from_secs(24)); let instance = payload.get("instance").and_then(Value::as_str); let manager = crate::user_scripts::default_user_script_manager(); - let result = call(&manager, key, method, payload.get("params").cloned().unwrap_or(Value::Null), timeout, instance).await?; + let expected = source_generation(&manager, payload.get("source"))?; + let result = call_for_host(&manager, key, method, payload.get("params").cloned().unwrap_or(Value::Null), timeout, instance, BackendHost::Codex, Some(expected)).await?; Ok(json!({ "status": "ok", "result": result })) } @@ -1102,7 +1358,8 @@ pub async fn route_call(payload: &Value) -> anyhow::Result { pub async fn route_stop(payload: &Value) -> anyhow::Result { let key = payload.get("key").and_then(Value::as_str).context("缺少拓展 key")?; let manager = crate::user_scripts::default_user_script_manager(); - let stopped = stop(&manager, key, payload.get("instance").and_then(Value::as_str)).await; + let expected = source_generation(&manager, payload.get("source"))?; + let stopped = stop_for_generation(&manager, key, payload.get("instance").and_then(Value::as_str), Some(expected)).await; Ok(json!({ "status": "ok", "stopped": stopped })) } @@ -1111,8 +1368,9 @@ pub async fn route_stop(payload: &Value) -> anyhow::Result { pub async fn route_events(payload: &Value) -> anyhow::Result { let key = payload.get("key").and_then(Value::as_str).context("缺少拓展 key")?; let manager = crate::user_scripts::default_user_script_manager(); + let expected = source_generation(&manager, payload.get("source"))?; if payload.get("since").and_then(Value::as_str) == Some("latest") { - return Ok(match latest_seq(&manager, key).await { + return Ok(match latest_seq(&manager, key, expected).await? { Some(next) => json!({ "status": "ok", "events": [], "next": next, "running": true }), None => json!({ "status": "ok", "events": [], "next": 0, "running": false }), }); @@ -1123,11 +1381,86 @@ pub async fn route_events(payload: &Value) -> anyhow::Result { .and_then(Value::as_u64) .map(Duration::from_millis) .unwrap_or(MAX_EVENT_WAIT); - let mut result = events(&manager, key, since, wait).await; + let mut result = events_for_generation(&manager, key, since, wait, Some(expected)).await?; result["status"] = json!("ok"); Ok(result) } +#[cfg(test)] +mod source_fencing_tests { + use super::*; + + #[tokio::test] + async fn quiescing_revokes_source_before_replacing_backend_generation() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("config.json")); + let generation = backend_generation(&manager); + let old = bind_backend_document(&manager, generation, "document").await.unwrap(); + let quiescing = revoke_backend_document(&manager, generation).await.unwrap(); + assert!(source_generation(&manager, Some(&old)).is_err()); + assert_ne!(backend_generation(&manager), generation); + assert!(call_for_host(&manager, "user:unused", "test", Value::Null, Duration::from_secs(1), None, BackendHost::Codex, Some(generation)).await.is_err()); + assert!(stop_all_if_generation(&manager, quiescing).await); + let current = backend_generation(&manager); + let source = bind_backend_document(&manager, current, "document").await.unwrap(); + assert!(revoke_backend_document(&manager, generation).await.is_none()); + assert_eq!(source_generation(&manager, Some(&source)).unwrap(), current); + stop_all(&manager).await; + } + + #[tokio::test] + async fn old_document_and_backend_generation_cannot_enter_new_generation() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("config.json")); + let old_generation = backend_generation(&manager); + let old = bind_backend_document(&manager, old_generation, "old-document").await.unwrap(); + assert_eq!(source_generation(&manager, Some(&old)).unwrap(), old_generation); + assert!(source_generation(&manager, None).is_err()); + assert!(bind_backend_document(&manager, old_generation, "different-document").await.is_err()); + + assert!(stop_all_if_generation(&manager, old_generation).await); + assert!(source_generation(&manager, Some(&old)).is_err()); + assert!(bind_backend_document(&manager, old_generation, "old-document").await.is_err()); + let current_generation = backend_generation(&manager); + let current = bind_backend_document(&manager, current_generation, "new-document").await.unwrap(); + assert_eq!(source_generation(&manager, Some(¤t)).unwrap(), current_generation); + assert!(source_generation(&manager, Some(&json!({ + "ownerEpoch": "other-owner", "generation": current_generation, "documentId": "new-document" + }))).is_err()); + assert!(call_for_host(&manager, "user:unused", "test", Value::Null, Duration::from_secs(1), None, BackendHost::Codex, Some(old_generation)).await.is_err()); + assert!(events_for_generation(&manager, "user:unused", 0, Duration::ZERO, Some(old_generation)).await.is_err()); + assert!(!stop_for_generation(&manager, "user:unused", None, Some(old_generation)).await); + assert!(host_call_for_generation(&json!({ "service": "services", "method": "call", "params": {} }), &manager, old_generation).await.is_err()); + stop_all(&manager).await; + } +} + +#[cfg(test)] +mod scan_cache_tests { + use super::{SCAN_GENERATION, ScanCache, cached}; + use std::sync::atomic::Ordering; + use std::time::Instant; + + #[test] + fn old_revision_cannot_be_returned_as_a_cache_hit() { + static CACHE: ScanCache = std::sync::LazyLock::new(Default::default); + let revision = SCAN_GENERATION.load(Ordering::SeqCst); + CACHE.lock().unwrap().insert("stale".into(), (Instant::now(), revision.wrapping_sub(1), 1)); + assert_eq!(cached(&CACHE, "stale", || 2), 2); + } + + #[test] + fn invalidated_in_flight_scan_cannot_fill_cache() { + static CACHE: ScanCache = std::sync::LazyLock::new(Default::default); + assert_eq!(cached(&CACHE, "pending", || { + SCAN_GENERATION.fetch_add(1, Ordering::SeqCst); + 1 + }), 1); + assert!(!CACHE.lock().unwrap().contains_key("pending")); + assert_eq!(cached(&CACHE, "pending", || 2), 2); + } +} + #[cfg(test)] mod upgrade_tests { use super::copy_upgrade_until_eof; diff --git a/crates/codex-plus-core/src/extension_creator.rs b/crates/codex-plus-core/src/extension_creator.rs index 8ee0947717..d8adb4f661 100644 --- a/crates/codex-plus-core/src/extension_creator.rs +++ b/crates/codex-plus-core/src/extension_creator.rs @@ -56,6 +56,8 @@ const INSTRUCTIONS: &str = "用户要做或改 Codex++ 拓展(加功能、改 pub struct ExtensionCreator { pub manager: UserScriptManager, pub debug_ports: Vec, + /// 接入时固化的会话状态目录;重载不得猜测或回退到其他实例。 + pub state_dir: std::path::PathBuf, } pub async fn serve(creator: ExtensionCreator) -> anyhow::Result<()> { @@ -876,41 +878,21 @@ async fn reload_and_report(creator: &ExtensionCreator, key: &str) -> anyhow::Res /// 只有页内重载才自动执行。某个已加载脚本没登记清理函数时,重载会整页刷新 Codex, /// 可能打断用户正在进行的对话,这种情况交给用户在管理器里手动点。 async fn reload(creator: &ExtensionCreator) -> Value { - let mut last_error = String::from("没有找到已连接的 Codex 页面;拓展文件已写好,Codex 下次启动或在 Codex++ 管理器点「热重载」后生效"); - for port in &creator.debug_ports { - let Ok(status) = user_scripts::live_runtime_status(*port).await else { - continue; - }; - let blockers = status - .as_object() - .map(|scripts| { - scripts - .iter() - .filter(|(_, record)| { - record - .get("cleanups") - .and_then(Value::as_array) - .is_none_or(Vec::is_empty) - }) - .map(|(key, _)| key.clone()) - .collect::>() - }) - .unwrap_or_default(); - if !blockers.is_empty() { - return json!({ - "reloaded": false, - "reason": "有旧脚本不支持页内热重载,自动重载会刷新整个 Codex 页面、可能打断当前对话,所以没有自动执行。拓展文件已写好,请让用户在 Codex++ 管理器的「本地拓展」里点「热重载」。", - "blockingScripts": blockers, - }); - } - match user_scripts::reload_live_scripts(*port, &creator.manager).await { + let port = crate::status::StatusStore::new(creator.state_dir.join("latest-status.json")) + .load_latest().ok().flatten().and_then(|status| status.debug_port); + if let Some(port) = port { + match user_scripts::reload_native_scripts(&creator.state_dir, port, &creator.manager, false, None).await { Ok(inventory) => { - return json!({ "reloaded": true, "mode": inventory["reload_mode"] }); + let operation = &inventory["reloadOperation"]; + if operation["phase"] == "applied" { + return json!({ "reloaded": true, "mode": inventory["reload_mode"], "operation": operation }); + } + return json!({ "reloaded": false, "reason": "热重载未确认完成,请在 Codex++ 管理器检查状态;没有自动请求页面刷新。", "operation": operation }); } - Err(error) => last_error = format!("热重载失败:{error:#}"), + Err(error) => return json!({ "reloaded": false, "reason": format!("热重载失败:{error:#}") }), } } - json!({ "reloaded": false, "reason": last_error }) + json!({ "reloaded": false, "reason": "没有找到此实例的重载会话;拓展文件已写好,请在对应 Codex++ 管理器中启动 Codex 后重载。" }) } /// 启用中拓展声明的工具,以 MCP 工具定义返回。名字是 `<拓展 id>__<工具名>`。 diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 63bd9bd49e..24f3902f58 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -125,6 +125,7 @@ pub mod extension_node; pub mod extension_semver; pub mod upstream_model_meta; pub mod user_scripts; +pub mod user_script_control; pub mod version; pub mod vision; pub mod watcher; diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index 2465c39174..3c98870c0b 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -165,6 +165,17 @@ pub trait BridgeRuntimeService: Send + Sync { crate::user_scripts::default_user_script_manager().extension_readme(&key) } async fn reload_user_scripts(&self) -> anyhow::Result; + async fn begin_user_script_reload(&self, payload: Value) -> anyhow::Result { + anyhow::ensure!(payload.get("allowPage").and_then(Value::as_bool) != Some(false), + "当前宿主不支持禁止刷新页面的重载"); + self.reload_user_scripts().await + } + async fn begin_user_script_reload_no_page(&self, _payload: Value) -> anyhow::Result { + anyhow::bail!("当前宿主不支持禁止刷新页面的重载") + } + async fn user_script_reload_status(&self, _payload: Value) -> anyhow::Result { + anyhow::bail!("用户脚本重载状态不可用") + } async fn load_user_scripts(&self) -> anyhow::Result { self.user_script_inventory().await } @@ -466,7 +477,9 @@ pub async fn handle_bridge_request( } } "/user-scripts/load" => ctx.runtime.load_user_scripts().await, - "/user-scripts/reload" => ctx.runtime.reload_user_scripts().await, + "/user-scripts/reload" => ctx.runtime.begin_user_script_reload(payload.clone()).await, + "/user-scripts/reload-no-page" => ctx.runtime.begin_user_script_reload_no_page(payload.clone()).await, + "/user-scripts/reload-status" => ctx.runtime.user_script_reload_status(payload.clone()).await, "/cdp/targets" => ctx.runtime.cdp_targets().await, "/cdp/evaluate" => ctx.runtime.cdp_evaluate(payload.clone()).await, // 系统鼠标位置:拓展写「桌宠跟随鼠标」这类功能用(页面里拿不到窗口外的鼠标)。只读。 @@ -479,6 +492,7 @@ pub async fn handle_bridge_request( "/extensions/backend/events" => crate::extension_backend::route_events(&payload).await, "/extensions/backend/stop" => crate::extension_backend::route_stop(&payload).await, "/extensions/services/call" => crate::extension_backend::route_service_call(&payload).await, + "/extensions/services/call-external" => crate::extension_backend::route_external_service_call(&payload).await, // 拓展自己调模型(ctx.llm):经协议代理用当前供应商,不跑拓展网关中间件。 "/extensions/llm/complete" => crate::extension_llm::route_complete(&payload).await, "/extensions/llm/models" => crate::extension_llm::route_models(), @@ -935,11 +949,17 @@ impl BridgeRuntimeService for CoreRuntimeService { self.websocket_url.as_deref(), &self.user_script_evaluator, ) { - evaluator(websocket_url, &user_scripts.build_initial_bundle()?)?; + static SEQUENCE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + std::sync::atomic::AtomicU64::new(time.saturating_mul(1024)) + }); + let sequence = SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + crate::user_scripts::load_scripts_with_evaluator(user_scripts, sequence, |expression| { + std::future::ready(evaluator(websocket_url, &expression)) + }).await } else { - anyhow::bail!("Codex 页面尚未连接"); + anyhow::bail!("Codex 页面尚未连接") } - self.user_script_inventory().await } async fn script_market_list(&self) -> anyhow::Result { @@ -965,17 +985,9 @@ impl BridgeRuntimeService for CoreRuntimeService { } async fn reload_user_scripts(&self) -> anyhow::Result { - if let (Some(user_scripts), Some(websocket_url), Some(evaluator)) = ( - &self.user_scripts, - self.websocket_url.as_deref(), - &self.user_script_evaluator, - ) { - let bundle = user_scripts.build_reload_bundle()?; - evaluator(websocket_url, &bundle)?; - } else { - anyhow::bail!("Codex 页面尚未连接"); - } - self.user_script_inventory().await + let manager = self.user_scripts.as_ref().ok_or_else(|| anyhow::anyhow!("用户脚本目录不可用"))?; + // 所有运行期重载都交给实例 owner,旧的直接 bundle 入口不能绕过代际和确认协议。 + crate::user_scripts::reload_live_scripts(self.debug_port, manager).await } async fn cdp_targets(&self) -> anyhow::Result { diff --git a/crates/codex-plus-core/src/user_script_control.rs b/crates/codex-plus-core/src/user_script_control.rs new file mode 100644 index 0000000000..066fcc18e8 --- /dev/null +++ b/crates/codex-plus-core/src/user_script_control.rs @@ -0,0 +1,842 @@ +//! 用户脚本重载的私有会话通道。接收和查询不经过 renderer,页面执行仍由 launcher owner 协调。 + +use std::future::Future; +use std::path::Path; +#[cfg(unix)] +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::user_scripts::UserScriptManager; + +const PROTOCOL: u32 = 1; +const MAX_REQUEST: usize = 16 * 1024; +const MAX_RESPONSE: usize = 4 * 1024 * 1024; +const IO_DEADLINE: Duration = Duration::from_secs(3); + +#[derive(Debug, thiserror::Error)] +#[error("重载 owner 已变化,旧操作结果无法确认;没有向新 owner 重投")] +pub struct OwnerChanged; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlIdentity { + protocol: u32, + state_dir: String, + script_dir: String, + config_path: String, + debug_port: u16, +} + +impl ControlIdentity { + pub fn new(state_dir: &Path, debug_port: u16, manager: &UserScriptManager) -> anyhow::Result { + anyhow::ensure!(debug_port != 0, "重载会话的调试端口无效"); + Ok(Self { + protocol: PROTOCOL, + state_dir: normalized_path(state_dir)?, + script_dir: normalized_path(manager.user_dir())?, + config_path: normalized_path(manager.config_path())?, + debug_port, + }) + } + + pub(crate) fn key(&self) -> String { + let hash = Sha256::digest(serde_json::to_vec(self).expect("control identity is serializable")); + format!("{hash:x}") + } +} + +fn normalized_path(path: &Path) -> anyhow::Result { + anyhow::ensure!(path.is_absolute(), "重载会话的路径必须是绝对路径"); + // 新安装时配置文件可能还不存在。只解析现有父目录,不创建或修改用户目录。 + let mut ancestor = path; + let mut suffix = Vec::new(); + while !ancestor.exists() { + suffix.push(ancestor.file_name().context("无法解析重载会话路径")?.to_owned()); + ancestor = ancestor.parent().context("无法解析重载会话父目录")?; + } + let mut resolved = ancestor.canonicalize()?; + for component in suffix.iter().rev() { + resolved.push(component); + } + let value = resolved.to_str().context("重载会话路径不是有效 UTF-8")?.to_owned(); + #[cfg(windows)] + let value = value.to_lowercase(); + Ok(value) +} + +pub fn new_owner_epoch() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +pub fn new_operation_id(epoch: &str) -> String { + format!("{epoch}-{:x}-{}", now_ms(), uuid::Uuid::new_v4().simple()) +} + +pub fn operation_issued_at(epoch: &str, id: &str) -> Option { + let tail = id.strip_prefix(epoch)?.strip_prefix('-')?; + let (time, nonce) = tail.split_once('-')?; + if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + u64::from_str_radix(time, 16).ok() +} + +pub fn now_ms() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 +} + +pub enum ControlAction { + Submit(Value), + Status(String), + StatusWithVerification(String, String), +} + +pub type ControlHandler = Arc anyhow::Result + Send + Sync>; + +pub struct ControlServer { + task: tokio::task::JoinHandle<()>, + #[cfg(unix)] + path: PathBuf, + #[cfg(unix)] + _lock: std::fs::File, +} + +impl Drop for ControlServer { + fn drop(&mut self) { + self.task.abort(); + #[cfg(unix)] + let _ = std::fs::remove_file(&self.path); + } +} + +pub fn start_owner( + identity: ControlIdentity, + owner_epoch: String, + handler: ControlHandler, +) -> anyhow::Result { + let endpoint = endpoint(&identity)?; + let permits = Arc::new(tokio::sync::Semaphore::new(16)); + #[cfg(windows)] + { + let mut listener = private_pipe(&endpoint, true)?; + let task = tokio::spawn(async move { + loop { + if listener.connect().await.is_err() { + break; + } + // 先创建下一实例,避免已连接实例退出时出现可被抢占的管道空档。 + let next = match private_pipe(&endpoint, false) { + Ok(next) => next, + Err(_) => break, + }; + let stream = std::mem::replace(&mut listener, next); + let Ok(permit) = permits.clone().try_acquire_owned() else { continue }; + let identity = identity.clone(); + let owner_epoch = owner_epoch.clone(); + let handler = handler.clone(); + tokio::spawn(async move { + let _permit = permit; + let _ = bounded(serve_connection(stream, &identity, &owner_epoch, &handler)).await; + }); + } + }); + Ok(ControlServer { task }) + } + #[cfg(unix)] + { + use fs2::FileExt; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let path = PathBuf::from(endpoint); + let lock = std::fs::OpenOptions::new().create(true).truncate(false).read(true).write(true) + .mode(0o600).custom_flags(libc::O_NOFOLLOW).open(path.with_extension("lock"))?; + lock.try_lock_exclusive().context("此实例已有重载协调通道")?; + // 同名锁已独占;只移除这一条上次异常退出留下的 socket。 + match std::fs::symlink_metadata(&path) { + Ok(metadata) => { + use std::os::unix::fs::FileTypeExt; + anyhow::ensure!(metadata.file_type().is_socket(), "重载通道路径不是 socket"); + std::fs::remove_file(&path)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let listener = tokio::net::UnixListener::bind(&path)?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + let task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { break }; + let Ok(permit) = permits.clone().try_acquire_owned() else { continue }; + let identity = identity.clone(); + let owner_epoch = owner_epoch.clone(); + let handler = handler.clone(); + tokio::spawn(async move { + let _permit = permit; + let _ = bounded(serve_connection(stream, &identity, &owner_epoch, &handler)).await; + }); + } + }); + Ok(ControlServer { task, path, _lock: lock }) + } +} + +async fn bounded(future: impl Future>) -> anyhow::Result { + tokio::time::timeout(IO_DEADLINE, future).await.context("本地重载协调通道响应超时")? +} + +async fn serve_connection( + mut stream: impl AsyncRead + AsyncWrite + Unpin, + identity: &ControlIdentity, + epoch: &str, + handler: &ControlHandler, +) -> anyhow::Result<()> { + let request = read_frame(&mut stream, MAX_REQUEST).await?; + let (identity_for_request, epoch_for_request, handler_for_request) = + (identity.clone(), epoch.to_owned(), handler.clone()); + // 配置/快照的文件 I/O 不能占住 launcher 单线程 runtime 的接收及查询循环。 + let result = tokio::task::spawn_blocking(move || dispatch_request( + request, &identity_for_request, &epoch_for_request, &handler_for_request, + )).await.context("重载协调命令执行任务失败")?; + let response = match result { + Ok(value) => json!({ "identity": identity, "ownerEpoch": epoch, "result": value }), + Err(error) => json!({ "identity": identity, "ownerEpoch": epoch, "error": format!("{error:#}") }), + }; + write_frame(&mut stream, &response, MAX_RESPONSE).await +} + +fn dispatch_request( + request: Value, + identity: &ControlIdentity, + epoch: &str, + handler: &ControlHandler, +) -> anyhow::Result { + anyhow::ensure!(request.get("identity") == Some(&serde_json::to_value(identity)?), + "重载通道的实例、脚本目录或配置身份不匹配"); + if request["action"] == "hello" { + return Ok(json!({ "capabilities": { "nativeReload": true, "noPageReload": true } })); + } + anyhow::ensure!(request["ownerEpoch"].as_str() == Some(epoch), "重载 owner 已变化;没有向新 owner 重投旧操作"); + let id = request["operationId"].as_str().context("缺少重载操作编号")?; + anyhow::ensure!(operation_issued_at(epoch, id).is_some(), "重载操作不属于此 owner"); + match request["action"].as_str() { + Some("submit") => { + let allow_page = request["allowPage"].as_bool().context("缺少页面刷新策略")?; + handler(ControlAction::Submit(json!({ "operationId": id, "allowPage": allow_page, + "verificationId": request["verificationId"] }))) + } + Some("status") => match request["verificationId"].as_str() { + Some(verification) => handler(ControlAction::StatusWithVerification(id.to_owned(), verification.to_owned())), + None => handler(ControlAction::Status(id.to_owned())), + }, + _ => anyhow::bail!("未知重载协调命令"), + } +} + +async fn read_frame(stream: &mut (impl AsyncRead + Unpin), limit: usize) -> anyhow::Result { + let size = stream.read_u32().await? as usize; + anyhow::ensure!(size > 0 && size <= limit, "重载协调消息超出长度限制"); + let mut data = vec![0_u8; size]; + stream.read_exact(&mut data).await?; + Ok(serde_json::from_slice(&data)?) +} + +async fn write_frame(stream: &mut (impl AsyncWrite + Unpin), value: &Value, limit: usize) -> anyhow::Result<()> { + let encoded = serde_json::to_vec(value)?; + anyhow::ensure!(encoded.len() <= limit, "重载协调响应超出长度限制"); + stream.write_u32(encoded.len() as u32).await?; + stream.write_all(&encoded).await?; + stream.flush().await?; + Ok(()) +} + +pub async fn request(identity: &ControlIdentity, mut request: Value) -> anyhow::Result { + request["identity"] = serde_json::to_value(identity)?; + bounded(async { + let endpoint = endpoint(identity)?; + #[cfg(windows)] + let mut stream = { + loop { + match tokio::net::windows::named_pipe::ClientOptions::new().open(&endpoint) { + Ok(stream) => { + verify_pipe_owner(&stream)?; + break stream; + } + Err(error) if error.raw_os_error() == Some(231) => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(error) => return Err(anyhow::Error::from(error).context( + "没有找到匹配的本地重载协调器;请更新或启动对应 Codex++ 实例")), + } + } + }; + #[cfg(unix)] + let mut stream = tokio::net::UnixStream::connect(endpoint).await.context( + "没有找到匹配的本地重载协调器;请更新或启动对应 Codex++ 实例")?; + write_frame(&mut stream, &request, MAX_REQUEST).await?; + let response = read_frame(&mut stream, MAX_RESPONSE).await?; + anyhow::ensure!(response.get("identity") == Some(&serde_json::to_value(identity)?), + "重载协调响应的实例身份不匹配"); + if request["action"] != "hello" { + if response["ownerEpoch"] != request["ownerEpoch"] { + return Err(OwnerChanged.into()); + } + } + if let Some(error) = response["error"].as_str() { + anyhow::bail!("{error}"); + } + Ok(json!({ "ownerEpoch": response["ownerEpoch"], "result": response["result"] })) + }).await +} + +#[cfg(windows)] +fn endpoint(identity: &ControlIdentity) -> anyhow::Result { + Ok(format!(r"\\.\pipe\codex-plus-reload-{}", identity.key())) +} + +#[cfg(any(unix, test))] +fn unix_socket_path(temp_root: &str, uid: u32, key: &str, capacity: usize) -> anyhow::Result { + for root in [temp_root, "/tmp"] { + let path = format!("{}/codex-plus-reload-{uid}/{key}.sock", root.trim_end_matches('/')); + // sun_path 包含结尾 NUL;UTF-8 路径按字节而非字符计长。 + if path.len() < capacity { + return Ok(path); + } + } + anyhow::bail!("重载 socket 路径超过当前平台的长度限制") +} + +#[cfg(unix)] +fn endpoint(identity: &ControlIdentity) -> anyhow::Result { + use std::os::unix::fs::{DirBuilderExt, MetadataExt}; + let uid = unsafe { libc::geteuid() }; + // sockaddr_un 全零有效;读取实际容量以适配 macOS、Linux 及其他 Unix。 + let address: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + let temp_root = std::env::temp_dir(); + let path = unix_socket_path( + temp_root.to_str().context("重载 socket 路径不是有效 UTF-8")?, + uid, + &identity.key()[..40], + address.sun_path.len(), + )?; + let directory = Path::new(&path).parent().context("重载 socket 缺少私有目录")?; + match std::fs::DirBuilder::new().mode(0o700).create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + let metadata = std::fs::symlink_metadata(&directory)?; + anyhow::ensure!(metadata.is_dir() && !metadata.file_type().is_symlink() + && metadata.uid() == uid && metadata.mode() & 0o077 == 0, + "重载协调目录不是当前用户的私有目录"); + Ok(path) +} + +#[cfg(windows)] +fn process_user_sid(process: windows::Win32::Foundation::HANDLE) -> anyhow::Result { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows::Win32::Foundation::{HANDLE, HLOCAL, LocalFree}; + use windows::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser}; + use windows::Win32::Security::Authorization::ConvertSidToStringSidW; + use windows::Win32::System::Threading::OpenProcessToken; + use windows::core::PWSTR; + let mut token = HANDLE::default(); + unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }?; + let token = unsafe { OwnedHandle::from_raw_handle(token.0) }; + let mut needed = 0; + let _ = unsafe { GetTokenInformation(HANDLE(token.as_raw_handle()), TokenUser, None, 0, &mut needed) }; + anyhow::ensure!(needed > 0 && needed <= 64 * 1024, "无法读取当前用户 SID"); + // Vec 给 TOKEN_USER/SID 提供原生指针对齐。 + let mut buffer = vec![0_usize; (needed as usize).div_ceil(std::mem::size_of::())]; + unsafe { + GetTokenInformation(HANDLE(token.as_raw_handle()), TokenUser, Some(buffer.as_mut_ptr().cast()), needed, &mut needed) + }?; + let user = unsafe { &*buffer.as_ptr().cast::() }; + let mut sid = PWSTR::null(); + unsafe { ConvertSidToStringSidW(user.User.Sid, &mut sid) }?; + let text = unsafe { sid.to_string() }; + unsafe { let _ = LocalFree(HLOCAL(sid.0.cast())); } + Ok(text?) +} + +#[cfg(windows)] +fn verify_pipe_owner(stream: &tokio::net::windows::named_pipe::NamedPipeClient) -> anyhow::Result<()> { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::Pipes::GetNamedPipeServerProcessId; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; + let mut pid = 0; + unsafe { GetNamedPipeServerProcessId(HANDLE(stream.as_raw_handle()), &mut pid) }?; + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?; + let process = unsafe { OwnedHandle::from_raw_handle(process.0) }; + anyhow::ensure!( + process_user_sid(HANDLE(process.as_raw_handle()))? == process_user_sid(unsafe { GetCurrentProcess() })?, + "重载协调器不属于当前用户", + ); + Ok(()) +} + +#[cfg(windows)] +fn with_user_security( + inherit_children: bool, + action: impl FnOnce(*mut std::ffi::c_void) -> anyhow::Result, +) -> anyhow::Result { + use windows::Win32::Foundation::{HLOCAL, LocalFree}; + use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; + use windows::Win32::Security::Authorization::{ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1}; + use windows::Win32::System::Threading::GetCurrentProcess; + use windows::core::PCWSTR; + struct LocalAllocation(*mut std::ffi::c_void); + impl Drop for LocalAllocation { + fn drop(&mut self) { unsafe { let _ = LocalFree(HLOCAL(self.0)); } } + } + let sid = process_user_sid(unsafe { GetCurrentProcess() })?; + // 只授予当前用户,禁止继承宽泛的默认 DACL;句柄不继承给子进程。 + let inheritance = if inherit_children { "OICI" } else { "" }; + let sddl: Vec = format!("D:P(A;{inheritance};GA;;;{sid})").encode_utf16().chain(Some(0)).collect(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW(PCWSTR(sddl.as_ptr()), SDDL_REVISION_1, &mut descriptor, None) + }?; + let _descriptor_allocation = LocalAllocation(descriptor.0); + let mut attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: false.into(), + }; + action((&mut attributes as *mut SECURITY_ATTRIBUTES).cast()) +} + +#[cfg(windows)] +fn private_pipe(endpoint: &str, first: bool) -> anyhow::Result { + // SECURITY_ATTRIBUTES 和 descriptor 在 CreateNamedPipe 调用期间保持有效。 + with_user_security(false, |attributes| Ok(unsafe { + tokio::net::windows::named_pipe::ServerOptions::new() + .first_pipe_instance(first).reject_remote_clients(true) + .create_with_security_attributes_raw(endpoint, attributes) + }?)) +} + +/// 创建新的私有目录。已有路径返回错误,不接受未经检查的旧目录或重解析点。 +pub(crate) fn create_private_directory(path: &Path) -> anyhow::Result<()> { + anyhow::ensure!(path.is_absolute(), "私有快照目录必须是绝对路径"); + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows::Win32::Security::SECURITY_ATTRIBUTES; + use windows::Win32::Storage::FileSystem::CreateDirectoryW; + use windows::core::PCWSTR; + let encoded = path.as_os_str().encode_wide().chain(Some(0)).collect::>(); + with_user_security(true, |attributes| { + unsafe { CreateDirectoryW(PCWSTR(encoded.as_ptr()), Some(attributes.cast::())) }?; + Ok(()) + }) + } + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + Ok(std::fs::DirBuilder::new().mode(0o700).create(path)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn unix_socket_path_keeps_short_roots() { + let key = "a".repeat(40); + for capacity in [104, 108] { + for root in ["/tmp", "/var/tmp/"] { + assert_eq!( + unix_socket_path(root, 501, &key, capacity).unwrap(), + format!("{}/codex-plus-reload-501/{key}.sock", root.trim_end_matches('/')), + ); + } + } + } + + #[test] + fn unix_socket_path_falls_back_from_long_temp_root() { + let key = "a".repeat(40); + let root = format!("/var/folders/{}/T/", "long-root".repeat(16)); + for capacity in [104, 108] { + assert_eq!( + unix_socket_path(&root, 501, &key, capacity).unwrap(), + format!("/tmp/codex-plus-reload-501/{key}.sock"), + ); + } + assert!(unix_socket_path(&root, 501, &key, 16).is_err()); + } + + #[test] + fn unix_socket_path_reserves_terminating_nul_at_platform_limits() { + let key = "a".repeat(40); + let suffix = format!("/codex-plus-reload-501/{key}.sock"); + for capacity in [104, 108] { + let root = format!("/{}", "x".repeat(capacity - suffix.len() - 2)); + let path = unix_socket_path(&root, 501, &key, capacity).unwrap(); + assert_eq!(path, format!("{root}{suffix}")); + assert_eq!(path.len(), capacity - 1); + assert_eq!( + unix_socket_path(&format!("{root}x"), 501, &key, capacity).unwrap(), + format!("/tmp{suffix}"), + ); + } + } + + #[test] + fn unix_socket_path_counts_multibyte_roots_as_utf8_bytes() { + let key = "a".repeat(40); + let suffix = format!("/codex-plus-reload-501/{key}.sock"); + for capacity in [104, 108] { + let root_bytes = capacity - suffix.len() - 2; + let root = format!("/{}{}", "\u{6e29}".repeat(root_bytes / 3), "x".repeat(root_bytes % 3)); + let path = unix_socket_path(&root, 501, &key, capacity).unwrap(); + assert_eq!(path, format!("{root}{suffix}")); + assert_eq!(path.len(), capacity - 1); + let too_long = format!("{root}\u{e9}"); + assert!(format!("{too_long}{suffix}").chars().count() < capacity); + assert_eq!( + unix_socket_path(&too_long, 501, &key, capacity).unwrap(), + format!("/tmp{suffix}"), + ); + } + } + + fn identity(temp: &tempfile::TempDir, port: u16) -> ControlIdentity { + ControlIdentity::new(temp.path(), port, &UserScriptManager::new( + temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json"), + )).unwrap() + } + + #[tokio::test] + async fn native_channel_receives_without_a_renderer_and_checks_identity_and_epoch() { + let temp = tempfile::tempdir().unwrap(); + let identity = identity(&temp, 9321); + let epoch = new_owner_epoch(); + let calls = Arc::new(AtomicUsize::new(0)); + let counted = calls.clone(); + let handler: ControlHandler = Arc::new(move |action| { + counted.fetch_add(1, Ordering::SeqCst); + match action { + ControlAction::Submit(payload) => Ok(json!({ "reloadOperation": payload })), + ControlAction::Status(id) | ControlAction::StatusWithVerification(id, _) => + Ok(json!({ "reloadOperation": { "operationId": id, "phase": "unknown" } })), + } + }); + let _server = start_owner(identity.clone(), epoch.clone(), handler.clone()).unwrap(); + let hello = request(&identity, json!({ "action": "hello" })).await.unwrap(); + assert_eq!(hello["ownerEpoch"], epoch); + let id = new_operation_id(&epoch); + let submitted = request(&identity, json!({ + "action": "submit", "ownerEpoch": epoch, "operationId": id, "allowPage": false, + })).await.unwrap(); + assert_eq!(submitted["result"]["reloadOperation"]["allowPage"], false); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(request(&identity, json!({ + "action": "submit", "ownerEpoch": new_owner_epoch(), "operationId": id, "allowPage": true, + })).await.is_err()); + let mut wrong = identity.clone(); + wrong.script_dir.push_str("-other"); + assert!(dispatch_request(json!({ + "identity": wrong, "action": "submit", "ownerEpoch": epoch, "operationId": id, "allowPage": true, + }), &identity, &epoch, &handler).is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn distinct_instance_identity_cannot_reuse_an_owner() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let owner = identity(&first, 9322); + let other = identity(&second, 9322); + assert_ne!(owner.key(), other.key()); + let _server = start_owner(owner.clone(), new_owner_epoch(), Arc::new(|_| unreachable!())).unwrap(); + assert!(request(&other, json!({ "action": "hello" })).await.is_err()); + } + + #[tokio::test] + async fn lost_submission_ack_is_resolved_by_the_same_id() { + use std::sync::Mutex; + let temp = tempfile::tempdir().unwrap(); + let identity = identity(&temp, 9323); + let epoch = new_owner_epoch(); + let records = Arc::new(Mutex::new(std::collections::HashMap::::new())); + let accepted = records.clone(); + let calls = Arc::new(AtomicUsize::new(0)); + let counted = calls.clone(); + let handler: ControlHandler = Arc::new(move |action| { + match action { + ControlAction::Submit(payload) => { + let id = payload["operationId"].as_str().unwrap().to_owned(); + let mut records = accepted.lock().unwrap(); + if let Some(record) = records.get(&id) { return Ok(record.clone()) } + counted.fetch_add(1, Ordering::SeqCst); + let record = json!({ "reloadOperation": { "operationId": id, "phase": "accepted" } }); + records.insert(id, record); + // 已登记后的响应丢失;handler故障不可导致client换ID。 + anyhow::bail!("模拟接收后的 ACK 丢失") + } + ControlAction::Status(id) | ControlAction::StatusWithVerification(id, _) => + Ok(accepted.lock().unwrap().get(&id).unwrap().clone()), + } + }); + let _server = start_owner(identity.clone(), epoch.clone(), handler).unwrap(); + let id = new_operation_id(&epoch); + let submit = json!({ "action": "submit", "ownerEpoch": epoch, "operationId": id, "allowPage": false }); + assert!(request(&identity, submit.clone()).await.is_err()); + let status = request(&identity, json!({ "action": "status", "ownerEpoch": epoch, "operationId": id })).await.unwrap(); + assert_eq!(status["result"]["reloadOperation"]["phase"], "accepted"); + assert_eq!(request(&identity, submit).await.unwrap()["result"]["reloadOperation"]["operationId"], id); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn completed_old_revision_advances_to_current_intent_instead_of_false_success() { + use std::sync::Mutex; + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json")); + let identity = ControlIdentity::new(temp.path(), 9324, &manager).unwrap(); + let epoch = new_owner_epoch(); + let ids = Arc::new(Mutex::new(Vec::new())); + let captured = ids.clone(); + let handler: ControlHandler = Arc::new(move |action| { + let ControlAction::Submit(payload) = action else { anyhow::bail!("测试应直接收到新意图") }; + let mut ids = captured.lock().unwrap(); + ids.push(payload["operationId"].clone()); + let revision = if ids.len() == 1 { "old" } else { "current" }; + Ok(json!({ + "reloadOperation": { "operationId": payload["operationId"], "phase": "applied", "revision": revision }, + "desiredRevision": "current", + })) + }); + let _server = start_owner(identity, epoch, handler).unwrap(); + let inventory = crate::user_scripts::reload_native_scripts(temp.path(), 9324, &manager, true, None).await.unwrap(); + assert_eq!(inventory["reloadOperation"]["phase"], "applied"); + assert_eq!(inventory["reloadOperation"]["revision"], "current"); + let ids = ids.lock().unwrap(); + assert_eq!(ids.len(), 2); + assert_ne!(ids[0], ids[1]); + } + + #[tokio::test] + async fn resuming_an_old_epoch_never_submits_to_replacement_owner() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json")); + let identity = ControlIdentity::new(temp.path(), 9325, &manager).unwrap(); + let old_epoch = new_owner_epoch(); + let old_id = new_operation_id(&old_epoch); + let calls = Arc::new(AtomicUsize::new(0)); + let counted = calls.clone(); + let _server = start_owner(identity, new_owner_epoch(), Arc::new(move |_| { + counted.fetch_add(1, Ordering::SeqCst); + unreachable!("旧 epoch 不得送入新 owner") + })).unwrap(); + let inventory = crate::user_scripts::reload_native_scripts(temp.path(), 9325, &manager, true, Some(&json!({ + "operationId": old_id, "ownerEpoch": old_epoch, "allowPage": true, "phase": "unknown", + }))).await.unwrap(); + assert_eq!(inventory["reloadOperation"]["phase"], "degraded"); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn initial_lost_ack_reuses_identical_bundle_and_source() { + use std::sync::Mutex; + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json")); + let expressions = Arc::new(Mutex::new(Vec::::new())); + let make_evaluator = || { + let expressions = expressions.clone(); + move |expression: String| { + let mut expressions = expressions.lock().unwrap(); + let index = expressions.len(); + expressions.push(expression); + std::future::ready(match index { + 0 | 2 => Ok(json!({ "result": { "result": { "value": json!({ + "documentId": "initial-document", "initialized": false, + }).to_string() } } })), + 1 => Err(anyhow::anyhow!("模拟页面已收到初始化,但 ACK 丢失")), + 3 => Ok(json!({ "result": { "result": { "value": json!({ + "accepted": true, "scripts": {}, + }).to_string() } } })), + _ => unreachable!(), + }) + } + }; + assert!(crate::user_scripts::load_scripts_with_evaluator(&manager, 1, make_evaluator()).await.is_err()); + let generation = crate::extension_backend::backend_generation(&manager); + crate::user_scripts::load_scripts_with_evaluator(&manager, 2, make_evaluator()).await.unwrap(); + assert_eq!(generation, crate::extension_backend::backend_generation(&manager)); + let expressions = expressions.lock().unwrap(); + assert_eq!(expressions[1], expressions[3]); + } + + #[tokio::test] + async fn initialized_document_with_retired_source_is_not_reported_as_loaded() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json")); + let generation = crate::extension_backend::backend_generation(&manager); + let old = crate::extension_backend::bind_backend_document(&manager, generation, "old-document").await.unwrap(); + crate::extension_backend::revoke_backend_document(&manager, generation).await.unwrap(); + let calls = AtomicUsize::new(0); + let error = crate::user_scripts::load_scripts_with_evaluator(&manager, 3, |_| { + assert_eq!(calls.fetch_add(1, Ordering::SeqCst), 0); + std::future::ready(Ok(json!({ "result": { "result": { "value": json!({ + "documentId": "old-document", "initialized": true, "backendSource": old, "scripts": {}, + }).to_string() } } }))) + }).await.unwrap_err(); + assert!(error.to_string().contains("旧代")); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn guarded_initial_replay_is_exact_and_old_document_cannot_bind_source() { + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json")); + let source = json!({ "ownerEpoch": "test-owner", "generation": 1, "documentId": "old-document" }); + let (bundle, _) = manager.build_guarded_initial_bundle("old-document", 12, &source).unwrap(); + let script = format!(r#" +const assert = require("node:assert/strict"); +const vm = require("node:vm"); +const bundle = {}; +function page(documentId) {{ + const window = {{ __codexPlusUserScripts: {{ documentId, scripts: {{}} }}, __codexPlusBackendSource: null }}; + const context = vm.createContext({{ window, document: {{}}, console }}); + return {{ window, context }}; +}} +const first = page("old-document"); +assert.equal(JSON.parse(vm.runInContext(bundle, first.context)).accepted, true); +assert.equal(JSON.parse(vm.runInContext(bundle, first.context)).replayed, true); +first.window.__codexPlusBackendSource.generation = 2; +assert.equal(JSON.parse(vm.runInContext(bundle, first.context)).accepted, false); +const next = page("new-document"); +assert.equal(JSON.parse(vm.runInContext(bundle, next.context)).accepted, false); +assert.equal(next.window.__codexPlusBackendSource, null); +assert.equal(next.window.__codexPlusUserScripts.initialized, undefined); +"#, json!(bundle)); + let output = std::process::Command::new("node").args(["-e", &script]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + } + + #[tokio::test] + async fn no_page_caller_never_submits_unaccepted_page_pending_and_explicit_page_can_resume() { + use std::sync::Mutex; + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json")); + let identity = ControlIdentity::new(temp.path(), 9341, &manager).unwrap(); + let epoch = new_owner_epoch(); + let old_id = new_operation_id(&epoch); + let submissions = Arc::new(Mutex::new(Vec::new())); + let captured = submissions.clone(); + let handler: ControlHandler = Arc::new(move |action| { + match action { + ControlAction::Status(id) | ControlAction::StatusWithVerification(id, _) => Ok(json!({ "reloadOperation": { + "operationId": id, "phase": "not_found", + } })), + ControlAction::Submit(payload) => { + captured.lock().unwrap().push(payload.clone()); + Ok(json!({ "reloadOperation": { + "operationId": payload["operationId"], "phase": "applied", "revision": "current", + }, "desiredRevision": "current" })) + } + } + }); + let _server = start_owner(identity, epoch.clone(), handler).unwrap(); + let resume = json!({ "operationId": old_id, "ownerEpoch": epoch, "allowPage": true, "needsSubmit": true }); + let blocked = crate::user_scripts::reload_native_scripts(temp.path(), 9341, &manager, false, Some(&resume)).await.unwrap(); + assert_eq!(blocked["reloadOperation"]["phase"], "blocked"); + assert_eq!(blocked["reloadOperation"]["operationId"], old_id); + assert!(submissions.lock().unwrap().is_empty()); + let resumed = crate::user_scripts::reload_native_scripts(temp.path(), 9341, &manager, true, None).await.unwrap(); + assert_eq!(resumed["reloadOperation"]["phase"], "applied"); + let submissions = submissions.lock().unwrap(); + assert_eq!(submissions.len(), 1); + assert_eq!(submissions[0]["operationId"], old_id); + assert_eq!(submissions[0]["allowPage"], true); + } + + #[tokio::test] + async fn no_page_caller_queries_accepted_page_operation_then_uses_no_page_for_new_intent() { + use std::sync::Mutex; + let temp = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("scripts.json")); + let identity = ControlIdentity::new(temp.path(), 9342, &manager).unwrap(); + let epoch = new_owner_epoch(); + let old_id = new_operation_id(&epoch); + let old_for_handler = old_id.clone(); + let submissions = Arc::new(Mutex::new(Vec::new())); + let captured = submissions.clone(); + let handler: ControlHandler = Arc::new(move |action| match action { + ControlAction::Status(id) | ControlAction::StatusWithVerification(id, _) => { + assert_eq!(id, old_for_handler); + Ok(json!({ "reloadOperation": { "operationId": id, "phase": "applied", "revision": "old" }, + "desiredRevision": "current" })) + } + ControlAction::Submit(payload) => { + assert_ne!(payload["operationId"], old_for_handler); + assert_eq!(payload["allowPage"], false); + captured.lock().unwrap().push(payload.clone()); + Ok(json!({ "reloadOperation": { "operationId": payload["operationId"], "phase": "applied", "revision": "current" }, + "desiredRevision": "current" })) + } + }); + let _server = start_owner(identity, epoch.clone(), handler).unwrap(); + let resume = json!({ "operationId": old_id, "ownerEpoch": epoch, "allowPage": true, "phase": "unknown" }); + let applied = crate::user_scripts::reload_native_scripts(temp.path(), 9342, &manager, false, Some(&resume)).await.unwrap(); + assert_eq!(applied["reloadOperation"]["phase"], "applied"); + assert_eq!(applied["reloadOperation"]["allowPage"], false); + assert_eq!(submissions.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn skipped_initialization_does_not_scan_or_stage_an_unreadable_enabled_package() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + let package = user.join("broken"); + std::fs::create_dir_all(&package).unwrap(); + // 目录占用 manifest 路径:任何正常包扫描都会触及这个损坏的启用包。 + std::fs::create_dir(package.join("codexpp.json")).unwrap(); + std::fs::write(package.join("renderer.js"), b"\xff\xfe").unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, temp.path().join("scripts.json")); + manager.set_skip_next_start(true).unwrap(); + let calls = AtomicUsize::new(0); + let inventory = crate::user_scripts::load_scripts_with_evaluator(&manager, 4, |expression| { + let response = if calls.fetch_add(1, Ordering::SeqCst) == 0 { + json!({ "documentId": "safe-document", "initialized": false }) + } else { + assert!(expression.contains("runtime.initialMode = \"skipped\"")); + assert!(!expression.contains("renderer.js")); + assert!(!expression.contains(".reload-backend-")); + // 安全启动绑定空来源,精确检查运行结果而非整段脚本中的字段文本。 + let script = format!(r#" +const assert = require("node:assert/strict"); +const vm = require("node:vm"); +const window = {{ __codexPlusUserScripts: {{ documentId: "safe-document", scripts: {{}} }} }}; +const context = vm.createContext({{ window, document: {{}}, console }}); +assert.equal(JSON.parse(vm.runInContext({}, context)).accepted, true); +assert.equal(window.__codexPlusBackendSource.documentId, "safe-document"); +assert.equal(window.__codexPlusBackendSource.revision, null); +assert.equal(window.__codexPlusUserScripts.appliedRevision, null); +assert.equal(window.__codexPlusUserScripts.initialMode, "skipped"); +"#, json!(expression)); + let output = std::process::Command::new("node").args(["-e", &script]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + json!({ "accepted": true, "scripts": {} }) + }; + std::future::ready(Ok(json!({ "result": { "result": { "value": response.to_string() } } }))) + }).await.unwrap(); + assert_eq!(inventory["initialMode"], "skipped"); + assert_eq!(inventory["inventoryDeferred"], true); + assert_eq!(inventory["skip_next_start"], false); + assert_eq!(inventory["scripts"], json!([])); + assert!(!std::fs::read_dir(temp.path()).unwrap().filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with(".reload-backend-"))); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/codex-plus-core/src/user_scripts.rs b/crates/codex-plus-core/src/user_scripts.rs index 777bd96615..d171e5a08b 100644 --- a/crates/codex-plus-core/src/user_scripts.rs +++ b/crates/codex-plus-core/src/user_scripts.rs @@ -1,11 +1,13 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; use anyhow::Context; use serde::Serialize; use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; use crate::script_market::MarketScript; @@ -19,18 +21,19 @@ pub const EXTENSION_API_VERSION: u64 = 1; /// 拓展包的清单文件名。目录里有它,目录才被当成拓展包。 pub const EXTENSION_MANIFEST_FILE: &str = "codexpp.json"; -/// 一次性连接当前 Codex 页面;不增加轮询,也不接管 launcher 的桥接连接。 +/// 通过 launcher 的私有会话通道登记重载;页面繁忙不影响操作接收。 pub async fn reload_live_scripts( debug_port: u16, manager: &UserScriptManager, ) -> anyhow::Result { - let targets = crate::cdp::list_targets(debug_port).await?; - let target = live_runtime_target(&targets)?; - let websocket = target - .web_socket_debugger_url - .as_deref() - .context("Codex renderer has no WebSocket URL")?; - reload_scripts_at(websocket, manager).await + reload_native_scripts(&crate::paths::default_app_state_dir(), debug_port, manager, true, None).await +} + +pub async fn reload_live_scripts_no_page( + debug_port: u16, + manager: &UserScriptManager, +) -> anyhow::Result { + reload_native_scripts(&crate::paths::default_app_state_dir(), debug_port, manager, false, None).await } /// 拓展配置改了以后先试着原地生效:页面里这个拓展的所有实例都用 `ctx.onConfigUpdate` 接住新配置时返回 `"applied"`, @@ -53,39 +56,259 @@ pub async fn reload_scripts_at( websocket: &str, manager: &UserScriptManager, ) -> anyhow::Result { - // 拓展重载后旧后台的方法表、配置都过期了:先全部停掉,下次调用按新代码重起。 - crate::extension_backend::stop_all(manager).await; - apply_scripts_at(websocket, manager, &manager.build_reload_bundle()?).await + let port = reqwest::Url::parse(websocket)?.port().context("重载 WebSocket 缺少明确端口")?; + reload_live_scripts(port, manager).await } -pub async fn load_scripts_at( - websocket: &str, +type PendingReloadSlot = Arc>>; +static PENDING_RELOADS: OnceLock>> = OnceLock::new(); + +/// resume 携带原 operationId/ownerEpoch。未知结果只能向原 owner 查询或幂等重投原编号。 +pub async fn reload_native_scripts( + state_dir: &std::path::Path, + debug_port: u16, manager: &UserScriptManager, + allow_page: bool, + resume: Option<&Value>, ) -> anyhow::Result { - // 页面重新加载:上一个页面的后台不再有人用。 - crate::extension_backend::stop_all(manager).await; - apply_scripts_at(websocket, manager, &manager.build_initial_bundle()?).await + use crate::user_script_control::{self as control, ControlIdentity}; + let identity = ControlIdentity::new(state_dir, debug_port, manager)?; + let slot = PENDING_RELOADS.get_or_init(|| Mutex::new(BTreeMap::new())) + .lock().unwrap().entry(identity.key()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None))).clone(); + let mut pending = slot.lock().await; + if let Some(resume) = resume.filter(|value| value.is_object()) { + let epoch = resume["ownerEpoch"].as_str().context("待确认操作缺少 owner 身份")?; + let id = resume["operationId"].as_str().context("待确认操作缺少编号")?; + anyhow::ensure!(control::operation_issued_at(epoch, id).is_some(), "待确认重载的操作身份无效"); + if let Some(current) = pending.as_ref() { + anyhow::ensure!(current["operationId"] == resume["operationId"] && current["ownerEpoch"] == resume["ownerEpoch"], + "另一个重载仍待确认,请先查询原操作"); + } else { + *pending = Some(resume.clone()); + } + } + if pending.is_none() { + let hello = control::request(&identity, json!({ "action": "hello" })).await?; + anyhow::ensure!(hello.pointer("/result/capabilities/nativeReload") == Some(&Value::Bool(true)) + && (allow_page || supports_no_page_reload(&hello["result"])), "当前启动器不支持可确认的重载"); + let epoch = hello["ownerEpoch"].as_str().context("重载协调器未提供 owner 身份")?; + *pending = Some(json!({ + "operationId": control::new_operation_id(epoch), "ownerEpoch": epoch, + "allowPage": allow_page, "phase": "accepted", "needsSubmit": true, + })); + } + let mut operation = pending.as_ref().unwrap().clone(); + let mut id = operation["operationId"].clone(); + let epoch = operation["ownerEpoch"].clone(); + let mut original_policy = operation["allowPage"].as_bool().context("待确认操作缺少原刷新策略")?; + let mut needs_submit = operation["needsSubmit"] == true; + if !allow_page && original_policy { + // 无刷新调用可以查询已接收的旧操作,但不能替它首次提交或重投允许刷新的策略。 + needs_submit = false; + } + let mut reason = String::new(); + let verification_id = uuid::Uuid::new_v4().simple().to_string(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + loop { + if needs_submit && !allow_page && original_policy { + operation["needsSubmit"] = json!(true); + *pending = Some(operation.clone()); + let mut blocked = operation.clone(); + blocked["phase"] = json!("blocked"); + blocked["active"] = json!(false); + blocked["reason"] = json!("原待确认操作允许页面刷新;本次无刷新调用仅查询,未提交。请显式允许页面刷新后继续原操作。"); + return reload_inventory(manager, blocked); + } + if tokio::time::Instant::now() >= deadline { + operation["phase"] = json!("unknown"); + operation["active"] = json!(true); + operation["needsSubmit"] = json!(needs_submit); + operation["reason"] = json!(if reason.is_empty() { "等待原重载操作确认".to_owned() } else { reason }); + *pending = Some(operation.clone()); + return reload_inventory(manager, operation); + } + let request = json!({ + "action": if needs_submit { "submit" } else { "status" }, + "ownerEpoch": epoch, "operationId": id, "allowPage": original_policy, + "verificationId": verification_id, + }); + // 丢失 submit 的 ACK 不代表未接收;下一次先查原编号,未登记时才幂等重投。 + needs_submit = false; + operation["needsSubmit"] = json!(false); + match control::request(&identity, request).await { + Ok(response) => { + let received = &response["result"]["reloadOperation"]; + if received["operationId"] == id { + operation = received.clone(); + operation["ownerEpoch"] = epoch.clone(); + operation["allowPage"] = json!(original_policy); + let phase = operation["phase"].as_str().unwrap_or_default(); + if matches!(phase, "applied" | "failed" | "blocked" | "degraded" | "rejected") { + if phase == "applied" { + if response["result"]["desiredRevisionPending"] == true { + operation["phase"] = json!("verifying"); + *pending = Some(operation.clone()); + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + if response["result"]["desiredRevision"].is_string() + && response["result"]["desiredRevision"] != operation["revision"] + { + // 旧操作完成时新配置已写入:串行开始当前意图,不把旧清单当新配置已生效。 + let epoch_text = epoch.as_str().context("重载 owner 身份无效")?; + id = json!(control::new_operation_id(epoch_text)); + // 原操作已终结。后续新意图采用本次调用的刷新策略,不继承旧策略。 + original_policy = allow_page; + operation = json!({ "operationId": id, "ownerEpoch": epoch, + "allowPage": original_policy, "phase": "accepted", "needsSubmit": true }); + needs_submit = true; + *pending = Some(operation.clone()); + continue; + } + if !response["result"]["desiredRevision"].is_string() { + operation["phase"] = json!("degraded"); + operation["reason"] = json!("原重载已完成,但当前拓展快照无法确认"); + } + } + *pending = None; + return reload_inventory(manager, operation); + } + needs_submit = phase == "not_found"; + } else { + reason = "重载协调器返回了不匹配的操作编号".to_owned(); + } + } + Err(error) if error.downcast_ref::().is_some() => { + operation["phase"] = json!("degraded"); + operation["active"] = json!(false); + operation["reason"] = json!(format!("{error:#}")); + *pending = None; + return reload_inventory(manager, operation); + } + Err(error) => reason = format!("{error:#}"), + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +fn reload_inventory(manager: &UserScriptManager, operation: Value) -> anyhow::Result { + let runtime = operation.pointer("/inventory/scripts").and_then(Value::as_array).map(|scripts| { + scripts.iter().filter_map(|script| Some((script["key"].as_str()?.to_owned(), json!({ + "status": script["runtimeStatus"], "error": script["error"], + "name": script["name"], "source": script["source"], + })))).collect::>() + }).map(Value::Object); + // 配置始终重新读取;operation里的历史inventory只用于登记运行状态。 + let mut inventory = manager.inventory_with_runtime_status(runtime.as_ref()) + .unwrap_or_else(|_| json!({ "inventoryUnavailable": true })); + inventory["reload_mode"] = operation["mode"].clone(); + inventory["reloadOperation"] = operation; + Ok(inventory) +} + +fn supports_no_page_reload(status: &Value) -> bool { + status.pointer("/capabilities/noPageReload") == Some(&Value::Bool(true)) +} + +struct InitialAttempt { + document: String, + source: Value, + bundle: String, + skip_claim: Option, } +type InitialAttemptSlot = Arc>>; +static INITIAL_ATTEMPTS: OnceLock>> = OnceLock::new(); -async fn apply_scripts_at( +pub async fn load_scripts_at( websocket: &str, manager: &UserScriptManager, - bundle: &str, + sequence: u64, ) -> anyhow::Result { - let response = crate::bridge::evaluate_script(websocket, bundle).await?; - if let Some(exception) = response.pointer("/result/exceptionDetails") { - anyhow::bail!( - "脚本重载失败:{}", - exception - .pointer("/exception/description") - .and_then(Value::as_str) - .unwrap_or("renderer exception") - ); + load_scripts_with_evaluator(manager, sequence, |expression| async move { + crate::bridge::evaluate_script(websocket, &expression).await + }).await +} + +pub async fn load_scripts_with_evaluator( + manager: &UserScriptManager, + sequence: u64, + evaluate: F, +) -> anyhow::Result +where + F: Fn(String) -> Fut, + Fut: std::future::Future>, +{ + let slot = INITIAL_ATTEMPTS.get_or_init(|| Mutex::new(BTreeMap::new())) + .lock().unwrap().entry(manager.config_path().to_path_buf()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None))).clone(); + let mut attempt = slot.lock().await; + let probe = evaluate(UserScriptManager::build_operation_probe("")).await?; + let probe = parse_live_runtime_status_response(&probe)?; + if probe["initialized"] == true || probe["refreshPending"] == true { + if probe["initialized"] == true { + anyhow::ensure!(crate::extension_backend::backend_source_is_current(manager, &probe["backendSource"]), + "页面已初始化,但后台来源属于旧代;未把重连报告为成功"); + if let Ok(claim) = serde_json::from_value::(probe["initialSkipClaim"].clone()) { + manager.consume_skip_start_claim(&claim)?; + } + *attempt = None; + if probe["initialMode"] == "skipped" { + return manager.skipped_initial_inventory(); + } + } + return manager.inventory_with_runtime_status(probe.get("scripts")); + } + let document = probe["documentId"].as_str().context("页面文档标识不可用")?; + if attempt.as_ref().is_some_and(|current| current.document != document) { + *attempt = None; } + if attempt.is_none() { + let (claim_manager, claim_document) = (manager.clone(), document.to_owned()); + let skip_claim = tokio::task::spawn_blocking(move || claim_manager.claim_skip_start(&claim_document)).await??; + // 发送前保存完整尝试;超时后同文档只重送相同 guard/source,不能创建新代。 + let generation = crate::extension_backend::backend_generation(manager); + if !crate::extension_backend::stop_all_if_generation(manager, generation).await { + anyhow::bail!("后台代际发生变化,未执行页面初始化"); + } + let source = if skip_claim.is_some() { + // 安全启动不扫描、复制或读取将被跳过的脚本和后台包。 + crate::extension_backend::bind_backend_document(manager, generation.wrapping_add(1), document).await? + } else { + let snapshot_manager = manager.clone(); + let snapshot = tokio::task::spawn_blocking(move || snapshot_manager.reload_snapshot()).await?; + match snapshot { + Ok((_, revision)) => { + let staging_manager = manager.clone(); + let staged = tokio::task::spawn_blocking(move || staging_manager.stage_backend_snapshot(&revision)).await??; + crate::extension_backend::bind_backend_snapshot(manager, generation.wrapping_add(1), document, staged).await? + } + // 冷启动保留原来的宽松加载;没有严格 revision 的文档不能通过 owner 的完成核验。 + Err(_) => crate::extension_backend::bind_backend_document(manager, generation.wrapping_add(1), document).await?, + } + }; + let (bundle_manager, bundle_document, bundle_source) = (manager.clone(), document.to_owned(), source.clone()); + let (bundle, skip_claim) = tokio::task::spawn_blocking(move || { + bundle_manager.build_guarded_initial_bundle_with_claim(&bundle_document, sequence, &bundle_source, skip_claim) + }).await??; + *attempt = Some(InitialAttempt { document: document.to_owned(), source, bundle, skip_claim }); + } + let current = attempt.as_ref().unwrap(); + anyhow::ensure!(crate::extension_backend::backend_source_is_current(manager, ¤t.source), + "页面初始化仍待确认,但其后台代际已变化;未重送旧初始化"); + let response = evaluate(current.bundle.clone()).await?; let result = parse_live_runtime_status_response(&response)?; - let mut inventory = manager.inventory_with_runtime_status(result.get("scripts"))?; - inventory["reload_mode"] = result["mode"].clone(); - Ok(inventory) + if result["accepted"] != true { + anyhow::bail!("页面初始化已被较新的操作取代"); + } + if let Some(claim) = ¤t.skip_claim { + manager.consume_skip_start_claim(claim)?; + } + let skipped = current.skip_claim.is_some(); + *attempt = None; + if skipped { + return manager.skipped_initial_inventory(); + } + manager.inventory_with_runtime_status(result.get("scripts")) } /// Query the live renderer-side user-script registry through a one-shot CDP @@ -131,6 +354,8 @@ pub struct UserScriptConfig { pub claude_scripts: BTreeMap, #[serde(default, skip_serializing_if = "is_false")] pub skip_next_start: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_start_marker: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub market: BTreeMap, /// 拓展包的配置值,键是脚本 key(`user:`)。只存清单 schema 里声明过的键。 @@ -145,6 +370,7 @@ impl Default for UserScriptConfig { scripts: BTreeMap::new(), claude_scripts: BTreeMap::new(), skip_next_start: false, + skip_start_marker: None, market: BTreeMap::new(), configs: BTreeMap::new(), } @@ -155,6 +381,103 @@ fn is_false(value: &bool) -> bool { !*value } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SkipStartMarker { + pub id: String, + pub document_id: Option, + pub acknowledged: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)] +pub struct SkipStartClaim { + pub marker_id: String, + pub document_id: String, +} + +#[derive(Debug, PartialEq, Eq)] +struct BackendSnapshotBytes { + extension: String, + entry: PathBuf, + config: Value, + files: Vec<(String, Vec)>, +} + +#[derive(Debug, PartialEq, Eq)] +struct ReloadSnapshotData { + bundle: String, + revision: String, + backends: BTreeMap, + services: Vec<(String, String)>, +} + +#[derive(Debug, Clone)] +pub(crate) struct StagedBackendEntry { + pub extension: String, + pub script: PathBuf, + pub config: Value, +} + +#[derive(Debug)] +struct StagedBackendFiles { + files: Vec, + directories: Vec, +} + +impl Drop for StagedBackendFiles { + fn drop(&mut self) { + let files = std::mem::take(&mut self.files); + let directories = std::mem::take(&mut self.directories); + if files.is_empty() && directories.is_empty() { + return; + } + // 释放代际时可能仍持有后台注册锁;逐文件 I/O 不能占住 launcher 接收线程。 + let cleanup = move || Self::cleanup(files, directories); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn_blocking(cleanup); + } else { + cleanup(); + } + } +} + +impl StagedBackendFiles { + fn cleanup(files: Vec, directories: Vec) { + let mut failed = 0; + for path in files.iter().rev() { + // Windows 不允许直接删除只读文件;这里只恢复本次记录的单个快照文件。 + #[cfg(windows)] + if let Ok(metadata) = fs::symlink_metadata(path) { + if !metadata.file_type().is_symlink() { + let mut permissions = metadata.permissions(); + permissions.set_readonly(false); + let _ = fs::set_permissions(path, permissions); + } + } + if fs::remove_file(path).is_err() { failed += 1; } + } + for path in directories.iter().rev() { + if fs::remove_dir(path).is_err() { failed += 1; } + } + if failed > 0 { + let _ = crate::diagnostic_log::append_diagnostic_log("user_scripts.snapshot_cleanup_incomplete", + json!({ "directory": directories.first(), "failedEntries": failed })); + } + } +} + +/// 只在实际应用时创建,探测 revision 不写磁盘。最后一个后台/页面代际引用释放后逐文件清理。 +#[derive(Debug, Clone)] +pub struct StagedBackendSnapshot { + pub(crate) revision: String, + pub(crate) entries: BTreeMap, + pub(crate) services: Vec<(String, String)>, + _files: Arc, +} + +impl StagedBackendSnapshot { + pub fn revision(&self) -> &str { &self.revision } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MarketScriptInstall { pub id: String, @@ -225,6 +548,7 @@ impl UserScriptManager { /// 官方拓展包的一次性迁移:配置与开关里都还没有它时写入;返回是否写了。 fn migrate_bundled(&self, key: &str, values: Map, enabled: bool) -> anyhow::Result { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut config = self.load_config_unlocked(); if config.configs.contains_key(key) || config.scripts.contains_key(key) { return Ok(false); @@ -286,9 +610,23 @@ impl UserScriptManager { pub fn save_config(&self, config: &UserScriptConfig) -> anyhow::Result<()> { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; self.save_config_unlocked(config) } + fn config_file_lock(&self) -> anyhow::Result { + if let Some(parent) = self.config_path.parent() { + fs::create_dir_all(parent)?; + } + // 锁持有稳定的旁路文件,不锁会被 atomic_write 替换的配置 inode。 + let mut name = self.config_path.as_os_str().to_os_string(); + name.push(".lock"); + let file = fs::OpenOptions::new().create(true).truncate(false).read(true).write(true) + .open(PathBuf::from(name)).context("打开用户脚本跨进程锁失败")?; + fs2::FileExt::lock_exclusive(&file).context("获取用户脚本跨进程锁失败")?; + Ok(file) + } + fn save_config_unlocked(&self, config: &UserScriptConfig) -> anyhow::Result<()> { crate::extension_backend::invalidate_scan_cache(); if let Some(parent) = self.config_path.parent() { @@ -307,6 +645,7 @@ impl UserScriptManager { pub fn set_global_enabled(&self, enabled: bool) -> anyhow::Result { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut config = self.load_config_unlocked(); config.enabled = enabled; self.save_config_unlocked(&config)?; @@ -315,27 +654,78 @@ impl UserScriptManager { pub fn set_skip_next_start(&self, skip: bool) -> anyhow::Result { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut config = self.load_config_unlocked(); config.skip_next_start = skip; + config.skip_start_marker = skip.then(|| SkipStartMarker { + id: uuid::Uuid::new_v4().to_string(), document_id: None, acknowledged: false, + }); self.save_config_unlocked(&config)?; Ok(config) } + /// 在发送页面命令前持久绑定;重试沿用标识,旧页面的不明结果不能跳过第二个页面。 + pub fn claim_skip_start(&self, document_id: &str) -> anyhow::Result> { + anyhow::ensure!(!document_id.is_empty(), "安全启动缺少页面文档标识"); + let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; + let mut config = self.load_reload_config()?; + if let Some(marker) = &config.skip_start_marker { + if marker.document_id.as_deref() == Some(document_id) { + return Ok(Some(SkipStartClaim { marker_id: marker.id.clone(), document_id: document_id.to_string() })); + } + if marker.document_id.is_some() && !marker.acknowledged { + anyhow::bail!("上一次安全启动的页面结果尚未确认,请重新设置“下次启动跳过脚本”后重试"); + } + } + if !config.skip_next_start { + return Ok(None); + } + // 兼容旧版 bool 配置,第一次领取时才赋予持久标识。 + let marker = config.skip_start_marker.get_or_insert_with(|| SkipStartMarker { + id: uuid::Uuid::new_v4().to_string(), document_id: None, acknowledged: false, + }); + marker.document_id = Some(document_id.to_string()); + let claim = SkipStartClaim { marker_id: marker.id.clone(), document_id: document_id.to_string() }; + self.save_config_unlocked(&config)?; + Ok(Some(claim)) + } + + /// 只确认最初领取的标记;迟到 ACK 不能消费用户之后设置的新标记。 + pub fn consume_skip_start_claim(&self, claim: &SkipStartClaim) -> anyhow::Result { + let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; + let mut config = self.load_reload_config()?; + let Some(marker) = config.skip_start_marker.as_mut().filter(|marker| { + marker.id == claim.marker_id && marker.document_id.as_deref() == Some(claim.document_id.as_str()) + }) else { return Ok(false); }; + if marker.acknowledged { return Ok(true); } + marker.acknowledged = true; + config.skip_next_start = false; + self.save_config_unlocked(&config)?; + Ok(true) + } + /// Consume the one-shot safe-start marker before building the first-page bundle. /// The marker is cleared only after the updated configuration is persisted. pub fn consume_skip_next_start(&self) -> anyhow::Result { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut config = self.load_config_unlocked(); if !config.skip_next_start { return Ok(false); } + anyhow::ensure!(!config.skip_start_marker.as_ref().is_some_and(|marker| marker.document_id.is_some()), + "已绑定页面的安全启动标记只能按页面确认消费"); config.skip_next_start = false; + config.skip_start_marker = None; self.save_config_unlocked(&config)?; Ok(true) } pub fn set_script_enabled(&self, key: &str, enabled: bool) -> anyhow::Result { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut config = self.load_config_unlocked(); // 有设置字段的内置拓展:开关就是那个设置项,写设置,不在用户脚本配置里另记一份 // (顺手清掉旧版本记下的停用标记,免得它在没有入口的地方一直压着功能)。 @@ -361,6 +751,7 @@ impl UserScriptManager { /// Claude 只接受显式适配的拓展包;不改 Codex 的单项开关或设置字段。 pub fn set_claude_script_enabled(&self, key: &str, enabled: bool) -> anyhow::Result { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut config = self.load_config_unlocked(); let script = self.scan_script_files(&config)? .into_iter() @@ -390,6 +781,8 @@ impl UserScriptManager { key: &str, trash_dir: &std::path::Path, ) -> anyhow::Result { + let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let Some(file_name) = key.strip_prefix("user:").filter(|value| !value.is_empty()) else { anyhow::bail!("only user scripts can be deleted"); }; @@ -430,7 +823,6 @@ impl UserScriptManager { } } - let _guard = self.config_lock.lock().unwrap(); let mut config = self.load_config_unlocked(); config.scripts.remove(key); config.claude_scripts.remove(key); @@ -445,6 +837,7 @@ impl UserScriptManager { pub fn record_market_install(&self, script: &MarketScript) -> anyhow::Result { let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut config = self.load_config_unlocked(); let key = format!("user:{}", market_script_filename(&script.id)); config.scripts.entry(key.clone()).or_insert(true); @@ -648,6 +1041,7 @@ impl UserScriptManager { stored.insert(name.clone(), value.clone()); } let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; let mut latest = self.load_config_unlocked(); latest.configs.insert(key.to_string(), stored.clone()); self.save_config_unlocked(&latest)?; @@ -1005,6 +1399,8 @@ impl UserScriptManager { /// 清单最后写,写入中途失败时旧清单仍在。不删除包里已有的其他文件。 /// 返回脚本 key 与清单校验结果(`error` 非空表示包写好了但不会被加载)。 pub fn write_extension_package(&self, write: &ExtensionWrite) -> anyhow::Result { + let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; validate_extension_id(&write.id)?; crate::extension_backend::invalidate_scan_cache(); let file_count = write.files.len() + write.assets.len(); @@ -1424,6 +1820,7 @@ impl UserScriptManager { } else { self.build_enabled_bundle()? }; + let revision = bundle_revision(&bundle); Ok(format!( r#"(() => {{ {RUNTIME_SCRIPT} @@ -1431,6 +1828,8 @@ impl UserScriptManager { if (!runtime.initialized && !runtime.refreshPending && !Object.keys(runtime.scripts).length) {{ runtime.initialized = true; {bundle} + runtime.appliedRevision = {revision}; + runtime.initialMode = "{mode}"; }} return JSON.stringify({{ mode: "{mode}", scripts: runtime.scripts }}); }})()"#, @@ -1438,10 +1837,352 @@ impl UserScriptManager { "skipped" } else { "scripts" - } + }, + revision = json!(revision), )) } + /// 初始载入只在页面确认后消费跳过标记,迟到的旧载入受文档和序号约束。 + pub fn build_guarded_initial_bundle(&self, document_id: &str, sequence: u64, backend_source: &Value) -> anyhow::Result<(String, Option)> { + let skip_claim = self.claim_skip_start(document_id)?; + self.build_guarded_initial_bundle_with_claim(document_id, sequence, backend_source, skip_claim) + } + + fn skipped_initial_inventory(&self) -> anyhow::Result { + let _guard = self.config_lock.lock().unwrap(); + let config = self.load_reload_config()?; + Ok(json!({ + "enabled": config.enabled, "skip_next_start": config.skip_next_start, + "scripts": [], "initialMode": "skipped", "inventoryDeferred": true, + "builtin_dir": self.builtin_dir, "user_dir": self.user_dir, + })) + } + + fn build_guarded_initial_bundle_with_claim( + &self, document_id: &str, sequence: u64, backend_source: &Value, skip_claim: Option, + ) -> anyhow::Result<(String, Option)> { + let skip = skip_claim.is_some(); + let (bundle, revision) = if skip { + (String::new(), None) + } else { + match self.reload_snapshot() { + Ok((bundle, revision)) => { + if let Some(expected) = backend_source.get("revision").and_then(Value::as_str) { + anyhow::ensure!(expected == revision, "页面 bundle 与已绑定后台快照不一致,未发送初始化"); + } + (bundle, Some(revision)) + } + Err(error) if backend_source.get("revision").and_then(Value::as_str).is_some() => { + return Err(error.context("已绑定后台快照,但页面来源读取失败;未发送初始化")); + } + // 尚未绑定严格快照的冷启动保留损坏包诊断,不冒充已核验版本。 + Err(_) => (self.build_enabled_bundle()?, None), + } + }; + Ok((format!( + r#"(() => {{ + {RUNTIME_SCRIPT} + const runtime = window.__codexPlusUserScripts; + const skipClaim = {skip_claim}; + if (runtime.documentId === {document} && runtime.initialized && skipClaim && + runtime.initialSkipClaim?.marker_id === skipClaim.marker_id && + runtime.initialSkipClaim?.document_id === skipClaim.document_id) {{ + return JSON.stringify({{ accepted: true, replayed: true, scripts: runtime.scripts }}); + }} + const incomingSource = {backend_source}; + const currentSource = window.__codexPlusBackendSource; + if (!skipClaim && runtime.documentId === {document} && runtime.initialized && + runtime.reloadSequence === {sequence} && runtime.appliedRevision === {revision} && + currentSource && incomingSource && + currentSource.ownerEpoch === incomingSource.ownerEpoch && + currentSource.generation === incomingSource.generation && + currentSource.documentId === incomingSource.documentId && + currentSource.revision === incomingSource.revision) {{ + return JSON.stringify({{ accepted: true, replayed: true, scripts: runtime.scripts }}); + }} + if (runtime.documentId !== {document} || runtime.initialized || runtime.refreshPending || + {sequence} <= (runtime.reloadSequence || 0) || Object.keys(runtime.scripts).length) {{ + return JSON.stringify({{ accepted: false, scripts: runtime.scripts }}); + }} + runtime.reloadSequence = {sequence}; + runtime.initialized = true; + runtime.initialSkipClaim = skipClaim; + window.__codexPlusBackendSource = {backend_source}; + window.__codexPlusBackendServices = []; + try {{ window.__codexPlusSyncBackendServices?.(); }} catch {{}} + {bundle} + runtime.appliedRevision = {revision}; + runtime.initialMode = {mode}; + runtime.initialError = null; + return JSON.stringify({{ accepted: true, scripts: runtime.scripts }}); +}})()"#, + document = json!(document_id), revision = json!(revision), backend_source = json!(backend_source), + skip_claim = json!(skip_claim), + mode = json!(if skip { "skipped" } else { "scripts" }), + ), skip_claim)) + } + + /// 内部写入共同持锁。外部编辑不遵守此锁,因此连续读取两次并拒绝可观察到的不稳定版本。 + pub fn reload_snapshot(&self) -> anyhow::Result<(String, String)> { + let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; + self.reload_snapshot_checked(|| {}) + } + + fn reload_snapshot_checked(&self, after_first: impl FnOnce()) -> anyhow::Result<(String, String)> { + let data = self.reload_snapshot_data_checked(after_first)?; + Ok((data.bundle, data.revision)) + } + + fn reload_snapshot_data_checked(&self, after_first: impl FnOnce()) -> anyhow::Result { + let before = self.reload_config_bytes()?; + let first = self.reload_snapshot_unlocked()?; + after_first(); + let between = self.reload_config_bytes()?; + let second = self.reload_snapshot_unlocked()?; + let after = self.reload_config_bytes()?; + anyhow::ensure!(before == between && between == after && first == second, + "用户脚本或配置在读取快照时发生变化,未应用混合版本,请等待编辑完成后重试"); + Ok(first) + } + + fn reload_config_bytes(&self) -> anyhow::Result>> { + match fs::read(&self.config_path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("读取用户脚本配置失败"), + } + } + + fn reload_snapshot_unlocked(&self) -> anyhow::Result { + let config = self.load_reload_config()?; + let mut scripts = if config.enabled { self.scan_script_files_strict(&config)? } else { Vec::new() }; + let disabled = crate::settings_catalog::builtin_extension_defs() + .iter() + .filter(|def| def.setting.is_none()) + .map(|def| def.id.as_str()) + .filter(|id| config.scripts.get(&builtin_extension_key(id)) == Some(&false)) + .collect::>(); + let mut replaced_by = Map::new(); + if config.enabled { + for script in scripts.iter_mut().filter(|script| script.enabled) { + if let Some(package) = &mut script.package { + if let Some(error) = &package.error { + anyhow::bail!("读取拓展 {} 失败: {}", script.key, error); + } + let root = package.root.as_ref().context("拓展包缺少根目录")?; + package.locales = load_extension_locales(root, true) + .with_context(|| format!("读取拓展 {} 语言文件失败", script.key))?; + (package.assets, package.skipped_assets) = load_extension_assets(root, true) + .with_context(|| format!("读取拓展 {} 资源失败", script.key))?; + for id in &package.replaces { + replaced_by.entry(id.clone()).or_insert_with(|| Value::String(script.key.clone())); + } + } + } + } + let builtin_state = format!( + "window.__codexPlusBuiltinExtensionState = {}; try {{ window.__codexPlusSyncBuiltinExtensions?.(); }} catch {{}}", + json!({ "disabled": disabled, "replacedBy": replaced_by }) + ); + let mut blocks = vec![builtin_state]; + let mut package_sources = Vec::new(); + let mut backends = BTreeMap::new(); + let mut services: Vec<(String, String)> = Vec::new(); + if config.enabled { + blocks.push(RUNTIME_SCRIPT.to_string()); + for script in scripts.iter().filter(|script| script.enabled) { + if let Some(package) = &script.package { + let root = package.root.as_deref().context("拓展包缺少根目录")?; + let files = snapshot_package_sources(root, &script.key)?; + package_sources.extend(files.iter().cloned()); + if let Some(backend) = &package.backend { + anyhow::ensure!(backend.is_file(), "拓展 {} 后台入口不是文件", script.key); + let prefix = format!("{}\0", script.key); + backends.insert(script.key.clone(), BackendSnapshotBytes { + extension: package.id.clone(), + entry: backend.strip_prefix(root)?.to_path_buf(), + config: package.config.clone(), + files: files.into_iter().map(|(name, bytes)| (name.strip_prefix(&prefix).unwrap_or(&name).to_string(), bytes)).collect(), + }); + for name in &package.backend_provides { + if !services.iter().any(|(existing, _)| existing == name) { + services.push((name.clone(), script.key.clone())); + } + } + } + } + } + blocks.push(format!( + "window.__codexPlusBackendServices = {}; try {{ window.__codexPlusSyncBackendServices?.(); }} catch {{}}", + Value::Array(services.iter().map(|(name, key)| json!({ "name": name, "key": key })).collect()) + )); + for script in scripts.iter().filter(|script| script.enabled) { + let source = match &script.package { + Some(package) => package.renderer.iter() + .map(|path| fs::read_to_string(path).with_context(|| format!("读取拓展 {} 页面入口失败", script.key))) + .collect::>>()?.join("\n;\n"), + None => fs::read_to_string(&script.path) + .with_context(|| format!("读取用户脚本 {} 失败", script.key))?, + }; + blocks.push(wrap_script(script, &source)); + } + } + let bundle = blocks.join("\n"); + let revision = snapshot_revision(&bundle, &package_sources); + Ok(ReloadSnapshotData { bundle, revision, backends, services }) + } + + /// 将同一份已验证字节用于实际 Node 启动,避免 probe 与 import 原路径之间的 TOCTOU。 + pub fn stage_backend_snapshot(&self, expected_revision: &str) -> anyhow::Result { + let data = { + let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; + self.reload_snapshot_data_checked(|| {})? + }; + anyhow::ensure!(data.revision == expected_revision, "拓展快照已变化,未创建旧版本的后台代际"); + let mut staged = StagedBackendFiles { files: Vec::new(), directories: Vec::new() }; + let mut entries = BTreeMap::new(); + if !data.backends.is_empty() { + let parent = self.config_path.parent().context("用户脚本配置缺少父目录")?; + // 同一父目录可能托管多个配置,保留数只约束本配置;创建配额与跨进程写入共同串行。 + let bucket = format!("{:x}", Sha256::digest(self.config_path.file_name().context("配置路径缺少文件名")?.to_string_lossy().as_bytes())); + let prefix = format!(".reload-backend-{}-", &bucket[..16]); + let directory = { + let _guard = self.config_lock.lock().unwrap(); + let _file_guard = self.config_file_lock()?; + let retained = fs::read_dir(parent)?.filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)).take(4).count(); + anyhow::ensure!(retained < 4, "后台快照目录尚有未释放的活动代或清理失败记录,未继续创建副本"); + let directory = parent.join(format!("{prefix}{}", uuid::Uuid::new_v4().simple())); + crate::user_script_control::create_private_directory(&directory)?; + directory + }; + staged.directories.push(directory.clone()); + for (index, (key, backend)) in data.backends.into_iter().enumerate() { + let root = directory.join(format!("package-{index}")); + fs::create_dir(&root)?; + staged.directories.push(root.clone()); + let mut known_directories = BTreeSet::from([root.clone()]); + for (relative, bytes) in backend.files { + validate_package_relative_path(&relative)?; + let path = root.join(&relative); + let mut pending = Vec::new(); + let mut ancestor = path.parent().context("后台快照路径缺少父目录")?; + while !known_directories.contains(ancestor) { + anyhow::ensure!(ancestor.starts_with(&root), "后台快照路径超出本次目录"); + pending.push(ancestor.to_path_buf()); + ancestor = ancestor.parent().context("后台快照路径无效")?; + } + for parent in pending.into_iter().rev() { + fs::create_dir(&parent)?; + known_directories.insert(parent.clone()); + staged.directories.push(parent); + } + use std::io::Write; + let mut file = fs::OpenOptions::new().create_new(true).write(true).open(&path)?; + staged.files.push(path.clone()); + file.write_all(&bytes)?; + let mut permissions = file.metadata()?.permissions(); + permissions.set_readonly(true); + file.set_permissions(permissions)?; + } + let script = root.join(&backend.entry); + anyhow::ensure!(script.is_file(), "后台入口不在已复制的快照中: {}", backend.entry.display()); + entries.insert(key, StagedBackendEntry { extension: backend.extension, script, config: backend.config }); + } + } + Ok(StagedBackendSnapshot { + revision: data.revision, entries, services: data.services, _files: Arc::new(staged), + }) + } + + fn load_reload_config(&self) -> anyhow::Result { + let text = match fs::read_to_string(&self.config_path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(UserScriptConfig::default()), + Err(error) => return Err(error).context("读取用户脚本配置失败"), + }; + let value: Value = serde_json::from_str(&text).context("用户脚本配置不是合法 JSON")?; + let raw = value.as_object().context("用户脚本配置顶层必须是对象")?; + for key in ["enabled", "skip_next_start"] { + if raw.get(key).is_some_and(|value| !value.is_boolean()) { + anyhow::bail!("用户脚本配置 {key} 必须是布尔值"); + } + } + if let Some(marker) = raw.get("skip_start_marker").filter(|value| !value.is_null()) { + anyhow::ensure!(skip_start_marker_from_value(marker).is_some(), "用户脚本配置 skip_start_marker 无效"); + } + for key in ["scripts", "claude_scripts"] { + if let Some(value) = raw.get(key) { + let map = value.as_object().with_context(|| format!("用户脚本配置 {key} 必须是对象"))?; + anyhow::ensure!(map.values().all(Value::is_boolean), "用户脚本配置 {key} 的值必须是布尔值"); + } + } + if let Some(value) = raw.get("configs") { + let map = value.as_object().context("用户脚本配置 configs 必须是对象")?; + anyhow::ensure!(map.values().all(Value::is_object), "用户脚本配置 configs 的值必须是对象"); + } + if let Some(value) = raw.get("market") { + let map = value.as_object().context("用户脚本配置 market 必须是对象")?; + anyhow::ensure!(map.values().all(|value| market_install_from_value(value).is_some()), "用户脚本配置 market 包含无效记录"); + } + Ok(config_from_object(raw)) + } + + pub fn build_prepare_operation(id: &str, document_id: &str, revision: &str, allow_page: bool, sequence: u64) -> String { + format!( + r#"(() => {{ + {RUNTIME_SCRIPT} + return JSON.stringify(window.__codexPlusUserScripts.prepareOperation({}, {}, {}, {}, {})); +}})()"#, + json!(id), json!(document_id), json!(revision), json!(allow_page), sequence, + ) + } + + pub fn build_operation_probe(id: &str) -> String { + format!( + r##"(() => {{ + {RUNTIME_SCRIPT} + const runtime = window.__codexPlusUserScripts; + const readiness = runtime.nativePageReadiness(); + return JSON.stringify({{ + documentId: runtime.documentId, + initialized: !!runtime.initialized, + refreshPending: !!runtime.refreshPending, + operation: runtime.reloadOperations[{}] || null, + revision: runtime.appliedRevision || null, + initialMode: runtime.initialMode || null, + initialSkipClaim: runtime.initialSkipClaim || null, + backendSource: window.__codexPlusBackendSource || null, + initialError: runtime.initialError || null, + ready: readiness.ready, + pageReadiness: readiness, + cleanupSupported: Object.values(runtime.scripts).every(script => !!script.cleanups?.length), + scripts: runtime.scripts + }}); +}})()"##, + json!(id) + ) + } + + pub fn build_apply_operation(id: &str, document_id: &str, revision: &str, bundle: &str, backend_source: &Value) -> String { + format!( + r#"(() => {{ + {RUNTIME_SCRIPT} + const runtime = window.__codexPlusUserScripts; + const operation = runtime.applyOperation({}, {}, {}, () => {{ + window.__codexPlusBackendSource = {}; + window.__codexPlusBackendServices = []; + try {{ window.__codexPlusSyncBackendServices?.(); }} catch {{}} + {bundle} + }}); + return JSON.stringify({{ operation, scripts: runtime.scripts }}); +}})()"#, + json!(id), json!(document_id), json!(revision), json!(backend_source), + ) + } + /// 即便全部禁用也必须清理上一次运行的脚本。 pub fn build_reload_bundle(&self) -> anyhow::Result { let bundle = self.build_enabled_bundle()?; @@ -1464,7 +2205,7 @@ impl UserScriptManager { let runtime_scripts = runtime_status .and_then(|value| value.get("scripts").or(Some(value))) .and_then(Value::as_object); - Ok(self + let mut result: Vec = self .scan_script_files(config)? .into_iter() .map(|script| { @@ -1481,6 +2222,21 @@ impl UserScriptManager { "not_loaded" }; let live = runtime_scripts.and_then(|items| items.get(&script.key)); + let configured_enabled = config.enabled && script.enabled; + let runtime_state = if runtime_scripts.is_none() { + "unknown" + } else { + live.and_then(|item| item.get("status").and_then(Value::as_str)) + .unwrap_or("not_loaded") + }; + // 配置和页面登记是不同的事实;缺少页面快照时不能推断已经卸载。 + let apply_state = match (configured_enabled, runtime_state) { + (_, "unknown") => "unknown", + // Registry membership does not establish which bundle revision ran. + (false, "not_loaded") | (true, "loaded") => "unknown", + (true, "failed") => "failed", + _ => "pending", + }; let status = if fallback_status == "disabled" || package_error.is_some() { fallback_status } else { @@ -1502,6 +2258,9 @@ impl UserScriptManager { "kind": if script.package.is_some() { "package" } else { "script" }, "package": script.package.as_ref().map(|package| package.summary()), "enabled": script.enabled, + "configuredEnabled": configured_enabled, + "runtimeStatus": runtime_state, + "applyState": apply_state, "claude_enabled": script.package.as_ref() .filter(|package| package.claude_desktop.is_some()) .map(|_| config.claude_scripts.get(&script.key).copied().unwrap_or(true)), @@ -1515,10 +2274,39 @@ impl UserScriptManager { "homepage": market.as_ref().map(|item| item.homepage.as_str()).unwrap_or("") }) }) - .collect()) + .collect(); + if let Some(runtime) = runtime_scripts { + for (key, live) in runtime { + if result.iter().any(|item| item["key"].as_str() == Some(key.as_str())) { + continue; + } + result.push(json!({ + "key": key, + "name": live["name"].as_str().unwrap_or(key), + "source": live["source"].as_str().unwrap_or("user"), + "kind": "script", + "enabled": false, + "configuredEnabled": false, + "runtimeStatus": live["status"].as_str().unwrap_or("unknown"), + "applyState": "pending", + "status": "disabled", + "error": "", + "missing": true + })); + } + } + Ok(result) } fn scan_script_files(&self, config: &UserScriptConfig) -> anyhow::Result> { + self.scan_script_files_with_mode(config, false) + } + + fn scan_script_files_strict(&self, config: &UserScriptConfig) -> anyhow::Result> { + self.scan_script_files_with_mode(config, true) + } + + fn scan_script_files_with_mode(&self, config: &UserScriptConfig, strict: bool) -> anyhow::Result> { fs::create_dir_all(&self.user_dir).with_context(|| { format!( "failed to create user scripts directory {}", @@ -1526,11 +2314,11 @@ impl UserScriptManager { ) })?; let mut scripts = Vec::new(); - self.append_scripts("builtin", &self.builtin_dir, config, &mut scripts)?; + self.append_scripts("builtin", &self.builtin_dir, config, &mut scripts, strict)?; if let Some(bundled) = &self.bundled_dir { - self.append_scripts(crate::bundled_extensions::BUNDLED_SOURCE, bundled, config, &mut scripts)?; + self.append_scripts(crate::bundled_extensions::BUNDLED_SOURCE, bundled, config, &mut scripts, strict)?; } - self.append_scripts("user", &self.user_dir, config, &mut scripts)?; + self.append_scripts("user", &self.user_dir, config, &mut scripts, strict)?; // 第三方拓展包的 replaces 写了改成官方拓展包的原内置拓展 id(如 usage-statusline): // 它启用时对应的官方拓展包让位,停用时回来,和顶替内置拓展的效果一致。 if config.enabled { @@ -1564,24 +2352,43 @@ impl UserScriptManager { directory: &std::path::Path, config: &UserScriptConfig, scripts: &mut Vec, + strict: bool, ) -> anyhow::Result<()> { - let Ok(entries) = fs::read_dir(directory) else { - return Ok(()); + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if !strict || error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error).with_context(|| format!("读取脚本目录 {} 失败", directory.display())), }; // 单文件脚本是 `*.js`;拓展包是带 codexpp.json 的子目录。目录名以 .js 结尾的 // 不当包处理,免得和同名脚本文件抢同一个 key。 - let mut paths = entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - let is_js = path.extension().and_then(|value| value.to_str()) == Some("js"); - if path.is_dir() { - !is_js && path.join(EXTENSION_MANIFEST_FILE).is_file() - } else { - is_js + let mut paths = Vec::new(); + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) if !strict => { let _ = error; continue; } + Err(error) => return Err(error).with_context(|| format!("枚举脚本目录 {} 失败", directory.display())), + }; + let path = entry.path(); + let is_js = path.extension().and_then(|value| value.to_str()) == Some("js"); + if strict { + let metadata = fs::metadata(&path).with_context(|| format!("读取脚本路径 {} 失败", path.display()))?; + if metadata.is_dir() { + if !is_js { + let manifest = path.join(EXTENSION_MANIFEST_FILE); + match fs::metadata(&manifest) { + Ok(meta) if meta.is_file() => paths.push(path), + Ok(_) => anyhow::bail!("拓展清单不是文件: {}", manifest.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).with_context(|| format!("读取拓展清单 {} 失败", manifest.display())), + } + } + } else if is_js { + paths.push(path); } - }) - .collect::>(); + } else if (path.is_dir() && !is_js && path.join(EXTENSION_MANIFEST_FILE).is_file()) || (!path.is_dir() && is_js) { + paths.push(path); + } + } paths.sort_by_key(|path| { path.file_name() .map(|name| name.to_string_lossy().to_lowercase()) @@ -1616,6 +2423,98 @@ impl UserScriptManager { } } +fn bundle_revision(bundle: &str) -> String { + snapshot_revision(bundle, &[]) +} + +const RELOAD_PACKAGE_MAX_FILES: usize = 4096; +const RELOAD_PACKAGE_MAX_ENTRIES: usize = 8192; +const RELOAD_PACKAGE_MAX_BYTES: usize = 64 * 1024 * 1024; +const RELOAD_PACKAGE_MAX_FILE_BYTES: usize = 16 * 1024 * 1024; + +/// 包内模块可以动态引用文件,不能只读取 backend.script 入口或猜测 import 语法。 +/// 纳入整个包(含 node_modules),.git 作为版本控制元数据排除;包外运行依赖不在快照契约内。 +fn snapshot_package_sources(root: &std::path::Path, key: &str) -> anyhow::Result)>> { + struct Budget { entries: usize, bytes: usize } + fn visit( + root: &std::path::Path, + path: &std::path::Path, + key: &str, + ancestors: &mut BTreeSet, + sources: &mut BTreeMap>, + budget: &mut Budget, + ) -> anyhow::Result<()> { + use std::io::Read; + budget.entries += 1; + anyhow::ensure!(budget.entries <= RELOAD_PACKAGE_MAX_ENTRIES, "拓展 {key} 的快照目录条目超过 {RELOAD_PACKAGE_MAX_ENTRIES},未执行热重载"); + let resolved = path.canonicalize().with_context(|| format!("解析拓展 {key} 快照路径失败: {}", path.display()))?; + anyhow::ensure!(resolved.starts_with(root), "拓展 {key} 的依赖超出包目录: {}", path.display()); + let metadata = fs::metadata(&resolved)?; + if metadata.is_dir() { + anyhow::ensure!(ancestors.len() < 64 && ancestors.insert(resolved.clone()), "拓展 {key} 的快照目录过深或存在循环链接"); + let mut paths = Vec::new(); + for entry in fs::read_dir(&resolved)? { + let entry = entry?; + if entry.file_name() == ".git" { continue; } + paths.push(path.join(entry.file_name())); + anyhow::ensure!(paths.len() + budget.entries <= RELOAD_PACKAGE_MAX_ENTRIES, + "拓展 {key} 的快照目录条目超过 {RELOAD_PACKAGE_MAX_ENTRIES},未执行热重载"); + } + paths.sort(); + for child in paths { visit(root, &child, key, ancestors, sources, budget)?; } + ancestors.remove(&resolved); + } else { + anyhow::ensure!(metadata.is_file(), "拓展 {key} 包含不能快照的特殊文件: {}", path.display()); + anyhow::ensure!(sources.len() < RELOAD_PACKAGE_MAX_FILES, "拓展 {key} 的快照文件超过 {RELOAD_PACKAGE_MAX_FILES},未执行热重载"); + anyhow::ensure!(metadata.len() <= RELOAD_PACKAGE_MAX_FILE_BYTES as u64, + "拓展 {key} 的快照单文件超过 16 MiB: {}", path.display()); + let mut bytes = Vec::with_capacity(metadata.len() as usize); + fs::File::open(&resolved)?.take(RELOAD_PACKAGE_MAX_FILE_BYTES as u64 + 1).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() <= RELOAD_PACKAGE_MAX_FILE_BYTES, "拓展 {key} 的快照单文件读取中超过 16 MiB"); + budget.bytes = budget.bytes.checked_add(bytes.len()).context("拓展快照大小溢出")?; + anyhow::ensure!(budget.bytes <= RELOAD_PACKAGE_MAX_BYTES, "拓展 {key} 的快照总量超过 64 MiB,未执行热重载"); + let relative = path.strip_prefix(root)?.to_string_lossy().replace('\\', "/"); + sources.insert(format!("{key}\0{relative}"), bytes); + } + Ok(()) + } + let root = root.canonicalize()?; + let mut sources = BTreeMap::new(); + visit(&root, &root, key, &mut BTreeSet::new(), &mut sources, &mut Budget { entries: 0, bytes: 0 })?; + Ok(sources.into_iter().collect()) +} + +fn snapshot_revision(bundle: &str, backends: &[(String, Vec)]) -> String { + // Bundle text can contain extension secrets. Never expose an unsalted + // digest that could be used to test guesses against those values. + static KEY: std::sync::OnceLock<[u8; 64]> = std::sync::OnceLock::new(); + let key = KEY.get_or_init(|| { + let mut key = [0_u8; 64]; + for block in key.chunks_exact_mut(16) { + block.copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + } + key + }); + let mut inner_key = *key; + let mut outer_key = *key; + for byte in &mut inner_key { *byte ^= 0x36; } + for byte in &mut outer_key { *byte ^= 0x5c; } + let mut inner = Sha256::new(); + inner.update(inner_key); + inner.update((bundle.len() as u64).to_be_bytes()); + inner.update(bundle.as_bytes()); + for (key, bytes) in backends { + inner.update((key.len() as u64).to_be_bytes()); + inner.update(key.as_bytes()); + inner.update((bytes.len() as u64).to_be_bytes()); + inner.update(bytes); + } + let mut outer = Sha256::new(); + outer.update(outer_key); + outer.update(inner.finalize()); + format!("{:x}", outer.finalize()) +} + #[derive(Debug)] struct UserScriptFile { key: String, @@ -1918,7 +2817,7 @@ pub fn default_user_script_manager() -> UserScriptManager { default_user_script_manager_for_settings(crate::paths::default_settings_path()) } -pub(crate) fn default_user_script_manager_for_settings(settings_path: PathBuf) -> UserScriptManager { +pub fn default_user_script_manager_for_settings(settings_path: PathBuf) -> UserScriptManager { let config_dir = default_user_scripts_config_dir(); let builtin_dir = std::env::current_exe() .ok() @@ -2281,22 +3180,30 @@ pub(crate) fn extension_asset_mime(path: &str) -> Option<&'static str> { } /// 读包内 `assets/` 下的资源,转成 data URL 交给页面(`ctx.asset(path)`)。超限的跳过并记下原因。 -fn load_extension_assets(root: &std::path::Path) -> (std::collections::BTreeMap, Vec) { +fn load_extension_assets(root: &std::path::Path, strict: bool) -> anyhow::Result<(std::collections::BTreeMap, Vec)> { use base64::Engine; let mut assets = std::collections::BTreeMap::new(); let mut skipped = Vec::new(); let mut total = 0usize; let mut stack = vec![root.join("assets")]; while let Some(directory) = stack.pop() { - let Ok(entries) = fs::read_dir(&directory) else { - continue; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if !strict || error.kind() == std::io::ErrorKind::NotFound && directory == root.join("assets") => continue, + Err(error) => return Err(error).with_context(|| format!("读取资源目录 {} 失败", directory.display())), + }; + let mut entries = if strict { + entries.collect::>>()? + } else { + entries.flatten().collect::>() }; - let mut entries = entries.flatten().collect::>(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let path = entry.path(); - let Ok(metadata) = fs::symlink_metadata(&path) else { - continue; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(_) if !strict => continue, + Err(error) => return Err(error).with_context(|| format!("读取资源 {} 失败", path.display())), }; if metadata.file_type().is_symlink() { continue; @@ -2318,12 +3225,14 @@ fn load_extension_assets(root: &std::path::Path) -> (std::collections::BTreeMap< } // 每次扫描都会走到这里:按(路径, 大小, 修改时间)缓存编码结果,文件没变就不重读。 let modified = metadata.modified().ok(); - let cached = ASSET_CACHE.lock().unwrap().get(&path).filter(|entry| entry.0 == metadata.len() && entry.1 == modified).map(|entry| entry.2.clone()); + let cached = (!strict).then(|| ASSET_CACHE.lock().unwrap().get(&path).filter(|entry| entry.0 == metadata.len() && entry.1 == modified).map(|entry| entry.2.clone())).flatten(); let data_url = match cached { Some(data_url) => data_url, None => { - let Ok(bytes) = fs::read(&path) else { - continue; + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(_) if !strict => continue, + Err(error) => return Err(error).with_context(|| format!("读取资源 {} 失败", path.display())), }; let data_url = format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)); ASSET_CACHE.lock().unwrap().insert(path.clone(), (metadata.len(), modified, data_url.clone())); @@ -2334,7 +3243,7 @@ fn load_extension_assets(root: &std::path::Path) -> (std::collections::BTreeMap< assets.insert(relative, data_url); } } - (assets, skipped) + Ok((assets, skipped)) } type AssetCacheEntry = (u64, Option, String); @@ -2741,9 +3650,9 @@ fn parse_extension_manifest( if !package.schedules.is_empty() && package.backend.is_none() { anyhow::bail!("schedules 调用的是后台方法,需要同时声明 backend"); } - package.locales = load_extension_locales(&root)?; + package.locales = load_extension_locales(&root, false)?; package.providers = parse_extension_providers(raw.get("providers"), &package.config_schema)?; - (package.assets, package.skipped_assets) = load_extension_assets(&root); + (package.assets, package.skipped_assets) = load_extension_assets(&root, false)?; // 版本校验放在最后:清单本身的格式错误更具体,优先报。 if let Some(range) = engine_range { let current = crate::extension_semver::codexpp_version(); @@ -2809,13 +3718,20 @@ const EXTENSION_LOCALE_MAX_BYTES: u64 = 256 * 1024; /// 读包内 `locale/*.json`。文件名就是语言 id(如 `en`、`zh`、`zh-TW`),内容是 JSON 对象; /// `meta.title` / `meta.description` 是本地化的名称与描述,其余键是拓展自己的词条。 -fn load_extension_locales(root: &std::path::Path) -> anyhow::Result> { +fn load_extension_locales(root: &std::path::Path, strict: bool) -> anyhow::Result> { let mut locales = std::collections::BTreeMap::new(); let directory = root.join("locale"); - let Ok(entries) = fs::read_dir(&directory) else { - return Ok(locales); + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if !strict || error.kind() == std::io::ErrorKind::NotFound => return Ok(locales), + Err(error) => return Err(error).with_context(|| format!("读取语言目录 {} 失败", directory.display())), }; - for entry in entries.flatten() { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) if !strict => continue, + Err(error) => return Err(error).with_context(|| format!("枚举语言目录 {} 失败", directory.display())), + }; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("json") { continue; @@ -2834,7 +3750,8 @@ fn load_extension_locales(root: &std::path::Path) -> anyhow::Result EXTENSION_LOCALE_MAX_BYTES { + let size = if strict { fs::metadata(&resolved)?.len() } else { fs::metadata(&resolved).map(|meta| meta.len()).unwrap_or(0) }; + if size > EXTENSION_LOCALE_MAX_BYTES { anyhow::bail!("locale/{lang}.json 超过 256 KiB"); } let value: Value = serde_json::from_str(&fs::read_to_string(&resolved)?) @@ -3412,11 +4329,26 @@ fn config_from_object(raw: &Map) -> UserScriptConfig { }) .unwrap_or_default(), skip_next_start, + skip_start_marker: raw.get("skip_start_marker").and_then(skip_start_marker_from_value), market, configs, } } +fn skip_start_marker_from_value(value: &Value) -> Option { + let marker = value.as_object()?; + let id = marker.get("id")?.as_str()?.to_string(); + if uuid::Uuid::parse_str(&id).is_err() { return None; } + let document_id = match marker.get("document_id") { + None | Some(Value::Null) => None, + Some(Value::String(document)) if !document.is_empty() => Some(document.clone()), + _ => return None, + }; + let acknowledged = marker.get("acknowledged")?.as_bool()?; + if acknowledged && document_id.is_none() { return None; } + Some(SkipStartMarker { id, document_id, acknowledged }) +} + pub fn market_script_filename(id: &str) -> String { let sanitized = sanitize_market_id(id); format!( @@ -3474,6 +4406,104 @@ fn current_unix_timestamp_string() -> String { mod runtime_status_tests { use super::*; + #[test] + fn snapshot_cleanup_runs_outside_the_current_thread_runtime() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("snapshot"); + fs::create_dir(&directory).unwrap(); + let file = directory.join("dependency.mjs"); + fs::write(&file, "export default 1").unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all().max_blocking_threads(1).build().unwrap(); + let (release, wait) = std::sync::mpsc::channel(); + runtime.block_on(async move { + let (started, ready) = tokio::sync::oneshot::channel(); + let occupied = tokio::task::spawn_blocking(move || { + let _ = started.send(()); + let _ = wait.recv(); + }); + ready.await.unwrap(); + drop(StagedBackendFiles { + files: vec![file.clone()], directories: vec![directory.clone()], + }); + // 唯一的 blocking 线程尚未释放,清理应排队,当前线程仍能完成下一轮调度。 + assert!(file.is_file(), "Drop 不应在当前线程执行文件清理"); + tokio::time::timeout(Duration::from_secs(1), tokio::task::yield_now()).await.unwrap(); + release.send(()).unwrap(); + occupied.await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while directory.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("后台快照清理未在期限内完成"); + }); + } + + #[test] + fn snapshot_cleanup_without_runtime_removes_only_recorded_entries() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("snapshot"); + fs::create_dir(&directory).unwrap(); + let file = directory.join("dependency.mjs"); + let preserved = temp.path().join("unrelated.txt"); + fs::write(&file, "export default 1").unwrap(); + fs::write(&preserved, "keep").unwrap(); + drop(StagedBackendFiles { files: vec![file], directories: vec![directory.clone()] }); + assert!(!directory.exists()); + assert_eq!(fs::read_to_string(preserved).unwrap(), "keep"); + } + + #[test] + fn snapshot_rejects_direct_configuration_edits_between_reads() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.json"); + fs::write(&config, "{}").unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), &config); + // 未参与 bundle 的扩展字段也要检测;外部编辑并不持本进程的锁。 + let result = manager.reload_snapshot_checked(|| fs::write(&config, r#"{"externalEdit":true}"#).unwrap()); + assert!(result.unwrap_err().to_string().contains("混合版本")); + } + + #[test] + fn snapshot_rejects_direct_dependency_edits_between_reads() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + let package = user.join("demo"); + fs::create_dir_all(&package).unwrap(); + fs::write(package.join(EXTENSION_MANIFEST_FILE), r#"{"id":"demo","renderer":"main.js"}"#).unwrap(); + fs::write(package.join("main.js"), "window.demo = true").unwrap(); + fs::write(package.join("dynamic.js"), "module.exports = 1").unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), user, temp.path().join("config.json")); + let result = manager.reload_snapshot_checked(|| fs::write(package.join("dynamic.js"), "module.exports = 2").unwrap()); + assert!(result.unwrap_err().to_string().contains("混合版本")); + } + + #[test] + fn snapshot_config_lock_is_shared_by_independent_manager_objects() { + let temp = tempfile::tempdir().unwrap(); + let manager = || UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("config.json")); + let first = manager(); + let second = manager(); + let lock = first.config_file_lock().unwrap(); + let path = temp.path().join("config.json.lock"); + let contender = fs::OpenOptions::new().read(true).write(true).open(&path).unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&contender).is_err()); + drop(lock); + fs2::FileExt::try_lock_exclusive(&contender).unwrap(); + drop(contender); + second.set_global_enabled(false).unwrap(); + assert!(!first.load_config().enabled); + } + + #[test] + fn no_page_reload_requires_explicit_capability() { + assert!(supports_no_page_reload(&json!({ "capabilities": { "noPageReload": true } }))); + assert!(!supports_no_page_reload(&json!({ "capabilities": { "noPageReload": false } }))); + assert!(!supports_no_page_reload(&json!({ "capabilities": { "noPageReload": "true" } }))); + assert!(!supports_no_page_reload(&json!({ "scripts": [] }))); + assert!(!supports_no_page_reload(&Value::Null)); + } + fn target(id: &str, title: &str, url: &str, websocket: &str) -> crate::cdp::CdpTarget { crate::cdp::CdpTarget { id: id.to_string(), diff --git a/crates/codex-plus-core/tests/all/bridge_routes.rs b/crates/codex-plus-core/tests/all/bridge_routes.rs index cabc631239..5fed545e43 100644 --- a/crates/codex-plus-core/tests/all/bridge_routes.rs +++ b/crates/codex-plus-core/tests/all/bridge_routes.rs @@ -109,6 +109,19 @@ async fn bridge_routes_cover_all_current_paths() { } } +#[tokio::test] +async fn default_runtime_rejects_no_page_reload_without_invoking_legacy_reload() { + let runtime = FakeRuntime::default(); + let error = runtime.begin_user_script_reload(json!({ "allowPage": false })).await.unwrap_err(); + assert!(error.to_string().contains("不支持禁止刷新页面")); + assert_eq!(*runtime.reload_calls.lock().unwrap(), 0); + assert!(runtime.begin_user_script_reload_no_page(json!({ "allowPage": true })).await.is_err()); + assert_eq!(*runtime.reload_calls.lock().unwrap(), 0); + let allowed = runtime.begin_user_script_reload(json!({ "allowPage": true })).await.unwrap(); + assert_eq!(allowed["reloaded"], true); + assert_eq!(*runtime.reload_calls.lock().unwrap(), 1); +} + #[tokio::test] async fn cdp_evaluate_rejects_empty_expression_without_touching_cdp() { let error = evaluate_extension_cdp(1, &json!({"kind": "avatar-overlay"})) @@ -1269,7 +1282,7 @@ async fn user_script_inventory_merges_renderer_runtime_status() { std::fs::write(user_dir.join("loaded.js"), "window.loaded = true;").unwrap(); std::fs::write(user_dir.join("failed.js"), "throw new Error('boom');").unwrap(); let manager = - UserScriptManager::new(builtin_dir, user_dir, temp.path().join("user_scripts.json")); + UserScriptManager::new(builtin_dir, &user_dir, temp.path().join("user_scripts.json")); let runtime_status = json!({ "user:loaded.js": {"status": "loaded", "error": ""}, "user:failed.js": {"status": "failed", "error": "boom"} @@ -1292,6 +1305,31 @@ async fn user_script_inventory_merges_renderer_runtime_status() { assert_eq!(loaded["error"], ""); assert_eq!(failed["status"], "failed"); assert_eq!(failed["error"], "boom"); + + manager.set_script_enabled("user:loaded.js", false).unwrap(); + let inventory = manager.inventory_with_runtime_status(Some(&runtime_status)).unwrap(); + let disabled = inventory["scripts"].as_array().unwrap().iter() + .find(|script| script["key"] == "user:loaded.js").unwrap(); + assert_eq!(disabled["configuredEnabled"], false); + assert_eq!(disabled["runtimeStatus"], "loaded"); + assert_eq!(disabled["applyState"], "pending"); + let inventory = manager.inventory().unwrap(); + let unknown = inventory["scripts"].as_array().unwrap().iter() + .find(|script| script["key"] == "user:loaded.js").unwrap(); + assert_eq!(unknown["runtimeStatus"], "unknown"); + assert_eq!(unknown["applyState"], "unknown"); + let inventory = manager.inventory_with_runtime_status(Some(&json!({}))).unwrap(); + let stopped = inventory["scripts"].as_array().unwrap().iter() + .find(|script| script["key"] == "user:loaded.js").unwrap(); + assert_eq!(stopped["runtimeStatus"], "not_loaded"); + assert_eq!(stopped["applyState"], "unknown"); + std::fs::remove_file(user_dir.join("loaded.js")).unwrap(); + let inventory = manager.inventory_with_runtime_status(Some(&runtime_status)).unwrap(); + let orphan = inventory["scripts"].as_array().unwrap().iter() + .find(|script| script["key"] == "user:loaded.js").unwrap(); + assert_eq!(orphan["missing"], true); + assert_eq!(orphan["runtimeStatus"], "loaded"); + assert_eq!(orphan["applyState"], "pending"); } /// #2919:拓展包是文件夹,卸载时整个移进废纸篓(可找回),不做递归删除;配置里的记录一并去掉。 @@ -1375,7 +1413,7 @@ async fn user_script_manager_deletes_market_script_metadata_and_rejects_builtin_ } #[tokio::test] -async fn core_runtime_reload_evaluates_enabled_user_bundle_and_status_is_ok() { +async fn core_runtime_load_binds_source_and_reload_cannot_bypass_the_owner() { let temp = tempfile::tempdir().unwrap(); let builtin_dir = temp.path().join("builtin"); std::fs::create_dir_all(&builtin_dir).unwrap(); @@ -1386,7 +1424,7 @@ async fn core_runtime_reload_evaluates_enabled_user_bundle_and_status_is_ok() { temp.path().join("user_scripts.json"), ); let evaluated = Arc::new(Mutex::new(Vec::::new())); - let runtime = CoreRuntimeService::new(9229, StatusStore::default()) + let runtime = CoreRuntimeService::new(0, StatusStore::default()) .with_user_scripts(manager) .with_user_script_evaluator({ let evaluated = evaluated.clone(); @@ -1395,7 +1433,14 @@ async fn core_runtime_reload_evaluates_enabled_user_bundle_and_status_is_ok() { .lock() .unwrap() .push(format!("{websocket_url}:{script}")); - Ok(json!({"status": "ok"})) + let value = if script.contains("const readiness =") { + json!({ "documentId": "core-document", "initialized": false, "scripts": {} }) + } else { + json!({ "accepted": true, "scripts": { + "builtin:demo.js": { "status": "loaded" } + } }) + }; + Ok(json!({ "result": { "result": { "value": value.to_string() } } })) }) }) .with_websocket_url("ws://page"); @@ -1413,14 +1458,15 @@ async fn core_runtime_reload_evaluates_enabled_user_bundle_and_status_is_ok() { status, json!({"status": "ok", "message": "后端已连接", "version": codex_plus_core::version::VERSION, "hideOfficialUsageAlert": false, "settingsRevision": ""}) ); - assert_eq!(reloaded["scripts"][0]["key"], "builtin:demo.js"); + assert_eq!(reloaded["status"], "failed"); assert_eq!(loaded["scripts"][0]["key"], "builtin:demo.js"); let evaluated = evaluated.lock().unwrap(); assert_eq!(evaluated.len(), 2); assert!(evaluated[0].starts_with("ws://page:")); - assert!(evaluated[0].contains("window.demo = true;")); - assert!(!evaluated[0].contains(".prepareReload();")); - assert!(evaluated[1].contains(".prepareReload();")); + assert!(evaluated[0].contains("const readiness =")); + assert!(evaluated[1].contains("window.demo = true;")); + assert!(evaluated[1].contains("window.__codexPlusBackendSource =")); + assert!(!evaluated.iter().any(|script| script.contains(".prepareReload();"))); } #[tokio::test] @@ -1859,6 +1905,7 @@ impl BridgeSettingsService for FakeSettings { struct FakeRuntime { enabled: Mutex, script_enabled: Mutex, + reload_calls: Mutex, manager_payload: Mutex, goal_payloads: Mutex>, goal_reply: Mutex>>, @@ -1869,6 +1916,7 @@ impl Default for FakeRuntime { Self { enabled: Mutex::new(true), script_enabled: Mutex::new(true), + reload_calls: Mutex::new(0), manager_payload: Mutex::new(json!({})), goal_payloads: Mutex::new(Vec::new()), goal_reply: Mutex::new(None), @@ -1908,6 +1956,7 @@ impl BridgeRuntimeService for FakeRuntime { } async fn reload_user_scripts(&self) -> anyhow::Result { + *self.reload_calls.lock().unwrap() += 1; Ok(self.inventory(true)) } diff --git a/crates/codex-plus-core/tests/all/extension_backend.rs b/crates/codex-plus-core/tests/all/extension_backend.rs index fe09ee3e76..c8d3001b28 100644 --- a/crates/codex-plus-core/tests/all/extension_backend.rs +++ b/crates/codex-plus-core/tests/all/extension_backend.rs @@ -1,7 +1,7 @@ //! 拓展的常驻后台:清单校验、用真实 Node 跑通的方法调用、事件长轮询与停止。 use super::common::with_extension_backend_cleanup; -use codex_plus_core::extension_backend::{call, events, stop, stop_all}; +use codex_plus_core::extension_backend::{backend_generation, bind_backend_snapshot, call, events, stop, stop_all, stop_all_if_generation}; use codex_plus_core::user_scripts::UserScriptManager; use futures_util::FutureExt; use serde_json::json; @@ -117,6 +117,12 @@ export default ({ config, dataDir, emit }) => { assert!(stop(&manager, &key, Some("new")).await); assert!(!stop(&manager, &key, None).await, "已经停了"); + // 服务或定时器可能先于页面实例启动新后台,旧页面的 stop 不能匹配无实例归属的后台。 + assert_eq!(call(&manager, &key, "add", json!(null), Duration::from_secs(10), None).await.unwrap(), 2); + assert!(!stop(&manager, &key, Some("old")).await); + assert_eq!(call(&manager, &key, "add", json!(null), Duration::from_secs(10), None).await.unwrap(), 4); + stop_all(&manager).await; + // 改了配置(或脚本)后下一次调用按新内容重起。 assert_eq!(call(&manager, &key, "add", json!(null), Duration::from_secs(10), None).await.unwrap(), 2); manager.set_extension_config(&key, json!({"step": 10}).as_object().unwrap()).unwrap(); @@ -144,6 +150,142 @@ async fn backend_startup_failures_are_reported() { }).await; } +#[tokio::test] +async fn bound_backend_generation_loads_snapshot_bytes_for_delayed_dynamic_imports_and_resources() { + if codex_plus_core::dsh_plugins::locate_node().is_none() { return; } + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let key = "user:immutable"; + write_package(&temp, "immutable", json!({ + "backend": { "script": "backend/main.mjs", "provides": ["snapshotProbe"] }, + "config": { "dependency": { "type": "string", "default": "dynamic" } } + }), &[ + ("backend/main.mjs", r#"import fs from "node:fs"; +export default ({config}) => ({ + async read() { + const dependency = await import(`./${config.dependency}.mjs`); + return {value: dependency.default, data: fs.readFileSync(new URL("./data.txt", import.meta.url), "utf8")}; + }, + where() { return import.meta.url; } +});"#), + ("backend/dynamic.mjs", "export default 'first';"), + ("backend/data.txt", "first resource"), + ]); + with_extension_backend_cleanup(&[&manager], async { + let first_revision = manager.reload_snapshot().unwrap().1; + let snapshot = manager.stage_backend_snapshot(&first_revision).unwrap(); + let generation = backend_generation(&manager); + let source = bind_backend_snapshot(&manager, generation, "first-document", snapshot).await.unwrap(); + assert_eq!(source["revision"], first_revision); + assert!(codex_plus_core::extension_backend::backend_source_is_current(&manager, &source)); + let mut missing_revision = source.clone(); + missing_revision.as_object_mut().unwrap().remove("revision"); + assert!(!codex_plus_core::extension_backend::backend_source_is_current(&manager, &missing_revision)); + + // 尚未启动 Node。原目录之后的变更不许影响已绑定代际第一次 import。 + let package = temp.path().join("user/immutable/backend"); + fs::write(package.join("dynamic.mjs"), "export default 'second';").unwrap(); + fs::write(package.join("data.txt"), "second resource").unwrap(); + assert_eq!(call(&manager, key, "read", json!(null), Duration::from_secs(10), None).await.unwrap(), + json!({"value": "first", "data": "first resource"})); + let location = call(&manager, key, "where", json!(null), Duration::from_secs(10), None).await.unwrap(); + assert!(location.as_str().unwrap().contains(".reload-backend-")); + // 同一代的后台被按实例停止后再启动,也仍从这份保留的快照加载。 + assert!(stop(&manager, key, None).await); + assert_eq!(call(&manager, key, "read", json!(null), Duration::from_secs(10), None).await.unwrap(), + json!({"value": "first", "data": "first resource"})); + + stop_all(&manager).await; + let second_revision = manager.reload_snapshot().unwrap().1; + assert_ne!(first_revision, second_revision); + let snapshot = manager.stage_backend_snapshot(&second_revision).unwrap(); + bind_backend_snapshot(&manager, backend_generation(&manager), "second-document", snapshot).await.unwrap(); + assert_eq!(call(&manager, key, "read", json!(null), Duration::from_secs(10), None).await.unwrap(), + json!({"value": "second", "data": "second resource"})); + stop_all(&manager).await; + tokio::time::timeout(Duration::from_secs(5), async { + while fs::read_dir(temp.path()).unwrap().filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with(".reload-backend-")) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("后台快照目录未在期限内清理"); + }).await; +} + +#[test] +fn staging_backend_snapshot_rejects_a_revision_changed_before_staging() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + write_package(&temp, "changing", json!({"backend": {"script": "backend/main.mjs"}}), + &[("backend/main.mjs", "export default () => ({})"), ("backend/dependency.mjs", "export default 1")]); + let first = manager.reload_snapshot().unwrap().1; + fs::write(temp.path().join("user/changing/backend/dependency.mjs"), "export default 2").unwrap(); + assert!(manager.stage_backend_snapshot(&first).unwrap_err().to_string().contains("快照已变化")); + assert!(!fs::read_dir(temp.path()).unwrap().filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with(".reload-backend-"))); +} + +#[tokio::test] +async fn stopped_startup_cannot_register_or_remove_a_new_generation() { + if codex_plus_core::dsh_plugins::locate_node().is_none() { + return; + } + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let key = "user:delayed"; + write_package(&temp, "delayed", json!({"backend": {"script": "backend/main.mjs"}}), &[( + "backend/main.mjs", + r#"import fs from "node:fs"; +export default async ({ dataDir }) => { + const marker = `${dataDir}/started-${process.pid}`; + fs.writeFileSync(marker, ""); + while (!fs.existsSync(`${marker}.release`)) await new Promise(resolve => setTimeout(resolve, 10)); + let count = 0; + return { add() { return ++count; } }; +};"#, + )]); + let data_dir = temp.path().join("extension-data").join("delayed"); + async fn markers(dir: &std::path::Path, count: usize) -> Vec { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let entries = fs::read_dir(dir).ok().into_iter().flatten().flatten() + .map(|entry| entry.path()) + .filter(|path| path.file_name().unwrap().to_string_lossy().starts_with("started-") + && !path.to_string_lossy().ends_with(".release")) + .collect::>(); + if entries.len() >= count { + return entries; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }).await.expect("后台未按预期启动") + } + let start = |manager: UserScriptManager| tokio::spawn(async move { + call(&manager, key, "add", json!(null), Duration::from_secs(10), None).await + }); + with_extension_backend_cleanup(&[&manager], async { + let old_generation = backend_generation(&manager); + let old = start(manager.clone()); + let first = markers(&data_dir, 1).await.remove(0); + assert!(stop_all_if_generation(&manager, old_generation).await); + let new = start(manager.clone()); + let second = markers(&data_dir, 2).await.into_iter().find(|path| *path != first).unwrap(); + + let old_result = tokio::time::timeout(Duration::from_secs(2), old).await + .expect("停止操作必须及时取消尚未就绪的旧启动").unwrap(); + assert!(old_result.is_err(), "旧启动不得登记到新代际"); + assert!(!stop_all_if_generation(&manager, old_generation).await, "旧操作不得停止新代际"); + let follower = start(manager.clone()); + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!(markers(&data_dir, 2).await.len(), 2, "旧启动的清理不得删除新启动登记"); + fs::write(format!("{}.release", second.display()), "").unwrap(); + let values = [new.await.unwrap().unwrap().as_u64().unwrap(), follower.await.unwrap().unwrap().as_u64().unwrap()]; + assert!(values.contains(&1) && values.contains(&2), "{values:?}"); + assert_eq!(call(&manager, key, "add", json!(null), Duration::from_secs(10), None).await.unwrap(), 3); + }).await; +} + /// 后台按拓展目录隔离:两个目录里同名的拓展各有各的后台,停一个目录的全部后台不波及另一个目录 /// (合并测试程序里各用例并发跑,一个用例的 stop_all 曾把别的用例正在用的后台杀掉)。 #[tokio::test] diff --git a/crates/codex-plus-core/tests/all/extension_creator.rs b/crates/codex-plus-core/tests/all/extension_creator.rs index 4aab36635e..b9e871365d 100644 --- a/crates/codex-plus-core/tests/all/extension_creator.rs +++ b/crates/codex-plus-core/tests/all/extension_creator.rs @@ -15,6 +15,7 @@ fn creator(temp: &tempfile::TempDir) -> ExtensionCreator { ), // 不连 Codex:只验证写入与汇报,热重载应如实报告未执行。 debug_ports: Vec::new(), + state_dir: temp.path().join("state"), } } @@ -155,7 +156,7 @@ async fn write_read_and_disable_round_trip_without_codex() { .await; assert!(!error, "{written}"); assert_eq!(written["reload"]["reloaded"], false); - assert!(written["reload"]["reason"].as_str().unwrap().contains("没有找到已连接的 Codex 页面")); + assert!(written["reload"]["reason"].as_str().unwrap().contains("没有找到此实例的重载会话")); assert_eq!(written["extension"]["key"], "user:pomodoro"); assert_eq!(written["extension"]["kind"], "package"); assert_eq!(written["extension"]["status"], "not_loaded"); diff --git a/crates/codex-plus-core/tests/all/user_scripts_reload.rs b/crates/codex-plus-core/tests/all/user_scripts_reload.rs index 1c8bae92b2..9a42b234b6 100644 --- a/crates/codex-plus-core/tests/all/user_scripts_reload.rs +++ b/crates/codex-plus-core/tests/all/user_scripts_reload.rs @@ -1,4 +1,4 @@ -use codex_plus_core::user_scripts::{BOOTSTRAP_SCRIPT, UserScriptManager}; +use codex_plus_core::user_scripts::{BOOTSTRAP_SCRIPT, SkipStartClaim, UserScriptManager}; use serde_json::json; use std::{fs, process::Command}; @@ -52,6 +52,109 @@ fn skip_next_start_is_one_shot_and_preserves_script_choices() { assert!(next.contains("window.unsafeScript = true")); } +#[test] +fn skip_start_claim_survives_lost_ack_and_late_ack_cannot_consume_a_new_marker() { + let temp = tempfile::tempdir().unwrap(); + let manager = || UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("config.json")); + let first = manager(); + first.set_skip_next_start(true).unwrap(); + let claim = first.claim_skip_start("first-document").unwrap().unwrap(); + let restarted = manager(); + assert_eq!(restarted.claim_skip_start("first-document").unwrap(), Some(claim.clone())); + assert!(restarted.claim_skip_start("second-document").is_err()); + assert!(restarted.load_config().skip_next_start); + + restarted.set_skip_next_start(true).unwrap(); + assert!(!first.consume_skip_start_claim(&claim).unwrap()); + assert!(restarted.load_config().skip_next_start); + let new_claim = first.claim_skip_start("second-document").unwrap().unwrap(); + assert_ne!(claim.marker_id, new_claim.marker_id); + let wrong_document = SkipStartClaim { document_id: "wrong-document".into(), ..new_claim.clone() }; + assert!(!first.consume_skip_start_claim(&wrong_document).unwrap()); + assert!(restarted.consume_skip_start_claim(&new_claim).unwrap()); + assert!(first.consume_skip_start_claim(&new_claim).unwrap()); + assert!(!restarted.load_config().skip_next_start); + assert_eq!(manager().claim_skip_start("second-document").unwrap(), Some(new_claim)); + assert_eq!(manager().claim_skip_start("third-document").unwrap(), None); +} + +#[test] +fn guarded_skip_start_migrates_legacy_marker_and_replays_without_reapplying() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config.json"); + fs::write(&config, r#"{"skip_next_start":true}"#).unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), &config); + let source = json!({ "ownerEpoch": "owner", "generation": 1, "documentId": "page" }); + let (first, claim) = manager.build_guarded_initial_bundle("page", 1, &source).unwrap(); + let claim = claim.unwrap(); + assert!(manager.load_config().skip_start_marker.is_some()); + let (retry, retry_claim) = manager.build_guarded_initial_bundle("page", 2, &source).unwrap(); + assert_eq!(retry_claim, Some(claim.clone())); + let output = Command::new("node").arg("-e").arg(r#" +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const window = { __codexPlusUserScripts: { documentId: 'page', scripts: {} } }; +const context = vm.createContext({ window, console }); +assert.equal(JSON.parse(vm.runInContext(process.argv[1], context)).accepted, true); +const source = window.__codexPlusBackendSource; +const retry = JSON.parse(vm.runInContext(process.argv[2], context)); +assert.equal(retry.accepted, true); +assert.equal(retry.replayed, true); +assert.equal(window.__codexPlusBackendSource, source); +assert.equal(window.__codexPlusUserScripts.initialMode, 'skipped'); +assert.equal(Object.keys(window.__codexPlusUserScripts.scripts).length, 0); +"#).arg(&first).arg(&retry).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(manager.consume_skip_start_claim(&claim).unwrap()); + let (next, skip) = manager.build_guarded_initial_bundle("different-page", 3, &source).unwrap(); + assert!(skip.is_none()); + assert!(next.contains("runtime.initialMode = \"scripts\"")); +} + +#[test] +fn user_script_config_writer_process() { + let Some(root) = std::env::var_os("CODEX_PLUS_USER_SCRIPT_LOCK_TEST_DIR") else { return; }; + let writer = std::env::var("CODEX_PLUS_USER_SCRIPT_LOCK_TEST_WRITER").unwrap(); + let root = std::path::PathBuf::from(root); + fs::write(root.join(format!("ready-{writer}")), b"ready").unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !root.join("start").is_file() { + assert!(std::time::Instant::now() < deadline, "cross-process writer start timed out"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let manager = UserScriptManager::new(root.join("builtin"), root.join("user"), root.join("config.json")); + for index in 0..48 { + manager.set_script_enabled(&format!("user:writer-{writer}-{index}.js"), false).unwrap(); + } +} + +#[test] +fn independent_process_config_mutations_preserve_each_others_updates() { + use std::process::Stdio; + let temp = tempfile::tempdir().unwrap(); + let executable = std::env::current_exe().unwrap(); + let mut children = Vec::new(); + for writer in ["0", "1"] { + children.push(Command::new(&executable) + .args(["--exact", "user_scripts_reload::user_script_config_writer_process", "--nocapture"]) + .env("CODEX_PLUS_USER_SCRIPT_LOCK_TEST_DIR", temp.path()) + .env("CODEX_PLUS_USER_SCRIPT_LOCK_TEST_WRITER", writer) + .stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap()); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !(temp.path().join("ready-0").is_file() && temp.path().join("ready-1").is_file()) + && std::time::Instant::now() < deadline + { std::thread::sleep(std::time::Duration::from_millis(5)); } + // 无论就绪检查结果如何都放行并回收子进程。 + fs::write(temp.path().join("start"), b"start").unwrap(); + let results = children.into_iter().map(|child| child.wait_with_output().unwrap()).collect::>(); + for output in results { + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + } + let manager = UserScriptManager::new(temp.path().join("builtin"), temp.path().join("user"), temp.path().join("config.json")); + assert_eq!(manager.load_config().scripts.len(), 96); +} + #[test] fn reload_cleans_resources_and_applies_current_files_and_switches() { let temp = tempfile::tempdir().unwrap(); @@ -193,6 +296,179 @@ fn write_package(root: &std::path::Path, dir: &str, manifest: serde_json::Value) fs::write(package.join("codexpp.json"), manifest.to_string()).unwrap(); } +#[test] +fn strict_reload_snapshot_rejects_invalid_config_and_script_content() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + fs::create_dir_all(&user).unwrap(); + let config = temp.path().join("config.json"); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, &config); + fs::write(user.join("demo.js"), "window.demo = true;").unwrap(); + + let (bundle, revision) = manager.reload_snapshot().unwrap(); + assert_eq!(bundle, manager.build_enabled_bundle().unwrap()); + assert_eq!(revision, manager.reload_snapshot().unwrap().1); + fs::write(&config, "{").unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::write(&config, r#"{"scripts":{"user:demo.js":"yes"}}"#).unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::write(&config, "{}").unwrap(); + fs::write(user.join("demo.js"), [0xff]).unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::write(&config, r#"{"scripts":{"user:demo.js":false}}"#).unwrap(); + assert!(manager.reload_snapshot().is_ok()); + let unreadable_config = temp.path().join("config-directory"); + fs::create_dir(&unreadable_config).unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, unreadable_config); + assert!(manager.reload_snapshot().is_err()); +} + +#[test] +fn strict_reload_snapshot_rejects_invalid_enabled_packages_and_partial_resources() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + fs::create_dir_all(&user).unwrap(); + let config = temp.path().join("config.json"); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, &config); + write_package(&user, "demo", json!({ + "id": "demo", "renderer": "main.js", + "backend": { "script": "backend.js", "provides": ["demo.service"] } + })); + let package = user.join("demo"); + fs::write(package.join("main.js"), "window.demo = true;").unwrap(); + fs::write(package.join("backend.js"), "process.stdin.resume();").unwrap(); + let (bundle, _) = manager.reload_snapshot().unwrap(); + assert_eq!(bundle, manager.build_enabled_bundle().unwrap()); + assert!(bundle.contains("demo.service")); + + fs::write(package.join("main.js"), [0xff]).unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::write(package.join("main.js"), "window.demo = true;").unwrap(); + fs::remove_file(package.join("backend.js")).unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::write(package.join("backend.js"), "process.stdin.resume();").unwrap(); + fs::write(package.join("assets"), "not a directory").unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::remove_file(package.join("assets")).unwrap(); + fs::write(package.join("locale"), "not a directory").unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::remove_file(package.join("locale")).unwrap(); + fs::write(package.join("codexpp.json"), "{").unwrap(); + assert!(manager.reload_snapshot().is_err()); + fs::write(&config, r#"{"scripts":{"user:demo":false}}"#).unwrap(); + assert!(manager.reload_snapshot().is_ok()); + fs::create_dir_all(user.join("bad").join("codexpp.json")).unwrap(); + assert!(manager.reload_snapshot().is_err()); +} + +#[test] +fn strict_reload_snapshot_tracks_backend_entry_and_matches_initial_revision() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + fs::create_dir_all(&user).unwrap(); + write_package(&user, "demo", json!({ + "id": "demo", "renderer": "main.js", + "backend": { "script": "backend.js", "provides": ["demo.service"] } + })); + let package = user.join("demo"); + fs::write(package.join("main.js"), "window.demo = true;").unwrap(); + fs::write(package.join("backend.js"), "module.exports = 1;").unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, temp.path().join("config.json")); + let (bundle, first) = manager.reload_snapshot().unwrap(); + let source = json!({ "ownerEpoch": "owner", "generation": 1, "documentId": "document" }); + let (initial, skip) = manager.build_guarded_initial_bundle("document", 1, &source).unwrap(); + assert!(skip.is_none()); + assert!(initial.contains(&format!("runtime.appliedRevision = {};", json!(first)))); + fs::write(package.join("backend.js"), "module.exports = 2;").unwrap(); + let (changed_bundle, second) = manager.reload_snapshot().unwrap(); + assert_eq!(bundle, changed_bundle); + assert_ne!(first, second); + let (initial, _) = manager.build_guarded_initial_bundle("document", 2, &source).unwrap(); + assert!(initial.contains(&format!("runtime.appliedRevision = {};", json!(second)))); +} + +#[test] +fn guarded_initial_load_never_falls_back_after_binding_a_strict_backend_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + fs::create_dir_all(&user).unwrap(); + write_package(&user, "demo", json!({ + "id": "demo", "renderer": "main.js", + "backend": { "script": "backend.js", "provides": ["demo.service"] } + })); + let package = user.join("demo"); + fs::write(package.join("main.js"), "window.demo = true;").unwrap(); + fs::write(package.join("backend.js"), "module.exports = 1;").unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, temp.path().join("config.json")); + let (_, revision) = manager.reload_snapshot().unwrap(); + let source = json!({ "ownerEpoch": "owner", "generation": 1, "documentId": "document", "revision": revision }); + assert!(manager.build_guarded_initial_bundle("document", 1, &source).is_ok()); + fs::write(package.join("backend.js"), "module.exports = 2;").unwrap(); + assert!(manager.build_guarded_initial_bundle("document", 2, &source).unwrap_err() + .to_string().contains("已绑定后台快照不一致")); + fs::write(package.join("codexpp.json"), "{").unwrap(); + assert!(manager.build_guarded_initial_bundle("document", 3, &source).unwrap_err() + .to_string().contains("页面来源读取失败")); + let unbound = json!({ "ownerEpoch": "owner", "generation": 2, "documentId": "document", "revision": null }); + let (diagnostic_bundle, _) = manager.build_guarded_initial_bundle("document", 4, &unbound).unwrap(); + assert!(diagnostic_bundle.contains("runtime.appliedRevision = null;")); +} + +#[test] +fn strict_reload_snapshot_tracks_nested_dynamic_backend_dependencies_and_resources() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + fs::create_dir_all(&user).unwrap(); + write_package(&user, "demo", json!({ + "id": "demo", "renderer": "main.js", + "backend": { "script": "backend.js", "provides": ["demo.service"] } + })); + let package = user.join("demo"); + fs::write(package.join("main.js"), "window.demo = true;").unwrap(); + fs::write(package.join("backend.js"), "require('./lib/' + process.env.MODE); require('nested');").unwrap(); + fs::create_dir_all(package.join("lib")).unwrap(); + fs::create_dir_all(package.join("node_modules/nested")).unwrap(); + fs::write(package.join("lib/data.js"), "module.exports = 1").unwrap(); + fs::write(package.join("node_modules/nested/index.js"), "module.exports = 2").unwrap(); + fs::write(package.join("data.bin"), [0, 1, 2]).unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, temp.path().join("config.json")); + let (bundle, first) = manager.reload_snapshot().unwrap(); + fs::write(package.join("lib/data.js"), "module.exports = 3").unwrap(); + let (same_bundle, second) = manager.reload_snapshot().unwrap(); + assert_eq!(bundle, same_bundle); + assert_ne!(first, second); + fs::write(package.join("node_modules/nested/index.js"), "module.exports = 4").unwrap(); + let (_, third) = manager.reload_snapshot().unwrap(); + assert_ne!(second, third); + fs::write(package.join("data.bin"), [0, 1, 3]).unwrap(); + let (_, fourth) = manager.reload_snapshot().unwrap(); + assert_ne!(third, fourth); + fs::write(package.join("lib/new.js"), "module.exports = 5").unwrap(); + assert_ne!(fourth, manager.reload_snapshot().unwrap().1); +} + +#[test] +fn strict_reload_snapshot_rejects_unbounded_package_files_instead_of_ignoring_them() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + fs::create_dir_all(&user).unwrap(); + write_package(&user, "demo", json!({ "id": "demo", "renderer": "main.js" })); + let package = user.join("demo"); + fs::write(package.join("main.js"), "window.demo = true;").unwrap(); + let too_large = fs::File::create(package.join("dependency.bin")).unwrap(); + too_large.set_len(16 * 1024 * 1024 + 1).unwrap(); + let manager = UserScriptManager::new(temp.path().join("builtin"), &user, temp.path().join("config.json")); + assert!(manager.reload_snapshot().unwrap_err().to_string().contains("16 MiB")); + #[cfg(unix)] + { + drop(too_large); + fs::remove_file(package.join("dependency.bin")).unwrap(); + let outside = temp.path().join("outside.js"); + fs::write(&outside, "module.exports = 'outside';").unwrap(); + std::os::unix::fs::symlink(outside, package.join("outside.js")).unwrap(); + assert!(manager.reload_snapshot().unwrap_err().to_string().contains("超出包目录")); + } +} #[test] fn extension_packages_are_scanned_bundled_and_exposed_with_manifest() { let temp = tempfile::tempdir().unwrap(); diff --git a/scripts/user-script-control-clients.test.mjs b/scripts/user-script-control-clients.test.mjs new file mode 100644 index 0000000000..9d71bd46da --- /dev/null +++ b/scripts/user-script-control-clients.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +const source = await readFile(new URL("../assets/inject/renderer-inject/40-backend-settings.js", import.meta.url), "utf8"); +const start = source.indexOf(" let codexPlusPendingReload = null;"); +const end = source.indexOf(" /** 左面板的导航项。", start); +assert.ok(start > 0 && end > start); +const client = source.slice(start, end); +const epoch = "a".repeat(32); + +function createClient(postJson, loadUserScripts = async () => {}) { + const context = vm.createContext({ + postJson, loadUserScripts, showToast() {}, + crypto: { randomUUID }, + setTimeout: (callback) => { queueMicrotask(callback); return 0; }, + Date, Promise, + }); + vm.runInContext(`${client}\nglobalThis.reload = reloadUserScriptsAfterChange;`, context); + return context.reload; +} + +function applied(id, revision = "current", desired = revision) { + return { + reloadOperation: { operationId: id, ownerEpoch: epoch, phase: "applied", revision }, + desiredRevision: desired, + }; +} + +test("page client resolves a lost submission ACK with the original ID", async () => { + let id; + let submissions = 0; + let queries = 0; + const reload = createClient(async (route, payload) => { + if (payload.capabilities) return { capabilities: { operationProtocol: 1 }, ownerEpoch: epoch }; + if (route === "/user-scripts/reload") { + id = payload.operationId; + submissions++; + throw new Error("ACK lost after owner accepted"); + } + queries++; + assert.equal(payload.operationId, id); + return applied(id); + }); + await reload(); + assert.equal(submissions, 1); + assert.equal(queries, 1); + assert.match(id, /^[a-f0-9]{32}-[a-f0-9]+-[a-f0-9]{32}$/); +}); + +test("page client applies current revision after a historical operation completes", async () => { + const ids = []; + const reload = createClient(async (_route, payload) => { + if (payload.capabilities) return { capabilities: { operationProtocol: 1 }, ownerEpoch: epoch }; + ids.push(payload.operationId); + return applied(payload.operationId, ids.length === 1 ? "old" : "current", "current"); + }); + await reload(); + assert.equal(ids.length, 2); + assert.notEqual(ids[0], ids[1]); +}); + +test("an intent arriving after desiredRevision was read still drains another reload", async () => { + let releaseInventory; + let inventoryStarted; + const waitingInventory = new Promise((resolve) => { inventoryStarted = resolve; }); + const inventoryGate = new Promise((resolve) => { releaseInventory = resolve; }); + let inventories = 0; + const ids = []; + let revision = "old"; + const reload = createClient(async (_route, payload) => { + if (payload.capabilities) return { capabilities: { operationProtocol: 1 }, ownerEpoch: epoch }; + ids.push(payload.operationId); + return applied(payload.operationId, revision); + }, async () => { + if (++inventories === 1) { + inventoryStarted(); + await inventoryGate; + } + }); + const first = reload(); + await waitingInventory; + revision = "new"; + const second = reload(); + assert.equal(first, second); + releaseInventory(); + await second; + assert.equal(ids.length, 2); + assert.notEqual(ids[0], ids[1]); +}); + +test("pending desired revision polls the same operation and verification intent", async () => { + const requests = []; + const reload = createClient(async (_route, payload) => { + if (payload.capabilities) return { capabilities: { operationProtocol: 1 }, ownerEpoch: epoch }; + requests.push(payload); + if (requests.length === 1) { + return { ...applied(payload.operationId), desiredRevision: null, desiredRevisionPending: true }; + } + return applied(payload.operationId); + }); + await reload(); + assert.equal(requests.length, 2); + assert.equal(requests[0].operationId, requests[1].operationId); + assert.equal(requests[0].verificationId, requests[1].verificationId); +}); diff --git a/scripts/user-scripts-runtime.test.mjs b/scripts/user-scripts-runtime.test.mjs new file mode 100644 index 0000000000..53512d361d --- /dev/null +++ b/scripts/user-scripts-runtime.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL("../assets/inject/user-scripts-runtime.js", import.meta.url), "utf8"); + +function page(scripts = {}, document = {}) { + const timers = []; + const window = { + __codexPlusUserScripts: { scripts }, + __codexPlusUserScriptsBootstrap: true, + setTimeout(fn) { timers.push(fn); }, + location: { reload() {} }, + }; + vm.runInNewContext(source, { window, document, console }); + return { runtime: window.__codexPlusUserScripts, timers, window }; +} + +test("two-phase operation is idempotent and bound to document and revision", () => { + let cleanups = 0; + let applies = 0; + const { runtime } = page({ old: { cleanups: [() => { cleanups++; }] } }); + const doc = runtime.documentId; + assert.equal(runtime.prepareOperation("one", doc, "r1", true, 1).phase, "stopped"); + assert.equal(runtime.prepareOperation("one", doc, "r1", true, 1).phase, "stopped"); + assert.equal(runtime.prepareOperation("one", doc, "r2", true, 1).reason, "operation_identity_conflict"); + assert.equal(cleanups, 1); + assert.equal(runtime.applyOperation("one", "other", "r1", () => applies++).phase, "rejected"); + assert.equal(runtime.applyOperation("one", doc, "r2", () => applies++).phase, "rejected"); + assert.equal(runtime.applyOperation("one", doc, "r1", () => applies++).phase, "verifying"); + assert.equal(runtime.applyOperation("one", doc, "r1", () => applies++).phase, "verifying"); + assert.equal(applies, 1); +}); + +test("no-page policy rejects old scripts without touching them or scheduling a refresh", () => { + let cleanups = 0; + const { runtime, timers } = page({ legacy: {}, other: { cleanups: [() => cleanups++] } }); + const result = runtime.prepareOperation("one", runtime.documentId, "r1", false, 1); + assert.equal(result.phase, "blocked"); + assert.equal(result.reason, "cleanup_missing"); + assert.equal(cleanups, 0); + assert.equal(timers.length, 0); +}); + +for (const [name, cleanup] of [ + ["throws", () => { throw new Error("failed"); }], + ["returns a promise", () => Promise.resolve()], +]) { + test(`no-page cleanup that ${name} is degraded without injecting or refreshing`, () => { + const { runtime, timers } = page({ old: { cleanups: [cleanup] } }); + const result = runtime.prepareOperation("one", runtime.documentId, "r1", false, 1); + assert.equal(result.phase, "degraded"); + assert.equal(runtime.applyOperation("one", runtime.documentId, "r1", () => { + throw new Error("must not apply"); + }).phase, "degraded"); + assert.equal(runtime.prepareOperation("two", runtime.documentId, "r2", false, 2).phase, "degraded"); + assert.equal(timers.length, 0); + }); +} + +test("old scripts use one page refresh when page fallback is allowed", () => { + const { runtime, timers } = page({ old: {} }); + assert.equal(runtime.prepareOperation("one", runtime.documentId, "r1", true, 1).phase, "pageRequested"); + assert.equal(runtime.prepareOperation("one", runtime.documentId, "r1", true, 1).phase, "pageRequested"); + assert.equal(timers.length, 1); +}); + +test("a later fenced operation supersedes a stopped one and rejects late commands", () => { + const { runtime } = page({ old: { cleanups: [() => {}] } }); + const doc = runtime.documentId; + assert.equal(runtime.prepareOperation("old", doc, "r1", true, 10).phase, "stopped"); + assert.equal(runtime.prepareOperation("new", doc, "r2", true, 11).phase, "stopped"); + assert.equal(runtime.applyOperation("old", doc, "r1", () => {}).phase, "rejected"); + assert.equal(runtime.prepareOperation("late", doc, "r1", true, 9).reason, "stale_operation"); + assert.equal(runtime.applyOperation("new", doc, "r2", () => {}).phase, "verifying"); +}); + +test("backend source is withdrawn before cleanup and page fallback", () => { + let duringCleanup; + const first = page({ old: { cleanups: [() => { duringCleanup = first.window.__codexPlusBackendSource; }] } }); + first.window.__codexPlusBackendSource = { generation: 7 }; + first.window.__codexPlusBackendServices = [{ name: "old" }]; + first.runtime.prepareOperation("clean", first.runtime.documentId, "r1", true, 1); + assert.equal(duringCleanup, null); + assert.equal(first.window.__codexPlusBackendServices.length, 0); + + const legacy = page({ old: {} }); + const oldSource = { generation: 7 }; + legacy.window.__codexPlusBackendSource = oldSource; + legacy.runtime.prepareOperation("blocked", legacy.runtime.documentId, "r1", false, 1); + assert.equal(legacy.window.__codexPlusBackendSource, oldSource); + legacy.runtime.prepareOperation("page", legacy.runtime.documentId, "r1", true, 2); + assert.equal(legacy.window.__codexPlusBackendSource, null); +}); + +test("native readiness includes settings and rejects an empty or extension-only shell", () => { + let composer = false; + let navigation = false; + let content = false; + let extensionOnly = false; + const element = { closest: selector => selector.includes("data-codex-plus-ext") && extensionOnly ? {} : null }; + const root = { + querySelectorAll(selector) { + const present = selector.includes("ProseMirror") ? composer + : selector.includes("app-shell-sidebar") ? navigation : content; + return present ? [element] : []; + }, + }; + const document = { readyState: "complete", getElementById: () => root }; + const { runtime } = page({}, document); + assert.equal(runtime.nativePageReadiness().ready, false); + navigation = true; + assert.equal(runtime.nativePageReadiness().ready, false); + content = true; + assert.equal(runtime.nativePageReadiness().view, "native_shell"); + extensionOnly = true; + assert.equal(runtime.nativePageReadiness().ready, false); + extensionOnly = false; + composer = true; + assert.equal(runtime.nativePageReadiness().view, "conversation"); + document.readyState = "loading"; + assert.equal(runtime.nativePageReadiness().ready, false); +}); From a759194cd20348f4c5b464555bb698e4b6bbe651 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Sat, 7 Nov 2026 01:28:20 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E7=83=AD=E9=87=8D=E8=BD=BD=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E4=B8=8E=E7=8A=B6=E6=80=81=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/codex-plus-launcher/src/main.rs | 469 ++++++++++- .../src/user_script_reload.rs | 714 +++++++++++++++- apps/codex-plus-manager/src/App.tsx | 40 +- .../src/claude-extensions-navigation.test.ts | 207 +++++ .../src/renderer-inject.test.ts | 62 +- assets/inject/renderer-inject.js | 299 +++++-- .../renderer-inject/40-backend-settings.js | 287 ++++++- .../renderer-inject/60-plugin-marketplace.js | 12 +- assets/inject/renderer-inject/manifest.json | 4 +- crates/codex-plus-core/src/bridge.rs | 38 +- .../codex-plus-core/src/extension_backend.rs | 242 +++++- crates/codex-plus-core/src/routes.rs | 4 + .../src/user_script_control.rs | 113 ++- crates/codex-plus-core/src/user_scripts.rs | 99 ++- .../tests/all/bridge_routes.rs | 24 + .../codex-plus-core/tests/all/cdp_bridge.rs | 2 +- .../tests/all/extension_backend.rs | 158 +++- .../tests/all/user_scripts_reload.rs | 170 ++++ scripts/user-script-control-clients.test.mjs | 781 +++++++++++++++++- scripts/user-scripts-runtime.test.mjs | 8 +- 20 files changed, 3511 insertions(+), 222 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 5420227cbe..5de2573e37 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -1727,6 +1727,7 @@ struct LauncherRuntimeService { websocket_url: Arc>>, user_scripts: UserScriptManager, reload_owner: user_script_reload::ReloadOwner, + legacy_reload: Mutex>, native_control: Mutex>, } @@ -1737,6 +1738,7 @@ impl LauncherRuntimeService { websocket_url: Arc::new(Mutex::new(None)), user_scripts, reload_owner: user_script_reload::ReloadOwner::default(), + legacy_reload: Mutex::new(None), native_control: Mutex::new(None), } } @@ -1782,40 +1784,118 @@ impl LauncherRuntimeService { fn stop_native_control(&self) { self.native_control.lock().unwrap().take(); } + + async fn wait_for_legacy_reload(&self, timeout: std::time::Duration) -> anyhow::Result { + let id = { + let mut pending = self.legacy_reload.lock().unwrap(); + if let Some(id) = pending.as_ref() { + id.clone() + } else { + let operation = self.reload_owner.submit( + &json!({}), self.websocket_url.clone(), self.user_scripts.clone(), + )?; + let id = operation["operationId"].as_str() + .context("重载协调器未提供操作编号")?.to_owned(); + *pending = Some(id.clone()); + id + } + }; + let verification = codex_plus_core::user_script_control::new_owner_epoch(); + let (record, desired_revision) = tokio::time::timeout(timeout, async { + loop { + let Some(operation) = self.reload_owner.status(&id) else { return None }; + if operation["active"] == false { + if operation["phase"] == "applied" { + let response = self.reload_owner.control_response( + operation.clone(), Some(&verification), &self.user_scripts, + ); + if response["desiredRevisionPending"] != true { + return Some((operation, response["desiredRevision"].as_str().map(str::to_owned))); + } + } else { + return Some((operation, None)); + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }).await.map_err(|_| anyhow::anyhow!( + "重载已提交但尚未确认;稍后再次操作将继续查询原请求" + ))?.context("原重载记录已不可用,未再次执行")?; + + let phase = record["phase"].as_str().unwrap_or_default(); + let result = match phase { + "applied" => legacy_reload_inventory(&record, desired_revision.as_deref()), + "failed" | "blocked" | "degraded" | "rejected" => Err(anyhow::anyhow!( + "{}", record["reason"].as_str().filter(|reason| !reason.is_empty()) + .unwrap_or("用户脚本重载未完成") + )), + _ => anyhow::bail!("原重载状态无法确认,未再次执行"), + }; + let mut pending = self.legacy_reload.lock().unwrap(); + if pending.as_deref() == Some(&id) { + *pending = None; + } + result + } +} + +fn legacy_reload_inventory(operation: &Value, desired_revision: Option<&str>) -> anyhow::Result { + let revision = operation["revision"].as_str().filter(|revision| !revision.is_empty()) + .context("重载已结束,但应用版本无法确认")?; + let desired = desired_revision.filter(|revision| !revision.is_empty()) + .context("重载已结束,但当前配置无法确认")?; + anyhow::ensure!(revision == desired, "重载期间配置已变化,请再次重载"); + let mut inventory = operation["inventory"].clone(); + anyhow::ensure!(inventory["scripts"].is_array(), "重载结果缺少可确认的脚本清单"); + inventory["reload_mode"] = operation["mode"].clone(); + Ok(inventory) } #[async_trait::async_trait] impl BridgeRuntimeService for LauncherRuntimeService { async fn user_script_inventory(&self) -> anyhow::Result { - self.user_scripts.inventory() + let manager = self.user_scripts.clone(); + tokio::task::spawn_blocking(move || manager.inventory()).await? } async fn user_script_inventory_with_runtime_status( &self, payload: Value, ) -> anyhow::Result { - self.user_scripts - .inventory_with_runtime_status(payload.get("runtime_status")) + let manager = self.user_scripts.clone(); + let runtime_status = payload.get("runtime_status").cloned(); + tokio::task::spawn_blocking(move || { + manager.inventory_with_runtime_status(runtime_status.as_ref()) + }).await? } async fn set_user_scripts_enabled(&self, enabled: bool) -> anyhow::Result { - self.user_scripts.set_global_enabled(enabled)?; - self.user_scripts.inventory() + let manager = self.user_scripts.clone(); + tokio::task::spawn_blocking(move || { + manager.set_global_enabled(enabled)?; + manager.inventory() + }).await? } async fn set_user_script_enabled(&self, key: String, enabled: bool) -> anyhow::Result { - let result = self.user_scripts.set_script_enabled(&key, enabled); - // 与管理器一致:拓展带的钩子 / Codex 资产跟着启停写回 Codex 配置。 - codex_plus_core::extension_hooks::sync_default(&self.user_scripts); - result?; - self.user_scripts.inventory() + let manager = self.user_scripts.clone(); + tokio::task::spawn_blocking(move || { + let result = manager.set_script_enabled(&key, enabled); + // 与管理器一致:拓展带的钩子 / Codex 资产跟着启停写回 Codex 配置。 + codex_plus_core::extension_hooks::sync_default(&manager); + result?; + manager.inventory() + }).await? } async fn delete_user_script(&self, key: String) -> anyhow::Result { - let result = self.user_scripts.delete_user_script(&key); - codex_plus_core::extension_hooks::sync_default(&self.user_scripts); - result?; - self.user_scripts.inventory() + let manager = self.user_scripts.clone(); + tokio::task::spawn_blocking(move || { + let result = manager.delete_user_script(&key); + codex_plus_core::extension_hooks::sync_default(&manager); + result?; + manager.inventory() + }).await? } async fn load_user_scripts(&self) -> anyhow::Result { @@ -1836,12 +1916,44 @@ impl BridgeRuntimeService for LauncherRuntimeService { } async fn begin_user_script_reload(&self, payload: Value) -> anyhow::Result { - let mut inventory = self.user_scripts.inventory()?; + if payload.as_object().is_some_and(|fields| fields.is_empty()) { + return self.wait_for_legacy_reload(std::time::Duration::from_secs(20)).await; + } + // 非空请求保留原有身份校验和即时回执语义。 + let manager = self.user_scripts.clone(); + let mut inventory = tokio::task::spawn_blocking(move || manager.inventory()).await??; let operation = self.reload_owner.submit(&payload, self.websocket_url.clone(), self.user_scripts.clone())?; - inventory["reloadOperation"] = operation; + let response = self.reload_owner.control_response(operation, payload["verificationId"].as_str(), &self.user_scripts); + if let (Some(target), Some(fields)) = (inventory.as_object_mut(), response.as_object()) { + target.extend(fields.clone()); + } Ok(inventory) } + async fn begin_user_script_reload_operation_v1(&self, payload: Value) -> anyhow::Result { + anyhow::ensure!(payload.is_object(), "重载参数必须是对象"); + let epoch = payload.get("ownerEpoch").and_then(Value::as_str) + .filter(|epoch| *epoch == self.reload_owner.epoch()) + .ok_or_else(|| anyhow::anyhow!("重载 owner 已变化或缺失"))?; + payload.get("operationId").and_then(Value::as_str) + .filter(|id| codex_plus_core::user_script_control::operation_issued_at(epoch, id).is_some()) + .ok_or_else(|| anyhow::anyhow!("重载操作编号无效或属于旧 owner"))?; + anyhow::ensure!( + payload.get("sourceDocumentId").and_then(Value::as_str) + .is_some_and(|value| !value.is_empty() && value.len() <= 256), + "页面重载缺少有效的来源文档标识" + ); + anyhow::ensure!(payload.get("allowPage").and_then(Value::as_bool).is_some(), + "页面重载缺少有效的刷新策略"); + if let Some(verification) = payload.get("verificationId") { + anyhow::ensure!(verification.as_str().is_some_and(|id| + id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit())), + "重载验证编号无效"); + } + let operation = self.reload_owner.submit(&payload, self.websocket_url.clone(), self.user_scripts.clone())?; + Ok(self.reload_owner.control_response(operation, payload["verificationId"].as_str(), &self.user_scripts)) + } + async fn begin_user_script_reload_no_page(&self, mut payload: Value) -> anyhow::Result { let payload = payload.as_object_mut().ok_or_else(|| anyhow::anyhow!("重载参数必须是对象"))?; payload.insert("allowPage".to_string(), json!(false)); @@ -1850,7 +1962,7 @@ impl BridgeRuntimeService for LauncherRuntimeService { async fn user_script_reload_status(&self, payload: Value) -> anyhow::Result { if payload.get("capabilities").and_then(Value::as_bool) == Some(true) { - return Ok(json!({ "capabilities": { "noPageReload": true, "operationProtocol": 1 }, + return Ok(json!({ "capabilities": { "noPageReload": true, "operationProtocol": 1, "documentFence": true, "fencedSubmitV1": true }, "ownerEpoch": self.reload_owner.epoch() })); } let id = payload.get("operationId").and_then(Value::as_str).unwrap_or_default(); @@ -2060,6 +2172,34 @@ fn default_user_script_manager() -> UserScriptManager { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; + + #[tokio::test(flavor = "current_thread")] + async fn script_configuration_lock_does_not_block_the_launcher_runtime() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("settings.json"); + let lock_path = dir.path().join("settings.json.lock"); + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let file = std::fs::OpenOptions::new().create(true).truncate(false).read(true).write(true) + .open(lock_path).unwrap(); + file.lock().unwrap(); + locked_tx.send(()).unwrap(); + let _ = release_rx.recv_timeout(Duration::from_secs(2)); + }); + locked_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), dir.path().join("user"), config); + let runtime = LauncherRuntimeService::new(0, manager); + let task = tokio::spawn(async move { runtime.set_user_scripts_enabled(false).await }); + tokio::time::sleep(Duration::from_millis(100)).await; + let still_waiting = !task.is_finished(); + let _ = release_tx.send(()); + holder.join().unwrap(); + let result = tokio::time::timeout(Duration::from_secs(3), task).await.unwrap().unwrap().unwrap(); + assert!(still_waiting, "配置锁等待阻塞了单线程运行时"); + assert_eq!(result["enabled"], false); + } #[tokio::test] async fn no_page_reload_route_overrides_a_conflicting_payload() { @@ -2074,6 +2214,301 @@ mod tests { "allowPage": true })).await.unwrap(); assert_eq!(result["reloadOperation"]["allowPage"], false); + assert!(result["scripts"].is_array()); + } + + #[tokio::test] + async fn native_and_page_reload_share_the_launcher_owner_and_queue() { + use codex_plus_core::user_script_control::{self as control, ControlIdentity}; + + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + dir.path().join("user"), + dir.path().join("settings.json"), + ); + let port = 49160; + let identity = ControlIdentity::new( + &codex_plus_core::paths::default_app_state_dir(), port, &manager, + ).unwrap(); + let runtime = Arc::new(LauncherRuntimeService::new(port, manager)); + runtime.start_native_control(port).unwrap(); + + let native_hello = control::request(&identity, json!({ "action": "hello" })).await.unwrap(); + let page_hello = runtime.user_script_reload_status(json!({ "capabilities": true })).await.unwrap(); + let epoch = native_hello["ownerEpoch"].as_str().unwrap(); + assert_eq!(page_hello["ownerEpoch"], epoch); + + let native_id = control::new_operation_id(epoch); + let native_submit = json!({ + "action": "submit", "ownerEpoch": epoch, "operationId": native_id, "allowPage": false, + }); + let accepted = control::request(&identity, native_submit.clone()).await.unwrap(); + assert_eq!(accepted["result"]["reloadOperation"]["operationId"], native_id); + assert_eq!(accepted["result"]["reloadOperation"]["allowPage"], false); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let response = runtime.user_script_reload_status(json!({ "operationId": native_id })).await.unwrap(); + if response["reloadOperation"]["phase"] == "unknown" { + assert_eq!(response["reloadOperation"]["ownerEpoch"], epoch); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.unwrap(); + + let duplicate = control::request(&identity, native_submit).await.unwrap(); + assert_eq!(duplicate["result"]["reloadOperation"]["operationId"], native_id); + assert_eq!( + duplicate["result"]["reloadOperation"]["createdAt"], + accepted["result"]["reloadOperation"]["createdAt"], + ); + + let page_id = control::new_operation_id(epoch); + let page = runtime.begin_user_script_reload_operation_v1(json!({ + "operationId": page_id, "ownerEpoch": epoch, + "sourceDocumentId": "page-document", "allowPage": true, + })).await.unwrap(); + assert_eq!(page["reloadOperation"]["operationId"], page_id); + tokio::time::sleep(Duration::from_millis(100)).await; + let status = control::request(&identity, json!({ + "action": "status", "ownerEpoch": epoch, "operationId": page_id, + })).await.unwrap(); + assert_eq!(status["result"]["reloadOperation"]["phase"], "accepted"); + assert_eq!(status["result"]["reloadOperation"]["sourceDocumentId"], "page-document"); + runtime.stop_native_control(); + } + + #[tokio::test] + async fn legacy_reload_does_not_report_success_before_the_page_is_reachable() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + dir.path().join("user"), + dir.path().join("settings.json"), + ); + let runtime = LauncherRuntimeService::new(0, manager); + let result = tokio::time::timeout( + Duration::from_millis(150), + runtime.begin_user_script_reload(json!({})), + ).await; + assert!(result.is_err(), "旧接口不能把登记中的操作返回为已重载的脚本清单"); + let first_id = runtime.legacy_reload.lock().unwrap().clone().unwrap(); + let error = runtime.wait_for_legacy_reload(Duration::from_millis(50)).await.unwrap_err(); + assert!(error.to_string().contains("尚未确认")); + let retry = tokio::time::timeout( + Duration::from_millis(150), + runtime.begin_user_script_reload(json!({})), + ).await; + assert!(retry.is_err()); + assert_eq!(runtime.legacy_reload.lock().unwrap().as_deref(), Some(first_id.as_str())); + assert_eq!(runtime.reload_owner.status(&first_id).unwrap()["active"], true); + } + + #[tokio::test] + async fn legacy_reload_reports_the_actual_terminal_failure() { + let dir = tempfile::tempdir().unwrap(); + let user = dir.path().join("not-a-directory"); + std::fs::write(&user, b"fixture").unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), user, dir.path().join("settings.json"), + ); + let runtime = LauncherRuntimeService::new(0, manager); + runtime.set_websocket_url("ws://127.0.0.1:1/devtools/page/fixture"); + let error = tokio::time::timeout( + Duration::from_secs(3), + runtime.begin_user_script_reload(json!({})), + ).await.unwrap().unwrap_err(); + assert!(!error.to_string().is_empty()); + assert!(runtime.legacy_reload.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn nonempty_unnumbered_reload_still_checks_owner_identity() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + dir.path().join("user"), + dir.path().join("settings.json"), + ); + let runtime = LauncherRuntimeService::new(0, manager); + let result = runtime.begin_user_script_reload(json!({ "ownerEpoch": "old-owner" })).await; + assert!(result.is_err()); + assert!(runtime.legacy_reload.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn legacy_reload_returns_only_the_confirmed_current_revision() { + for changed in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + dir.path().join("user"), + dir.path().join("settings.json"), + ); + let revision = manager.reload_snapshot().unwrap().1; + let runtime = Arc::new(LauncherRuntimeService::new(0, manager)); + let waiting = runtime.clone(); + let task = tokio::spawn(async move { + waiting.begin_user_script_reload(json!({})).await + }); + let id = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(id) = runtime.legacy_reload.lock().unwrap().clone() { + break id; + } + tokio::task::yield_now().await; + } + }).await.unwrap(); + runtime.reload_owner.finish_for_test(&id, "applied", json!({ + "revision": if changed { "older-revision" } else { &revision }, + "mode": "scripts", "inventory": { "scripts": [] }, + })); + let result = tokio::time::timeout(Duration::from_secs(3), task).await.unwrap().unwrap(); + if changed { + assert!(result.unwrap_err().to_string().contains("配置已变化")); + } else { + let inventory = result.unwrap(); + assert_eq!(inventory["scripts"], json!([])); + assert_eq!(inventory["reload_mode"], "scripts"); + } + assert!(runtime.legacy_reload.lock().unwrap().is_none()); + } + } + + #[test] + fn legacy_reload_inventory_requires_a_confirmed_script_list() { + assert!(legacy_reload_inventory(&json!({ "inventory": {} }), None).is_err()); + assert!(legacy_reload_inventory(&json!({ + "revision": "current", "inventory": {} + }), Some("current")).is_err()); + assert!(legacy_reload_inventory(&json!({ + "revision": "old", "inventory": { "scripts": [] } + }), Some("new")).is_err()); + assert!(legacy_reload_inventory(&json!({ + "revision": "old", "inventory": { "scripts": [] } + }), None).is_err()); + let result = legacy_reload_inventory(&json!({ + "revision": "current", "inventory": { "scripts": [] }, "mode": "scripts" + }), Some("current")).unwrap(); + assert_eq!(result["scripts"], json!([])); + assert_eq!(result["reload_mode"], "scripts"); + } + + #[tokio::test] + async fn identified_reload_ack_does_not_depend_on_inventory_scan() { + let dir = tempfile::tempdir().unwrap(); + let invalid_user_dir = dir.path().join("not-a-directory"); + std::fs::write(&invalid_user_dir, b"fixture").unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + invalid_user_dir, + dir.path().join("settings.json"), + ); + assert!(manager.inventory().is_err()); + let runtime = LauncherRuntimeService::new(0, manager); + let id = codex_plus_core::user_script_control::new_operation_id(runtime.reload_owner.epoch()); + let payload = json!({ "operationId": id, "ownerEpoch": runtime.reload_owner.epoch(), + "allowPage": false, "sourceDocumentId": "document" }); + let accepted = runtime.begin_user_script_reload_operation_v1(payload.clone()).await.unwrap(); + assert_eq!(accepted["reloadOperation"]["operationId"], id); + assert_eq!(accepted["reloadOperation"]["phase"], "accepted"); + assert_eq!(accepted["desiredRevisionPending"], false); + assert!(accepted.get("desiredRevision").is_some()); + assert!(accepted.get("scripts").is_none()); + + let repeated = runtime.begin_user_script_reload_operation_v1(payload).await.unwrap(); + assert_eq!(repeated["reloadOperation"]["operationId"], id); + assert_eq!(repeated["reloadOperation"]["phase"], "accepted"); + assert_eq!(repeated["desiredRevisionPending"], false); + let status = runtime.user_script_reload_status(json!({ "operationId": id })).await.unwrap(); + assert_eq!(status["reloadOperation"]["operationId"], id); + } + + #[tokio::test] + async fn versioned_reload_requires_complete_identity_before_registration() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + dir.path().join("user"), + dir.path().join("settings.json"), + ); + let runtime = LauncherRuntimeService::new(0, manager); + let epoch = runtime.reload_owner.epoch(); + let id = codex_plus_core::user_script_control::new_operation_id(epoch); + let valid = json!({ + "operationId": id, "ownerEpoch": epoch, "sourceDocumentId": "document", + "allowPage": false, "verificationId": "a".repeat(32) + }); + let mut invalid = vec![Value::Null, json!([])]; + for field in ["operationId", "ownerEpoch", "sourceDocumentId", "allowPage"] { + let mut payload = valid.clone(); + payload.as_object_mut().unwrap().remove(field); + invalid.push(payload); + } + for (field, value) in [ + ("operationId", json!("bad-id")), + ("operationId", json!(null)), + ("ownerEpoch", json!("old-owner")), + ("ownerEpoch", json!(false)), + ("sourceDocumentId", json!("")), + ("sourceDocumentId", json!("\u{00e9}".repeat(129))), + ("sourceDocumentId", json!(null)), + ("allowPage", json!("false")), + ("allowPage", json!(null)), + ("verificationId", json!("short")), + ("verificationId", json!(null)), + ] { + let mut payload = valid.clone(); + payload[field] = value; + invalid.push(payload); + } + for payload in invalid { + assert!(runtime.begin_user_script_reload_operation_v1(payload.clone()).await.is_err(), "{payload}"); + assert!(runtime.reload_owner.status(&id).is_none()); + } + + let capabilities = runtime.user_script_reload_status(json!({ "capabilities": true })).await.unwrap(); + assert_eq!(capabilities["capabilities"]["fencedSubmitV1"], true); + let accepted = runtime.begin_user_script_reload_operation_v1(valid.clone()).await.unwrap(); + assert_eq!(accepted["reloadOperation"]["operationId"], id); + assert_eq!(accepted["reloadOperation"]["sourceDocumentId"], "document"); + assert_eq!(accepted["reloadOperation"]["allowPage"], false); + let repeated = runtime.begin_user_script_reload_operation_v1(valid).await.unwrap(); + assert_eq!(repeated["reloadOperation"]["operationId"], id); + let conflicting = json!({ + "operationId": id, "ownerEpoch": epoch, "sourceDocumentId": "document", + "allowPage": true + }); + assert!(runtime.begin_user_script_reload_operation_v1(conflicting).await.is_err()); + let status = runtime.user_script_reload_status(json!({ "operationId": id })).await.unwrap(); + assert_eq!(status["reloadOperation"]["operationId"], id); + + let page_id = codex_plus_core::user_script_control::new_operation_id(epoch); + let page = runtime.begin_user_script_reload_operation_v1(json!({ + "operationId": page_id, "ownerEpoch": epoch, "sourceDocumentId": "document", + "allowPage": true + })).await.unwrap(); + assert_eq!(page["reloadOperation"]["allowPage"], true); + } + + #[tokio::test] + async fn legacy_identified_reload_does_not_require_a_source_document() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new( + dir.path().join("builtin"), + dir.path().join("user"), + dir.path().join("settings.json"), + ); + let runtime = LauncherRuntimeService::new(0, manager); + let id = codex_plus_core::user_script_control::new_operation_id(runtime.reload_owner.epoch()); + let accepted = runtime.begin_user_script_reload(json!({ + "operationId": id, "allowPage": true + })).await.unwrap(); + assert_eq!(accepted["reloadOperation"]["operationId"], id); + assert_eq!(accepted["reloadOperation"]["sourceDocumentId"], Value::Null); + assert!(accepted["scripts"].is_array()); } #[test] diff --git a/apps/codex-plus-launcher/src/user_script_reload.rs b/apps/codex-plus-launcher/src/user_script_reload.rs index 1dd0692e0d..cabcb4f69e 100644 --- a/apps/codex-plus-launcher/src/user_script_reload.rs +++ b/apps/codex-plus-launcher/src/user_script_reload.rs @@ -104,14 +104,25 @@ impl ReloadOwner { .filter(|id| !id.is_empty()).map(str::to_owned).unwrap_or_else(|| new_operation_id(&self.epoch)); let issued_at = operation_issued_at(&self.epoch, &id).context("重载操作编号无效或属于旧 owner")?; anyhow::ensure!(issued_at <= now_ms().saturating_add(30_000), "重载操作编号时间无效"); + if let Some(epoch) = payload.get("ownerEpoch") { + anyhow::ensure!(epoch.as_str() == Some(&self.epoch), "重载 owner 已变化;没有提交旧操作"); + } let allow_page = payload.get("allowPage").and_then(Value::as_bool).unwrap_or(true); + let source_document = match payload.get("sourceDocumentId") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_str().filter(|value| !value.is_empty() && value.len() <= 256) + .context("重载来源文档标识无效")?.to_owned()), + }; let record = json!({ "operationId": id, "ownerEpoch": self.epoch, "allowPage": allow_page, "phase": "accepted", "active": true, + "sourceDocumentId": source_document, "issuedAt": issued_at, "createdAt": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 }); { let mut state = self.state.lock().unwrap(); if let Some(previous) = state.get(&id) { - anyhow::ensure!(previous["allowPage"] == allow_page, "重载操作编号与已有请求冲突"); + anyhow::ensure!(previous["allowPage"] == allow_page + && previous["sourceDocumentId"] == record["sourceDocumentId"], + "重载操作编号与已有请求冲突"); return Ok(previous.clone()); } anyhow::ensure!(issued_at > self.retired_before.load(Ordering::Relaxed), @@ -136,7 +147,7 @@ impl ReloadOwner { } let owner = self.clone(); tokio::spawn(async move { - owner.observe(&id, Duration::from_secs(45), owner.run(&id, allow_page, &websocket, &manager)).await; + owner.observe(&id, Duration::from_secs(45), owner.run(&id, allow_page, source_document.as_deref(), &websocket, &manager)).await; }); Ok(record) } @@ -156,6 +167,11 @@ impl ReloadOwner { self.state.lock().unwrap().get(id).cloned() } + #[cfg(test)] + pub(super) fn finish_for_test(&self, id: &str, phase: &str, fields: Value) { + self.update(id, phase, fields); + } + pub(super) fn control_status(&self, id: &str) -> Value { if let Some(record) = self.status(id) { return record; } let retired = codex_plus_core::user_script_control::operation_issued_at(&self.epoch, id) @@ -174,6 +190,7 @@ impl ReloadOwner { fn update(&self, id: &str, phase: &str, fields: Value) { let mut state = self.state.lock().unwrap(); let Some(record) = state.get_mut(id) else { return }; + if record["active"] == false { return; } record["phase"] = json!(phase); if matches!(phase, "applied" | "failed" | "blocked" | "degraded" | "rejected") { record["active"] = json!(false); @@ -188,37 +205,168 @@ impl ReloadOwner { ); } - async fn run(&self, id: &str, allow_page: bool, websocket: &Arc>>, manager: &UserScriptManager) { + async fn run(&self, id: &str, allow_page: bool, source_document: Option<&str>, websocket: &Arc>>, manager: &UserScriptManager) { // Keep other reloads out even while a new document's bootstrap is allowed to run. let _operation_guard = self.operations.lock().await; + // 同一操作不能在目标切换后静默转交给另一个 CDP target。 + let target = loop { + let current = websocket.lock().unwrap().clone(); + if let Some(url) = current { + break url; + } + self.update(id, "unknown", json!({ "reason": "页面尚未连接,操作结果待确认" })); + tokio::time::sleep(Duration::from_millis(500)).await; + }; + let pinned = Arc::new(Mutex::new(Some(target.clone()))); + self.run_until_target_changes(id, websocket, &target, + self.run_on_target(id, allow_page, source_document, websocket, &target, &pinned, manager)).await; + } + + async fn run_until_target_changes( + &self, + id: &str, + current: &Arc>>, + target: &str, + operation: F, + ) + where + F: std::future::Future, + { + tokio::pin!(operation); + tokio::select! { + _ = &mut operation => {} + _ = wait_for_target_change(current, target) => { + self.update(id, "unknown", json!({ + "reason": "页面目标已切换;原操作仍在等待旧页面确认", + "active": true, + })); + operation.await; + } + } + } + + async fn run_on_target(&self, id: &str, allow_page: bool, source_document: Option<&str>, current: &Arc>>, target: &str, websocket: &Arc>>, manager: &UserScriptManager) { + self.run_on_target_with(id, allow_page, source_document, current, target, websocket, manager, + |websocket, expression| { + let websocket = websocket.clone(); + let expression = expression.to_owned(); + async move { evaluate(&websocket, &expression).await } + }).await; + } + + async fn run_on_target_with(&self, id: &str, allow_page: bool, source_document: Option<&str>, current: &Arc>>, target: &str, websocket: &Arc>>, manager: &UserScriptManager, evaluate_page: F) + where + F: Fn(&Arc>>, &str) -> Fut, + Fut: std::future::Future>, + { let mut attempt = 0_u64; + let mut prepare_sent = false; + let mut mutation_confirmed = false; + let mut applied_once = false; + let mut stopped_source: Option<(String, u64)> = None; loop { - let guard = self.execution.lock().await; attempt += 1; let operation_id = format!("{id}-{attempt:x}"); let sequence = next_sequence(); let (bundle, revision) = match snapshot(manager).await { Ok(snapshot) => snapshot, Err(error) => { - self.update(id, "failed", json!({ "reason": format!("{error:#}") })); + self.update(id, if stopped_source.is_some() || applied_once { "degraded" } else { "failed" }, + json!({ "reason": format!("{error:#}") })); return; } }; self.update(id, "accepted", json!({ "revision": revision, "attempt": attempt })); - let before = self.wait_probe(id, websocket, &operation_id).await; + // 探测本身不执行清理。旧目标不可达时不能一直占用初始化锁。 + loop { + if target_changed(current, target) { + if self.finish_on_target_change(id, prepare_sent, mutation_confirmed) { + return; + } + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + if evaluate_page(websocket, &build_probe_with_page_receipt(&operation_id)).await + .is_ok_and(|value| value["documentId"].is_string()) { break; } + self.update(id, "unknown", json!({ "reason": "页面暂未响应,等待确认" })); + tokio::time::sleep(Duration::from_millis(500)).await; + } + let guard = self.execution.lock().await; + if target_changed(current, target) { + drop(guard); + if self.finish_on_target_change(id, prepare_sent, mutation_confirmed) { + return; + } + continue; + } + // 获取锁后重新探测,防止等待锁期间 bootstrap 或导航改变文档。 + let before = match evaluate_page(websocket, &build_probe_with_page_receipt(&operation_id)).await { + Ok(value) if value["documentId"].is_string() => value, + _ => { + drop(guard); + self.update(id, "unknown", json!({ "reason": "页面暂未响应,等待确认" })); + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + }; + if target_changed(current, target) { + drop(guard); + if self.finish_on_target_change(id, prepare_sent, mutation_confirmed) { + return; + } + continue; + } let document = before["documentId"].as_str().unwrap_or_default().to_string(); if document.is_empty() { continue; } + if !prepare_sent && source_document.is_some_and(|source| source != document) { + self.update(id, "rejected", json!({ "reason": "来源页面已切换;未把旧请求应用到新页面" })); + return; + } + if stopped_source.as_ref().is_some_and(|(stopped_document, _)| stopped_document != &document) { + drop(guard); + if self.verify_navigation(id, websocket, &operation_id, + &stopped_source.as_ref().unwrap().0, "navigation", manager).await { + return; + } + stopped_source = None; + continue; + } let mut generation = codex_plus_core::extension_backend::backend_generation(manager); - let source_revoked = allow_page || before["cleanupSupported"] == true; - if source_revoked { - match codex_plus_core::extension_backend::revoke_backend_document(manager, generation).await { + let source_revoked = stopped_source.is_some() || allow_page || before["cleanupSupported"] == true; + if let Some((_, stopped_generation)) = stopped_source.as_ref() { + if before["initialized"] != true || !before["backendSource"].is_null() + || generation != *stopped_generation + { + self.update(id, "degraded", json!({ "reason": "页面清理后文档或后台代际已变化,未应用新快照" })); + return; + } + generation = *stopped_generation; + } else if source_revoked { + if before["initialized"] != true || !before["backendSource"].is_object() { + drop(guard); + self.update(id, "unknown", json!({ "reason": "等待页面初始化及后台来源确认" })); + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + match revoke_probed_backend_source(manager, &document, &before).await { Some(next) => generation = next, None => { - self.update(id, "degraded", json!({ "reason": "后台代际已变化,未开始页面清理" })); + self.update(id, "degraded", json!({ "reason": "页面文档或后台来源已变化,未开始页面清理" })); return; } } } + if target_changed(current, target) { + if source_revoked && (!prepare_sent || mutation_confirmed) { + self.update(id, "degraded", json!({ "reason": "页面目标已切换,旧后台来源已撤销;请检查或重新加载页面" })); + return; + } + drop(guard); + if self.finish_on_target_change(id, prepare_sent, mutation_confirmed) { + return; + } + continue; + } self.update(id, "quiescing", json!({ "documentId": document })); let preparation = json!({ "id": operation_id, "documentId": document, "revision": revision, @@ -226,21 +374,24 @@ impl ReloadOwner { }); let prepare = build_prepare_with_page_receipt(&preparation); // A timeout does not cancel CDP execution. Never resend this prepare. - let acknowledged = evaluate(websocket, &prepare).await.ok(); + prepare_sent = true; + mutation_confirmed = false; + let acknowledged = evaluate_page(&websocket, &prepare).await.ok(); if let Some(response) = &acknowledged { if response["phase"] == "rejected" || response["phase"] == "failed" { - self.update(id, "failed", json!({ "reason": response["reason"] })); + self.update(id, if source_revoked { "degraded" } else { "failed" }, + json!({ "reason": if source_revoked { "页面准备未完成,旧后台来源已撤销;请检查当前页面" } else { response["reason"].as_str().unwrap_or("页面准备失败") } })); return; } } let prepared = self.wait_preparation_with_probe(id, &preparation, acknowledged, || { - self.wait_probe(id, websocket, &operation_id) + self.wait_probe_with(id, &websocket, &operation_id, &evaluate_page) }).await; let Some(prepared) = prepared else { // Navigation can destroy the prepare response before it is read. // Let the new document's bootstrap acquire the same execution permit. drop(guard); - if self.verify_navigation(id, websocket, &operation_id, &document, "navigation", manager).await { + if self.verify_navigation(id, &websocket, &operation_id, &document, "navigation", manager).await { return; } continue; @@ -257,20 +408,37 @@ impl ReloadOwner { "pageRequested" => { self.update(id, "pageRequested", json!({ "mode": "page", "reason": prepared["reason"] })); drop(guard); - if self.verify_navigation(id, websocket, &operation_id, &document, "page", manager).await { + if self.verify_navigation(id, &websocket, &operation_id, &document, "page", manager).await { return; } continue; } - "stopped" => {} + "stopped" => mutation_confirmed = true, _ => { - self.update(id, "failed", json!({ "reason": "无法确认页面清理状态" })); + self.update(id, if source_revoked { "degraded" } else { "failed" }, + json!({ "reason": "无法确认页面清理状态;请检查当前页面" })); return; } } self.update(id, "stopped", json!({})); - if snapshot(manager).await.ok().map(|snapshot| snapshot.1) != Some(revision.clone()) { - continue; + if target_changed(current, target) { + self.finish_on_target_change(id, prepare_sent, mutation_confirmed); + return; + } + match snapshot(manager).await { + Ok((_, current_revision)) if current_revision == revision => {} + Ok(_) => { + stopped_source = Some((document, generation)); + continue; + } + Err(error) => { + self.update(id, "degraded", json!({ "reason": format!("页面已清理,但无法重新读取拓展快照;请修复后重新加载页面:{error:#}") })); + return; + } + } + if target_changed(current, target) { + self.finish_on_target_change(id, prepare_sent, mutation_confirmed); + return; } if !codex_plus_core::extension_backend::stop_all_if_generation(manager, generation).await { self.update(id, "degraded", json!({ "reason": "后台代际已变化,页面已清理但未重新加载" })); @@ -282,41 +450,62 @@ impl ReloadOwner { { Ok(staged) => staged, Err(error) => { - self.update(id, "degraded", json!({ "reason": format!("无法固定后台快照:{error:#}") })); + self.update(id, "degraded", json!({ "reason": format!("页面已清理,无法固定后台快照;请修复后重新加载页面:{error:#}") })); return; } }; + if target_changed(current, target) { + self.finish_on_target_change(id, prepare_sent, mutation_confirmed); + return; + } let backend_source = match codex_plus_core::extension_backend::bind_backend_snapshot( manager, generation.wrapping_add(1), &document, staged, ).await { Ok(source) => source, Err(error) => { - self.update(id, "degraded", json!({ "reason": format!("无法绑定页面后台代际:{error:#}") })); + self.update(id, "degraded", json!({ "reason": format!("页面已清理,无法绑定页面后台代际;请重新加载页面:{error:#}") })); return; } }; + if target_changed(current, target) { + self.finish_on_target_change(id, prepare_sent, mutation_confirmed); + return; + } + stopped_source = None; self.update(id, "applying", json!({})); let apply = UserScriptManager::build_apply_operation(&operation_id, &document, &revision, &bundle, &backend_source); // As with prepare, a lost response is resolved by querying the same operation. - if let Ok(response) = evaluate(websocket, &apply).await { + mutation_confirmed = false; + if let Ok(response) = evaluate_page(&websocket, &apply).await { if response["operation"]["phase"] == "rejected" || response["operation"]["phase"] == "failed" { self.update(id, "failed", json!({ "reason": response["operation"]["reason"] })); return; } } loop { - let probe = self.wait_probe(id, websocket, &operation_id).await; + let probe = self.wait_probe_with(id, &websocket, &operation_id, &evaluate_page).await; if probe["documentId"] != document { break; } + if !preparation_identity_matches(&probe["operation"], &preparation) { + self.update(id, "unknown", json!({ "reason": "页面应用回执身份不匹配,操作结果待确认" })); + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } let phase = probe["operation"]["phase"].as_str().unwrap_or_default(); if phase == "failed" || phase == "rejected" || phase == "superseded" { self.update(id, "failed", json!({ "reason": probe["operation"]["reason"] })); return; } if phase == "verifying" && probe["revision"] == revision && probe["ready"] == true { + applied_once = true; + mutation_confirmed = true; if !codex_plus_core::extension_backend::backend_source_is_current(manager, &probe["backendSource"]) { self.update(id, "degraded", json!({ "reason": "页面脚本已执行,但后台来源已失效" })); return; } + if target_changed(current, target) { + self.finish_on_target_change(id, prepare_sent, mutation_confirmed); + return; + } if snapshot(manager).await.ok().map(|snapshot| snapshot.1) != Some(revision.clone()) { break; } @@ -329,6 +518,19 @@ impl ReloadOwner { } } + fn finish_on_target_change(&self, id: &str, prepare_sent: bool, mutation_confirmed: bool) -> bool { + if !prepare_sent { + self.update(id, "rejected", json!({ "reason": "页面目标已切换,未开始页面清理" })); + return true; + } + if mutation_confirmed { + self.update(id, "degraded", json!({ "reason": "页面目标已切换;此前操作已确认,但新页面尚未验证,请检查或重新加载页面" })); + return true; + } + self.update(id, "unknown", json!({ "reason": "页面目标已切换,原操作仍待确认" })); + false + } + async fn wait_preparation_with_probe( &self, id: &str, @@ -353,8 +555,17 @@ impl ReloadOwner { if observed["documentId"] != preparation["documentId"] { return None; } - if observed["operation"].is_object() { - return Some(observed["operation"].clone()); + let operation = &observed["operation"]; + if preparation_identity_matches(operation, preparation) { + let complete = match operation["phase"].as_str().unwrap_or_default() { + "stopped" => operation["mode"] == "scripts", + "pageRequested" => page_receipt_matches(operation, preparation), + "blocked" | "degraded" | "failed" | "rejected" => true, + _ => false, + }; + if complete { + return Some(operation.clone()); + } } self.update(id, "unknown", json!({ "reason": "等待页面确认清理操作" })); tokio::time::sleep(Duration::from_millis(500)).await; @@ -429,8 +640,20 @@ impl ReloadOwner { } async fn wait_probe(&self, id: &str, websocket: &Arc>>, operation_id: &str) -> Value { + self.wait_probe_with(id, websocket, operation_id, &|websocket, expression| { + let websocket = websocket.clone(); + let expression = expression.to_owned(); + async move { evaluate(&websocket, &expression).await } + }).await + } + + async fn wait_probe_with(&self, id: &str, websocket: &Arc>>, operation_id: &str, evaluate_page: &F) -> Value + where + F: Fn(&Arc>>, &str) -> Fut, + Fut: std::future::Future>, + { loop { - if let Ok(value) = evaluate(websocket, &build_probe_with_page_receipt(operation_id)).await { + if let Ok(value) = evaluate_page(websocket, &build_probe_with_page_receipt(operation_id)).await { if value["documentId"].is_string() { return value; } } self.update(id, "unknown", json!({ "reason": "页面暂未响应,操作结果待确认" })); @@ -457,10 +680,34 @@ impl ReloadOwner { } } +async fn wait_for_target_change(current: &Arc>>, target: &str) { + loop { + if target_changed(current, target) { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn target_changed(current: &Arc>>, target: &str) -> bool { + current.lock().unwrap().as_deref().is_some_and(|url| url != target) +} + +async fn revoke_probed_backend_source(manager: &UserScriptManager, document: &str, probe: &Value) -> Option { + if probe.pointer("/backendSource/documentId").and_then(Value::as_str) != Some(document) { + return None; + } + codex_plus_core::extension_backend::revoke_backend_source(manager, &probe["backendSource"]).await +} + +fn preparation_identity_matches(receipt: &Value, preparation: &Value) -> bool { + ["id", "documentId", "revision", "allowPage", "sequence"].iter() + .all(|key| receipt.get(*key).is_some_and(|value| preparation.get(*key) == Some(value))) +} + fn page_receipt_matches(receipt: &Value, preparation: &Value) -> bool { - receipt["phase"] == "pageRequested" && receipt["allowPage"] == true - && ["id", "documentId", "revision", "allowPage", "sequence"].iter() - .all(|key| receipt.get(*key).is_some_and(|value| preparation.get(*key) == Some(value))) + receipt["phase"] == "pageRequested" && receipt["mode"] == "page" && receipt["allowPage"] == true + && preparation_identity_matches(receipt, preparation) } fn build_prepare_with_page_receipt(preparation: &Value) -> String { @@ -539,6 +786,17 @@ mod tests { }) } + fn fixture_preparation(expression: &str) -> Value { + let encoded = expression.lines() + .find_map(|line| line.trim().strip_prefix("const expected = ")) + .and_then(|line| line.strip_suffix(';')) + .expect("准备操作缺少预期身份"); + let mut operation: Value = serde_json::from_str(encoded).unwrap(); + operation["phase"] = json!("stopped"); + operation["mode"] = json!("scripts"); + operation + } + fn probe_after_requested_page_reload(preparation: &Value, storage_available: bool) -> Value { let prepare = build_prepare_with_page_receipt(preparation); let probe = build_probe_with_page_receipt(preparation["id"].as_str().unwrap()); @@ -581,6 +839,302 @@ process.stdout.write(JSON.stringify(observed)); serde_json::from_slice(&output.stdout).unwrap() } + #[tokio::test] + async fn changed_snapshot_after_cleanup_is_prepared_again_on_the_same_document() { + let dir = tempfile::tempdir().unwrap(); + let result = exercise_changed_snapshot(dir.path(), "change").await; + assert_eq!(result.0["phase"], "applied"); + assert_eq!(result.1, 2); + assert_eq!(result.2, 1); + } + + #[tokio::test] + async fn snapshot_error_after_cleanup_reports_degraded_without_reapplying() { + let dir = tempfile::tempdir().unwrap(); + let result = exercise_changed_snapshot(dir.path(), "invalid").await; + assert_eq!(result.0["phase"], "degraded"); + assert_eq!(result.1, 1); + assert_eq!(result.2, 0); + } + + #[tokio::test] + async fn lost_cleanup_response_is_confirmed_without_repeating_cleanup() { + let dir = tempfile::tempdir().unwrap(); + let result = exercise_changed_snapshot(dir.path(), "lost_prepare").await; + assert_eq!(result.0["phase"], "applied"); + assert_eq!(result.1, 1); + assert_eq!(result.2, 1); + } + + #[tokio::test] + async fn timed_out_cleanup_can_finish_later_without_being_resent() { + let dir = tempfile::tempdir().unwrap(); + let result = exercise_changed_snapshot(dir.path(), "deferred_prepare").await; + assert_eq!(result.0["phase"], "applied"); + assert_eq!(result.1, 1); + assert_eq!(result.2, 1); + } + + #[tokio::test] + async fn lost_apply_response_is_confirmed_without_repeating_application() { + let dir = tempfile::tempdir().unwrap(); + let result = exercise_changed_snapshot(dir.path(), "lost_apply").await; + assert_eq!(result.0["phase"], "applied"); + assert_eq!(result.1, 1); + assert_eq!(result.2, 1); + } + + #[tokio::test] + async fn target_change_after_confirmed_apply_does_not_block_later_operations() { + let dir = tempfile::tempdir().unwrap(); + let result = exercise_changed_snapshot(dir.path(), "target_after_apply").await; + assert_eq!(result.0["phase"], "degraded"); + assert_eq!(result.0["active"], false); + assert_eq!(result.1, 1); + assert_eq!(result.2, 1); + } + + #[tokio::test] + async fn target_change_after_confirmed_cleanup_does_not_block_later_operations() { + let dir = tempfile::tempdir().unwrap(); + let user = dir.path().join("user"); + std::fs::create_dir(&user).unwrap(); + let script = user.join("fixture.js"); + std::fs::write(&script, "window.fixtureVersion = 'old';").unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), &user, dir.path().join("scripts.json")); + let owner = ReloadOwner::default(); + let generation = codex_plus_core::extension_backend::backend_generation(&manager); + let source = codex_plus_core::extension_backend::bind_backend_document(&manager, generation, "document").await.unwrap(); + let id = codex_plus_core::user_script_control::new_operation_id(owner.epoch()); + owner.state.lock().unwrap().insert(id.clone(), json!({ "active": true })); + let current = Arc::new(Mutex::new(Some("old-target".to_string()))); + let pinned = Arc::new(Mutex::new(Some("old-target".to_string()))); + let changed_target = current.clone(); + let cleanups = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let applications = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let operation = Arc::new(Mutex::new(None::)); + let cleanup_count = cleanups.clone(); + let application_count = applications.clone(); + let task = async { + let _operation_guard = owner.operations.lock().await; + owner.run_on_target_with( + &id, true, Some("document"), ¤t, "old-target", &pinned, &manager, + move |_, expression| { + let changed_target = changed_target.clone(); + let cleanups = cleanup_count.clone(); + let applications = application_count.clone(); + let source = source.clone(); + let script = script.clone(); + let operation = operation.clone(); + let expression = expression.to_owned(); + async move { + if expression.contains("const encoded =") { + cleanups.fetch_add(1, Ordering::SeqCst); + std::fs::write(&script, "window.fixtureVersion = 'new';").unwrap(); + let prepared = fixture_preparation(&expression); + *operation.lock().unwrap() = Some(prepared.clone()); + Ok(prepared) + } else if expression.contains("const operation = runtime.applyOperation") { + applications.fetch_add(1, Ordering::SeqCst); + Ok(json!({ "operation": { "phase": "verifying" } })) + } else { + let stopped = cleanups.load(Ordering::SeqCst) > 0; + if stopped { + *changed_target.lock().unwrap() = Some("new-target".to_string()); + } + Ok(json!({ "documentId": "document", "initialized": true, "ready": true, + "cleanupSupported": true, "backendSource": if stopped { Value::Null } else { source }, + "operation": operation.lock().unwrap().clone() })) + } + } + }, + ).await; + }; + let completed = tokio::time::timeout(Duration::from_secs(2), task).await; + let status = owner.status(&id).unwrap(); + let next_can_start = tokio::time::timeout(Duration::from_secs(1), owner.operations.lock()).await.is_ok(); + codex_plus_core::extension_backend::stop_all(&manager).await; + assert!(completed.is_ok(), "已确认清理后的目标切换不能无限占用重载队列"); + assert_eq!(status["phase"], "degraded"); + assert_eq!(status["active"], false); + assert_eq!(cleanups.load(Ordering::SeqCst), 1); + assert_eq!(applications.load(Ordering::SeqCst), 0); + assert!(next_can_start); + } + + async fn exercise_changed_snapshot(root: &std::path::Path, after_prepare: &str) -> (Value, usize, usize) { + let user = root.join("user"); + std::fs::create_dir(&user).unwrap(); + let script = user.join("fixture.js"); + std::fs::write(&script, "window.fixtureVersion = 'old';").unwrap(); + let config = root.join("scripts.json"); + let manager = UserScriptManager::new(root.join("builtin"), &user, &config); + let owner = ReloadOwner::default(); + let generation = codex_plus_core::extension_backend::backend_generation(&manager); + let source = codex_plus_core::extension_backend::bind_backend_document(&manager, generation, "document").await.unwrap(); + let id = codex_plus_core::user_script_control::new_operation_id(owner.epoch()); + owner.state.lock().unwrap().insert(id.clone(), json!({ "active": true })); + let websocket = Arc::new(Mutex::new(Some("mock-target".to_string()))); + let old_evaluations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let new_evaluations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let old_cleanup = old_evaluations.clone(); + let new_apply = new_evaluations.clone(); + let probe_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let probes = probe_count.clone(); + let first_source = source.clone(); + let evaluator_manager = manager.clone(); + let operation = Arc::new(Mutex::new(None::)); + let invalid = after_prepare == "invalid"; + let change_after_apply = after_prepare == "target_after_apply"; + let lost_prepare = after_prepare == "lost_prepare"; + let deferred_prepare = after_prepare == "deferred_prepare"; + let lost_apply = after_prepare == "lost_apply"; + let current_target = websocket.clone(); + let task = async { + let _operation_guard = owner.operations.lock().await; + owner.run_on_target_with( + &id, true, Some("document"), &websocket, "mock-target", &websocket, &manager, + move |_, expression| { + let old_cleanup = old_cleanup.clone(); + let new_apply = new_apply.clone(); + let probes = probes.clone(); + let first_source = first_source.clone(); + let script = script.clone(); + let config = config.clone(); + let manager = evaluator_manager.clone(); + let operation = operation.clone(); + let current_target = current_target.clone(); + let expression = expression.to_owned(); + async move { + if expression.contains("const encoded =") { + if old_cleanup.fetch_add(1, Ordering::SeqCst) == 0 { + if invalid { + std::fs::write(&config, "{invalid").unwrap(); + } else if !change_after_apply && !lost_prepare && !deferred_prepare && !lost_apply { + std::fs::write(&script, "window.fixtureVersion = 'new';").unwrap(); + } + } + let prepared = fixture_preparation(&expression); + if deferred_prepare { + let pending = operation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(125)).await; + *pending.lock().unwrap() = Some(prepared); + }); + anyhow::bail!("模拟 CDP 超时后页面才完成清理"); + } + *operation.lock().unwrap() = Some(prepared.clone()); + if lost_prepare { + anyhow::bail!("模拟清理已执行但 CDP 响应超时"); + } + Ok(prepared) + } else if expression.contains("const operation = runtime.applyOperation") { + assert!(expression.contains(if change_after_apply || lost_prepare || deferred_prepare || lost_apply { + "window.fixtureVersion = 'old';" + } else { + "window.fixtureVersion = 'new';" + })); + new_apply.fetch_add(1, Ordering::SeqCst); + let mut applied = operation.lock().unwrap(); + applied.as_mut().unwrap()["phase"] = json!("verifying"); + if change_after_apply { + std::fs::write(&script, "window.fixtureVersion = 'new';").unwrap(); + } + if lost_apply { + anyhow::bail!("模拟应用已执行但 CDP 响应超时"); + } + Ok(json!({ "operation": applied.clone() })) + } else { + let index = probes.fetch_add(1, Ordering::SeqCst); + let generation = codex_plus_core::extension_backend::backend_generation(&manager); + let operation = operation.lock().unwrap().clone(); + if change_after_apply && new_apply.load(Ordering::SeqCst) > 0 { + *current_target.lock().unwrap() = Some("new-target".to_string()); + } + let backend_source = if index < 2 { + first_source + } else if new_apply.load(Ordering::SeqCst) > 0 { + json!({ "ownerEpoch": first_source["ownerEpoch"], "generation": generation, + "documentId": "document", "revision": operation.as_ref().unwrap()["revision"] }) + } else { + Value::Null + }; + let revision = if new_apply.load(Ordering::SeqCst) > 0 { + operation.as_ref().map(|value| value["revision"].clone()) + } else { + manager.reload_snapshot().ok().map(|snapshot| json!(snapshot.1)) + }; + Ok(json!({ "documentId": "document", "initialized": true, "ready": true, + "cleanupSupported": true, "backendSource": backend_source, + "scripts": { "user:fixture.js": { "status": "loaded", "name": "fixture.js", "source": "user" } }, + "revision": revision, + "operation": operation })) + } + } + }, + ).await; + }; + let completed = tokio::time::timeout(Duration::from_secs(3), task).await; + let status = owner.status(&id).unwrap(); + let prepares = old_evaluations.load(Ordering::SeqCst); + let applies = new_evaluations.load(Ordering::SeqCst); + codex_plus_core::extension_backend::stop_all(&manager).await; + completed.expect("版本变化后重载未完成"); + (status, prepares, applies) + } + + #[tokio::test] + async fn same_document_waits_for_a_matching_complete_preparation_receipt() { + let owner = ReloadOwner::default(); + let preparation = page_preparation(); + let mut matching = preparation.clone(); + matching["phase"] = json!("stopped"); + matching["mode"] = json!("scripts"); + + for (field, value) in [ + ("id", json!("other-attempt")), + ("documentId", json!("other-document")), + ("revision", json!("other-revision")), + ("allowPage", json!(false)), + ("sequence", json!(18)), + ("mode", json!("page")), + ] { + let mut stale = matching.clone(); + stale[field] = value; + let observed = json!({ "documentId": "old-document", "operation": stale }); + let result = tokio::time::timeout(Duration::from_millis(40), + owner.wait_preparation_with_probe("reload", &preparation, None, || { + std::future::ready(observed.clone()) + })).await; + assert!(result.is_err(), "不匹配的清理回执不能确认: {field}"); + } + + let mut incomplete = matching.clone(); + incomplete.as_object_mut().unwrap().remove("sequence"); + let observed = json!({ "documentId": "old-document", "operation": incomplete }); + assert!(tokio::time::timeout(Duration::from_millis(40), + owner.wait_preparation_with_probe("reload", &preparation, None, || { + std::future::ready(observed.clone()) + })).await.is_err(), "缺失字段的清理回执不能确认"); + + let mut denied = preparation.clone(); + denied["allowPage"] = json!(false); + let mut page_requested = denied.clone(); + page_requested["phase"] = json!("pageRequested"); + page_requested["mode"] = json!("page"); + let observed = json!({ "documentId": "old-document", "operation": page_requested }); + assert!(tokio::time::timeout(Duration::from_millis(40), + owner.wait_preparation_with_probe("reload", &denied, None, || { + std::future::ready(observed.clone()) + })).await.is_err(), "禁止刷新时不能接受页面刷新回执"); + + let observed = json!({ "documentId": "old-document", "operation": matching.clone() }); + let prepared = owner.wait_preparation_with_probe("reload", &preparation, None, || { + std::future::ready(observed.clone()) + }).await; + assert_eq!(prepared, Some(matching)); + } + #[tokio::test] async fn acknowledged_page_request_survives_the_next_probe_being_a_new_document() { let owner = ReloadOwner::default(); @@ -694,6 +1248,106 @@ process.stdout.write(JSON.stringify(observed)); next.await.unwrap(); } + #[tokio::test] + async fn changed_target_does_not_cancel_old_coordination_or_release_execution() { + let owner = ReloadOwner::default(); + owner.state.lock().unwrap().insert("target-change".into(), json!({ "active": true })); + let current = Arc::new(Mutex::new(Some("old-target".to_owned()))); + let task_owner = owner.clone(); + let task_current = current.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (finish_tx, finish_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + task_owner.run_until_target_changes("target-change", &task_current, "old-target", async { + let _guard = task_owner.lock_initial_load().await; + let _ = started_tx.send(()); + let _ = finish_rx.await; + task_owner.update("target-change", "applied", json!({})); + }).await; + }); + started_rx.await.unwrap(); + *current.lock().unwrap() = Some("new-target".to_owned()); + tokio::time::timeout(Duration::from_secs(2), async { + while owner.status("target-change").unwrap()["phase"] != "unknown" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.unwrap(); + let status = owner.status("target-change").unwrap(); + assert_eq!(status["active"], true); + assert!(!task.is_finished()); + assert!(tokio::time::timeout(Duration::from_millis(30), owner.lock_initial_load()).await.is_err()); + finish_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(1), task).await.unwrap().unwrap(); + assert_eq!(owner.status("target-change").unwrap()["phase"], "applied"); + let _guard = tokio::time::timeout(Duration::from_secs(1), owner.lock_initial_load()).await.unwrap(); + } + + #[tokio::test] + async fn target_change_before_prepare_rejects_without_blocking_bootstrap() { + let dir = tempfile::tempdir().unwrap(); + let user = dir.path().join("user"); + std::fs::create_dir(&user).unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), user, dir.path().join("scripts.json")); + let owner = ReloadOwner::default(); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let old_target = format!("ws://127.0.0.1:{}/devtools/page/old", listener.local_addr().unwrap().port()); + let (connected_tx, connected_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let _ = connected_tx.send(()); + let _ = release_rx.await; + drop(socket); + }); + let current = Arc::new(Mutex::new(Some(old_target.clone()))); + let pinned = Arc::new(Mutex::new(Some(old_target.clone()))); + let id = codex_plus_core::user_script_control::new_operation_id(owner.epoch()); + owner.state.lock().unwrap().insert(id.clone(), json!({ "active": true })); + let task_owner = owner.clone(); + let task_current = current.clone(); + let task_id = id.clone(); + let task = tokio::spawn(async move { + task_owner.run_on_target(&task_id, true, Some("old-document"), &task_current, &old_target, &pinned, &manager).await; + }); + tokio::time::timeout(Duration::from_secs(2), connected_rx).await.unwrap().unwrap(); + let guard = tokio::time::timeout(Duration::from_secs(1), owner.lock_initial_load()).await.unwrap(); + *current.lock().unwrap() = Some("ws://127.0.0.1:2/devtools/page/new".to_owned()); + release_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while owner.status(&id).unwrap()["phase"] != "rejected" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.unwrap(); + assert_eq!(owner.status(&id).unwrap()["active"], false); + task.await.unwrap(); + server.await.unwrap(); + drop(guard); + } + + #[test] + fn terminal_operation_cannot_be_made_active_again_by_a_late_observer() { + let owner = ReloadOwner::default(); + owner.state.lock().unwrap().insert("finished".into(), json!({ "active": true })); + owner.update("finished", "applied", json!({})); + owner.update("finished", "unknown", json!({ "active": true })); + let status = owner.status("finished").unwrap(); + assert_eq!(status["phase"], "applied"); + assert_eq!(status["active"], false); + } + + #[tokio::test] + async fn mismatched_probe_document_cannot_revoke_current_backend_source() { + let dir = tempfile::tempdir().unwrap(); + let manager = UserScriptManager::new(dir.path().join("builtin"), dir.path().join("user"), dir.path().join("scripts.json")); + let generation = codex_plus_core::extension_backend::backend_generation(&manager); + let source = codex_plus_core::extension_backend::bind_backend_document(&manager, generation, "new-document").await.unwrap(); + let probe = json!({ "backendSource": source }); + assert_eq!(revoke_probed_backend_source(&manager, "old-document", &probe).await, None); + assert_eq!(codex_plus_core::extension_backend::backend_generation(&manager), generation); + assert!(codex_plus_core::extension_backend::backend_source_is_current(&manager, &probe["backendSource"])); + assert_eq!(revoke_probed_backend_source(&manager, "new-document", &probe).await, Some(generation.wrapping_add(1))); + } + #[test] fn inventory_verdict_requires_matching_terminal_states() { let runtime = json!({}); @@ -730,6 +1384,8 @@ process.stdout.write(JSON.stringify(observed)); assert_eq!(first, duplicate); assert_eq!(owner.state.lock().unwrap().len(), 1); assert!(owner.submit(&json!({ "operationId": id, "allowPage": true }), websocket.clone(), manager.clone()).is_err()); + assert!(owner.submit(&json!({ "operationId": id, "allowPage": false, "sourceDocumentId": "other-document" }), websocket.clone(), manager.clone()).is_err()); + assert!(owner.submit(&json!({ "operationId": id, "allowPage": false, "ownerEpoch": "old-owner" }), websocket.clone(), manager.clone()).is_err()); let issued_at = first["issuedAt"].as_u64().unwrap(); owner.state.lock().unwrap().remove(&id); owner.retired_before.store(issued_at, Ordering::Relaxed); diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 9789df1081..735ce2fdac 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -2158,17 +2158,29 @@ export function App() { }; const pendingUserScriptReload = useRef(null); - const reloadUserScripts = async (silent = false) => { - const result = await run(() => call("reload_user_scripts", { - operation: pendingUserScriptReload.current, - })); - if (result) { - const operation = result.user_scripts?.reloadOperation; - pendingUserScriptReload.current = operation?.phase === "unknown" ? operation : null; - setSettings(result); - setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); - showResultNotice(t("本地拓展"), result, { silentSuccess: silent }); - } + const [pendingReload, setPendingReload] = useState(null); + const userScriptReloadQueue = useRef>(Promise.resolve()); + const reloadUserScripts = (silent = false) => { + const request = userScriptReloadQueue.current.then(async () => { + const result = await run(() => call("reload_user_scripts", { + operation: pendingUserScriptReload.current, + })); + if (result) { + const operation = result.user_scripts?.reloadOperation; + if (operation?.phase === "unknown") { + pendingUserScriptReload.current = operation; + setPendingReload(operation); + } else if (operation && ["applied", "failed", "blocked", "degraded", "rejected"].includes(operation.phase)) { + pendingUserScriptReload.current = null; + setPendingReload(null); + } + setSettings(result); + setScriptMarket((current) => syncMarketInstalledState(current, result.user_scripts)); + showResultNotice(t("本地拓展"), result, { silentSuccess: silent }); + } + }); + userScriptReloadQueue.current = request.catch(() => {}); + return request; }; const installMarketScript = async (id: string) => { @@ -5474,7 +5486,7 @@ export function App() { actions={actions} /> ) : null} - {route === "userScripts" ? void navigate("enhance")} onOpenClaude={() => void switchTool("claude", "claude")} actions={actions} /> : null} + {route === "userScripts" ? void navigate("enhance")} onOpenClaude={() => void switchTool("claude", "claude")} actions={actions} /> : null} {route === "pluginMarket" ? : null} {route === "home" ? ( void; settings: SettingsResult | null; market: ScriptMarketResult | null; onOpenBuiltins: () => void; onOpenClaude: () => void; actions: Actions }) { +function UserScriptsScreen({ host, focus = null, onHostChange, settings, market, reloadPending, onOpenBuiltins, onOpenClaude, actions }: { host: ExtensionHost; focus?: AgentFocus; onHostChange: (host: ExtensionHost) => void; settings: SettingsResult | null; market: ScriptMarketResult | null; reloadPending: boolean; onOpenBuiltins: () => void; onOpenClaude: () => void; actions: Actions }) { const codexHost = host === "codex"; const [reloading, setReloading] = useState(false); const reload = async () => { @@ -10731,7 +10743,7 @@ function UserScriptsScreen({ host, focus = null, onHostChange, settings, market, {codexHost ? :