use super::{dispatch::*, endpoint::*, protocol::*, state::*}; #[cfg(not(test))] use crate::GameCreatorManifestInvalidationEventSink; use serde_json::Value; use std::ffi::OsString; use std::fs; use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; const AGENT_RUNNER_LOG_FILE_NAME: &str = "agent-runner.log"; const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024; const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024; #[derive(Default)] pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { generation: u64, registration: Option, } struct ExternalAgentRunnerGuiOwnerRegistration { generation: u64, claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, config_dir: PathBuf, params: ExternalAgentRunnerRequestParams, attached_boot_id: Option, } /// claim 解析模式。 /// /// `Adopt` 用于窗口启动:沿用现有 durable claim,只有 claim 缺失或不可读时才发布。 /// `Publish` 用于本窗口改动了平台登录态:发布新 epoch,成为新的登录态权威。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum ExternalAgentRunnerGuiOwnerClaimMode { Adopt, Publish, } static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< Mutex, > = OnceLock::new(); #[cfg(not(test))] static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock< Mutex>, > = OnceLock::new(); fn external_agent_runner_gui_owner_attachment_state( ) -> &'static Mutex { EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) } #[cfg(not(test))] fn external_agent_runner_gui_participant_lock( ) -> &'static Mutex> { EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None)) } /// 取得并持有本窗口的界面参与锁,直到窗口退出。 /// /// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测 /// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。 #[cfg(not(test))] pub(crate) fn hold_external_agent_runner_gui_participant_lock( config_dir: &Path, ) -> Result<(), String> { let lock = acquire_external_agent_runner_gui_participant_lock(config_dir)?; *lock_unpoisoned(external_agent_runner_gui_participant_lock()) = Some(lock); Ok(()) } #[cfg(not(test))] fn release_external_agent_runner_gui_participant_lock() { drop(lock_unpoisoned(external_agent_runner_gui_participant_lock()).take()); } /// 登记本窗口的 owner claim 与 attach 参数。 /// /// 这里不做 claim 文件 IO:claim 由调用方按 `claim_mode` 解析后写进 `params`, /// 因此该函数可以在没有真实 AppData 的单元测试里使用。 pub(super) fn register_external_agent_runner_gui_owner_attachment( state: &Mutex, config_dir: &Path, claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, mut params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); state.generation = state.generation.wrapping_add(1); let generation = state.generation; if params.gui_owner_session_revision.is_none() { params.gui_owner_session_revision = Some(generation); } state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { generation, claim_mode, config_dir: config_dir.to_path_buf(), params, attached_boot_id: None, }); Ok(()) } pub(super) fn reserve_external_agent_runner_gui_owner_claim_revision( state: &Mutex, ) -> u64 { let mut state = lock_unpoisoned(state); state.generation = state.generation.wrapping_add(1); state.generation } pub(super) fn resolve_external_agent_runner_gui_owner_claim( config_dir: &Path, claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, session_revision: u64, ) -> Result { match claim_mode { ExternalAgentRunnerGuiOwnerClaimMode::Adopt => { adopt_or_publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) } ExternalAgentRunnerGuiOwnerClaimMode::Publish => { publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) } } } pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( state: &Mutex, config_dir: &Path, endpoint: &ExternalAgentRunnerEndpoint, attach: F, ) -> Result<(), String> where F: Fn(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, { const ATTACH_CLAIM_RETRY_LIMIT: usize = 3; let mut last_claim_error = None; for attempt in 0..ATTACH_CLAIM_RETRY_LIMIT { let Some((generation, params, claim_mode)) = ({ let state = lock_unpoisoned(state); state.registration.as_ref().and_then(|registration| { (registration.config_dir == config_dir && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) .then(|| { ( registration.generation, registration.params.clone(), registration.claim_mode, ) }) }) }) else { return Ok(()); }; match attach(endpoint, params) { Ok(()) => { let mut state = lock_unpoisoned(state); if let Some(registration) = state.registration.as_mut() { if registration.generation == generation && registration.config_dir == config_dir { registration.attached_boot_id = Some(endpoint.boot_id.clone()); } } return Ok(()); } Err(error) if attempt + 1 < ATTACH_CLAIM_RETRY_LIMIT && error.contains("claim") => { // 另一个窗口在本次 attach 前后发布了新 claim:按最新 claim 重新解析后重试。 last_claim_error = Some(error); refresh_registered_external_agent_runner_gui_owner_claim( state, config_dir, claim_mode, )?; } Err(error) => return Err(error), } } Err(last_claim_error.unwrap_or_else(|| "Agent Runner attach 重试后仍然失败".to_string())) } fn refresh_registered_external_agent_runner_gui_owner_claim( state: &Mutex, config_dir: &Path, claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, ) -> Result<(), String> { let session_revision = reserve_external_agent_runner_gui_owner_claim_revision(state); let claim = resolve_external_agent_runner_gui_owner_claim(config_dir, claim_mode, session_revision)?; let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_mut() else { return Ok(()); }; if registration.config_dir != config_dir { return Ok(()); } registration.claim_mode = claim_mode; registration.params.gui_owner_epoch = Some(claim.owner_epoch); registration.params.gui_owner_session_revision = Some(claim.session_revision); registration.attached_boot_id = None; Ok(()) } fn redact_url_queries(line: &str) -> String { line.split_whitespace() .map(|token| { if (token.starts_with("http://") || token.starts_with("https://")) && token.contains('?') { let base = token.split_once('?').map(|(base, _)| base).unwrap_or(token); format!("{base}?") } else { token.to_string() } }) .collect::>() .join(" ") } pub(super) fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { let lowercase = line.to_ascii_lowercase(); if [ "authorization", "bearer ", "api_key", "apikey", "api key", "x-api-key", "token=", "token:", "credential", "password", "cookie", "set-cookie", "secret", "access_token", "accesstoken", "platform_access_token", "platformaccesstoken", "refresh_token", "\"token\"", "'token'", ] .iter() .any(|marker| lowercase.contains(marker)) { return "".to_string(); } if lowercase.contains("panic") { return "".to_string(); } let safe_internal_detail = lowercase.starts_with("agent.runner.failed:") || lowercase.starts_with("runner."); if !safe_internal_detail { let summary = if ["error", "failed", "failure", "失败", "错误", "异常"] .iter() .any(|marker| lowercase.contains(marker)) { "" } else if ["warning", "warn:"] .iter() .any(|marker| lowercase.contains(marker)) { "" } else { "" }; return summary.to_string(); } crate::sanitize_diagnostic_message(&redact_url_queries(line), Some(config_dir)) .chars() .take(AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS) .collect() } fn read_bounded_agent_runner_line( reader: &mut R, ) -> io::Result> { let mut content = Vec::new(); let mut truncated = false; let mut saw_bytes = false; loop { let available = reader.fill_buf()?; if available.is_empty() { return if saw_bytes { Ok(Some(( String::from_utf8_lossy(&content).into_owned(), truncated, ))) } else { Ok(None) }; } saw_bytes = true; let newline = available.iter().position(|byte| *byte == b'\n'); let consumed = newline.map(|index| index + 1).unwrap_or(available.len()); let payload_len = newline.unwrap_or(available.len()); let remaining = AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES.saturating_sub(content.len()); let copied = payload_len.min(remaining); content.extend_from_slice(&available[..copied]); if copied < payload_len { truncated = true; } reader.consume(consumed); if newline.is_some() { return Ok(Some(( String::from_utf8_lossy(&content).into_owned(), truncated, ))); } } } fn spawn_agent_runner_log_pump( stream: R, stream_name: &'static str, log_path: PathBuf, config_dir: PathBuf, ) where R: Read + Send + 'static, { let _ = thread::Builder::new() .name(format!("agent-runner-{stream_name}-log")) .spawn(move || { let mut reader = BufReader::new(stream); loop { match read_bounded_agent_runner_line(&mut reader) { Ok(None) => break, Ok(Some((line, truncated))) => { let line = sanitize_agent_runner_output(line.trim(), &config_dir); let _ = crate::append_bounded_diagnostic_line( &log_path, &format!( "runner.{stream_name} truncated={} {line}", if truncated { "true" } else { "false" } ), ); } Err(_) => { let _ = crate::append_bounded_diagnostic_line( &log_path, &format!("runner.{stream_name}.read-failed details=redacted"), ); break; } } } }); } pub(super) struct LaunchedExternalAgentRunner { child: Child, } pub(super) fn launch_external_agent_runner( config_dir: &Path, ) -> Result { let executable = std::env::current_exe() .map_err(|error| format!("读取 Agent Runner 当前二进制失败:{error}"))?; let runner_log_path = config_dir.join(AGENT_RUNNER_LOG_FILE_NAME); let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.begin"); let mut command = Command::new(executable); let gui_owner_required = EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT.load(std::sync::atomic::Ordering::Acquire); command .args(external_agent_runner_launch_arguments( config_dir, gui_owner_required, )) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); #[cfg(unix)] { use std::os::unix::process::CommandExt; // SAFETY: the closure only calls the async-signal-safe setsid syscall before exec. unsafe { command.pre_exec(|| { if libc::setsid() == -1 { Err(io::Error::last_os_error()) } else { Ok(()) } }); } } #[cfg(windows)] { crate::configure_windows_background_std_command(&mut command, true); } let mut child = command .spawn() .map_err(|error| format!("启动外部 Agent Runner 失败:{error}"))?; if let Some(stdout) = child.stdout.take() { spawn_agent_runner_log_pump( stdout, "stdout", runner_log_path.clone(), config_dir.to_path_buf(), ); } if let Some(stderr) = child.stderr.take() { spawn_agent_runner_log_pump( stderr, "stderr", runner_log_path.clone(), config_dir.to_path_buf(), ); } let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.spawned"); Ok(LaunchedExternalAgentRunner { child }) } pub(super) fn external_agent_runner_launch_arguments( config_dir: &Path, gui_owner_required: bool, ) -> Vec { let mut arguments = vec![ OsString::from("--agent-runner"), OsString::from("--config-dir"), config_dir.as_os_str().to_os_string(), ]; if gui_owner_required { arguments.push(OsString::from("--gui-owner-required")); } arguments } pub(super) fn send_external_agent_runner_request_with_protocol_and_id( endpoint: &ExternalAgentRunnerEndpoint, protocol_version: u32, request_id: String, method: &str, params: ExternalAgentRunnerRequestParams, ) -> Result { send_external_agent_runner_request_with_protocol_and_id_and_timeouts( endpoint, protocol_version, request_id, method, params, EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT, external_agent_runner_client_read_timeout(method), EXTERNAL_AGENT_RUNNER_IO_TIMEOUT, ) } fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( endpoint: &ExternalAgentRunnerEndpoint, protocol_version: u32, request_id: String, method: &str, params: ExternalAgentRunnerRequestParams, connect_timeout: Duration, read_timeout: Duration, write_timeout: Duration, ) -> Result { if endpoint.protocol_version != protocol_version { return Err("Agent Runner endpoint 协议版本不兼容".to_string()); } let request = ExternalAgentRunnerRequest { protocol_version, request_id: request_id.clone(), token: endpoint.token.clone(), method: method.to_string(), params, }; let payload = serde_json::to_vec(&request).map_err(|_| "序列化 Agent Runner 请求失败".to_string())?; let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into(); let mut stream = TcpStream::connect_timeout(&address, connect_timeout) .map_err(|error| format!("连接 Agent Runner 失败:{error}"))?; stream .set_read_timeout(Some(read_timeout)) .and_then(|_| stream.set_write_timeout(Some(write_timeout))) .map_err(|error| format!("配置 Agent Runner 客户端超时失败:{error}"))?; write_external_agent_runner_frame(&mut stream, &payload) .map_err(|error| format!("写入 Agent Runner 请求失败:{error}"))?; stream .flush() .map_err(|error| format!("刷新 Agent Runner 请求失败:{error}"))?; let response_payload = read_external_agent_runner_frame(&mut stream) .map_err(|error| format!("读取 Agent Runner 响应失败:{error}"))?; let response = serde_json::from_slice::(&response_payload) .map_err(|_| "解析 Agent Runner 响应失败".to_string())?; if response.protocol_version != protocol_version { return Err("Agent Runner 响应协议版本不兼容".to_string()); } if response.request_id != request_id { return Err("Agent Runner 响应 requestId 不匹配".to_string()); } if response.ok { return Ok(response.result.unwrap_or(Value::Null)); } let error = response.error.unwrap_or(ExternalAgentRunnerProtocolError { code: "runner-error".to_string(), message: "Agent Runner 请求失败".to_string(), }); Err(redact_runner_secret( &format!("{}: {}", error.code, error.message), &endpoint.token, )) } pub(super) fn external_agent_runner_client_read_timeout(method: &str) -> Duration { match method { "unity.editor.rpc" | "godot.editor.rpc" => Duration::from_secs(80), _ => EXTERNAL_AGENT_RUNNER_IO_TIMEOUT, } } pub(super) fn send_external_agent_runner_request_with_id( endpoint: &ExternalAgentRunnerEndpoint, request_id: String, method: &str, params: ExternalAgentRunnerRequestParams, ) -> Result { send_external_agent_runner_request_with_protocol_and_id( endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, request_id, method, params, ) } pub(super) fn send_external_agent_runner_request( endpoint: &ExternalAgentRunnerEndpoint, method: &str, params: ExternalAgentRunnerRequestParams, ) -> Result { let request_id = random_identifier(b"genarrative-agent-runner-request-id")?; send_external_agent_runner_request_with_id(endpoint, request_id, method, params) } pub(super) fn ping_external_agent_runner( endpoint: &ExternalAgentRunnerEndpoint, ) -> Result<(), String> { send_external_agent_runner_request( endpoint, "runner.ping", ExternalAgentRunnerRequestParams::default(), ) .map(|_| ()) } pub(super) fn ping_external_agent_runner_with_timeout( endpoint: &ExternalAgentRunnerEndpoint, timeout: Duration, ) -> Result<(), String> { if timeout.is_zero() { return Err("Agent Runner ping 超时预算已耗尽".to_string()); } send_external_agent_runner_request_with_protocol_and_id_and_timeouts( endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, random_identifier(b"genarrative-agent-runner-start-ping-id")?, "runner.ping", ExternalAgentRunnerRequestParams::default(), EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT.min(timeout), timeout, timeout, ) .map(|_| ()) } pub(super) fn retire_incompatible_external_agent_runner( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, allow_force_busy_migration: bool, ) -> Result<(), String> { if !request_external_agent_runner_shutdown_if_idle_at(endpoint_path, endpoint)? { if !allow_force_busy_migration { return Err( "Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启".to_string(), ); } force_terminate_external_agent_runner_and_cleanup(endpoint_path, endpoint, true) .map_err(|error| format!("旧 Agent Runner busy 且安全迁移失败:{error}"))?; } Ok(()) } pub(super) fn verify_external_agent_runner_ping_identity( endpoint: &ExternalAgentRunnerEndpoint, ) -> Result<(), String> { let result = send_external_agent_runner_request_with_protocol_and_id( endpoint, endpoint.protocol_version, random_identifier(b"genarrative-agent-runner-identity-ping-id")?, "runner.ping", ExternalAgentRunnerRequestParams::default(), )?; if result.get("pid").and_then(Value::as_u64) != Some(endpoint.pid as u64) || result.get("bootId").and_then(Value::as_str) != Some(endpoint.boot_id.as_str()) { return Err("Agent Runner ping 身份与 endpoint 不匹配".to_string()); } Ok(()) } fn request_external_agent_runner_shutdown_if_idle_at( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, ) -> Result { let request_id = random_identifier(b"genarrative-agent-runner-upgrade-request-id")?; let result = send_external_agent_runner_request_with_protocol_and_id( endpoint, endpoint.protocol_version, request_id, "runner.shutdown_if_idle", ExternalAgentRunnerRequestParams::default(), )?; let idle = result .get("idle") .and_then(Value::as_bool) .ok_or_else(|| "Agent Runner shutdown_if_idle 响应缺少 idle".to_string())?; if !idle { return Ok(false); } wait_for_external_agent_runner_boot_exit( endpoint_path, endpoint, EXTERNAL_AGENT_RUNNER_START_TIMEOUT, "旧 Agent Runner 未在版本切换期限内退出", )?; Ok(true) } fn wait_for_external_agent_runner_boot_exit( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, timeout: Duration, timeout_error: &str, ) -> Result<(), String> { let deadline = Instant::now() + timeout; let lock_path = endpoint_path .parent() .map(external_agent_runner_lock_path) .ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?; loop { match read_external_agent_runner_endpoint(endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => {} Ok(_) => return Ok(()), Err(_) => { if let Some(lock) = try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? { drop(lock); return Ok(()); } } } if Instant::now() >= deadline { return Err(timeout_error.to_string()); } thread::sleep(Duration::from_millis(50)); } } pub(super) fn shutdown_external_agent_runner_at(config_dir: &Path) -> Result<(), String> { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let lock_path = external_agent_runner_lock_path(config_dir); let endpoint_deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT; loop { match fs::symlink_metadata(&endpoint_path) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err("Agent Runner endpoint 不允许符号链接".to_string()); } Ok(_) => break, Err(error) if error.kind() == io::ErrorKind::NotFound => { if let Some(lock) = try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? { drop(lock); return Ok(()); } if Instant::now() >= endpoint_deadline { return Err( "Agent Runner 实例锁仍被占用,但 endpoint 未在 GUI 关闭期限内出现" .to_string(), ); } thread::sleep(Duration::from_millis(25)); } Err(error) => { return Err(format!( "读取 Agent Runner endpoint 元数据失败:{}: {error}", endpoint_path.display() )); } } } let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?; let graceful_shutdown = send_external_agent_runner_request_with_protocol_and_id_and_timeouts( &endpoint, endpoint.protocol_version, random_identifier(b"genarrative-agent-runner-gui-shutdown-id")?, "runner.shutdown", ExternalAgentRunnerRequestParams::default(), EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_CONNECT_TIMEOUT, EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT, EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT, ) .and_then(|result| { result .get("willShutdown") .and_then(Value::as_bool) .unwrap_or(false) .then_some(()) .ok_or_else(|| "Agent Runner shutdown 响应未确认退出".to_string()) }); match graceful_shutdown { Ok(()) => { let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT; loop { match read_external_agent_runner_endpoint(&endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => {} Ok(_) => return Ok(()), Err(_) => { if let Some(lock) = try_open_external_agent_runner_lock( &lock_path, "Agent Runner 单实例锁", )? { drop(lock); return Ok(()); } } } if Instant::now() >= deadline { return force_terminate_external_agent_runner_and_cleanup( &endpoint_path, &endpoint, false, ); } thread::sleep(Duration::from_millis(25)); } } Err(graceful_error) => { force_terminate_external_agent_runner_and_cleanup(&endpoint_path, &endpoint, true) .map_err(|force_error| { format!("{graceful_error};强制终止 Agent Runner 失败:{force_error}") }) } } } fn force_terminate_external_agent_runner_and_cleanup( endpoint_path: &Path, endpoint: &ExternalAgentRunnerEndpoint, allow_verified_legacy_identity: bool, ) -> Result<(), String> { force_terminate_external_agent_runner_process(endpoint, allow_verified_legacy_identity)?; let config_dir = endpoint_path .parent() .ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?; let lock_path = external_agent_runner_lock_path(config_dir); let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE; loop { if let Some(lock) = try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")? { if read_external_agent_runner_endpoint(endpoint_path) .ok() .is_some_and(|current| current.boot_id == endpoint.boot_id) { fs::remove_file(endpoint_path) .map_err(|error| format!("清理已终止 Agent Runner endpoint 失败:{error}"))?; } drop(lock); return Ok(()); } if Instant::now() >= deadline { return Err("Agent Runner 已终止但实例锁未在期限内释放".to_string()); } thread::sleep(Duration::from_millis(25)); } } #[cfg(target_os = "linux")] struct ExternalAgentRunnerPidFd(i32); #[cfg(target_os = "linux")] impl Drop for ExternalAgentRunnerPidFd { fn drop(&mut self) { // SAFETY: self.0 is an owned pidfd returned by pidfd_open. unsafe { libc::close(self.0) }; } } #[cfg(target_os = "linux")] fn open_external_agent_runner_pidfd(pid: i32) -> Result, String> { // SAFETY: pidfd_open receives a range-checked pid and flags=0. let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; if fd < 0 { let error = io::Error::last_os_error(); if error.raw_os_error() == Some(libc::ESRCH) { return Ok(None); } return Err(format!("打开 Agent Runner pidfd 失败:{error}")); } Ok(Some(ExternalAgentRunnerPidFd(fd as i32))) } #[cfg(target_os = "linux")] fn signal_external_agent_runner_pidfd( pidfd: &ExternalAgentRunnerPidFd, signal: i32, ) -> Result { // SAFETY: pidfd is owned and live; null siginfo with flags=0 matches pidfd_send_signal. let result = unsafe { libc::syscall( libc::SYS_pidfd_send_signal, pidfd.0, signal, std::ptr::null::(), 0, ) }; if result == 0 { return Ok(true); } let error = io::Error::last_os_error(); if error.raw_os_error() == Some(libc::ESRCH) { Ok(false) } else { Err(format!("通过 pidfd 发送信号失败:{error}")) } } #[cfg(target_os = "linux")] fn force_terminate_external_agent_runner_process( endpoint: &ExternalAgentRunnerEndpoint, allow_verified_legacy_identity: bool, ) -> Result<(), String> { if endpoint.pid == std::process::id() { return Err("拒绝终止当前 GUI 进程".to_string()); } let pid = i32::try_from(endpoint.pid).map_err(|_| "Agent Runner pid 超出范围".to_string())?; let Some(pidfd) = open_external_agent_runner_pidfd(pid)? else { return Ok(()); }; if let Some(expected_start_identity) = endpoint.process_start_identity.as_deref() { let actual_start_identity = external_agent_runner_process_start_identity(endpoint.pid)? .ok_or_else(|| "当前平台未返回 Agent Runner 进程启动身份".to_string())?; if actual_start_identity != expected_start_identity { return Err("Agent Runner pid 已被其他进程复用,拒绝终止".to_string()); } } else if allow_verified_legacy_identity { verify_external_agent_runner_ping_identity(endpoint)?; } else { return Err("旧 Agent Runner endpoint 缺少进程启动身份,拒绝强制终止".to_string()); } if !signal_external_agent_runner_pidfd(&pidfd, libc::SIGTERM)? { return Ok(()); } let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE; while signal_external_agent_runner_pidfd(&pidfd, 0)? && Instant::now() < deadline { thread::sleep(Duration::from_millis(25)); } if !signal_external_agent_runner_pidfd(&pidfd, 0)? { return Ok(()); } signal_external_agent_runner_pidfd(&pidfd, libc::SIGKILL)?; Ok(()) } #[cfg(windows)] fn force_terminate_external_agent_runner_process( endpoint: &ExternalAgentRunnerEndpoint, allow_verified_legacy_identity: bool, ) -> Result<(), String> { use std::ffi::c_void; #[repr(C)] struct FileTime { low_date_time: u32, high_date_time: u32, } #[link(name = "kernel32")] unsafe extern "system" { fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; fn GetProcessTimes( process: *mut c_void, creation_time: *mut FileTime, exit_time: *mut FileTime, kernel_time: *mut FileTime, user_time: *mut FileTime, ) -> i32; fn TerminateProcess(process: *mut c_void, exit_code: u32) -> i32; fn WaitForSingleObject(handle: *mut c_void, milliseconds: u32) -> u32; fn CloseHandle(handle: *mut c_void) -> i32; } const PROCESS_TERMINATE: u32 = 0x0001; const SYNCHRONIZE: u32 = 0x0010_0000; const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; if endpoint.pid == std::process::id() { return Err("拒绝终止当前 GUI 进程".to_string()); } // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. let process = unsafe { OpenProcess( PROCESS_TERMINATE | SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, endpoint.pid, ) }; if process.is_null() { let error = io::Error::last_os_error(); if error.raw_os_error() == Some(87) { return Ok(()); } return Err(format!("打开 Agent Runner 进程失败:{error}")); } let result = (|| { if let Some(expected_start_identity) = endpoint.process_start_identity.as_deref() { // SAFETY: FileTime is plain data filled by GetProcessTimes. let mut creation = unsafe { std::mem::zeroed::() }; let mut exit = unsafe { std::mem::zeroed::() }; let mut kernel = unsafe { std::mem::zeroed::() }; let mut user = unsafe { std::mem::zeroed::() }; // SAFETY: process is live and all output pointers refer to writable FileTime values. if unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) } == 0 { return Err(format!( "读取 Agent Runner 进程启动身份失败:{}", io::Error::last_os_error() )); } let actual_start_identity = ((creation.high_date_time as u64) << 32 | creation.low_date_time as u64) .to_string(); if actual_start_identity != expected_start_identity { return Err("Agent Runner pid 已被其他进程复用,拒绝终止".to_string()); } } else if allow_verified_legacy_identity { verify_external_agent_runner_ping_identity(endpoint)?; } else { return Err("旧 Agent Runner endpoint 缺少进程启动身份,拒绝强制终止".to_string()); } // SAFETY: the handle includes PROCESS_TERMINATE and its process start identity was verified. if unsafe { TerminateProcess(process, 1) } == 0 { return Err(format!( "终止 Agent Runner 失败:{}", io::Error::last_os_error() )); } // SAFETY: the handle includes SYNCHRONIZE and remains valid for this bounded wait. let wait = unsafe { WaitForSingleObject(process, 1_000) }; if wait != 0 { return Err(format!("等待 Agent Runner 退出失败:waitResult={wait}")); } Ok(()) })(); // SAFETY: process is an owned non-null handle returned by OpenProcess. unsafe { CloseHandle(process) }; result } #[cfg(target_os = "macos")] fn force_terminate_external_agent_runner_process( _endpoint: &ExternalAgentRunnerEndpoint, _allow_verified_legacy_identity: bool, ) -> Result<(), String> { Err("macOS 不提供可绑定进程实例的安全强制终止句柄,拒绝按裸 pid 终止 Agent Runner".to_string()) } #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] fn force_terminate_external_agent_runner_process( _endpoint: &ExternalAgentRunnerEndpoint, _allow_verified_legacy_identity: bool, ) -> Result<(), String> { Err("当前平台不支持核验并强制终止 Agent Runner".to_string()) } #[cfg(not(test))] pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, ) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; let platform_session = crate::current_platform_session(); // 启动阶段先采纳现有 durable claim:第二个及后续窗口与第一个窗口共享同一 // epoch,因此不会被判定为抢走登录态权威;claim 缺失或不可读时才发布新 claim。 let session_revision = reserve_external_agent_runner_gui_owner_claim_revision( external_agent_runner_gui_owner_attachment_state(), ); let claim = resolve_external_agent_runner_gui_owner_claim( &config_dir, ExternalAgentRunnerGuiOwnerClaimMode::Adopt, session_revision, )?; register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), gui_owner_epoch: Some(claim.owner_epoch), gui_owner_session_revision: Some(claim.session_revision), platform_user_id: platform_session .as_ref() .map(|session| session.user_id.clone()), platform_access_token: platform_session .as_ref() .map(|session| session.access_token.clone()), platform_api_base_url: platform_session .as_ref() .map(|session| session.api_base_url.clone()), platform_auth_generation: platform_session .as_ref() .map(|session| session.identity_generation), platform_auth_revision: platform_session.map(|session| session.revision), ..ExternalAgentRunnerRequestParams::default() }, )?; ensure_external_agent_runner(&config_dir).map(|_| ()) } pub(crate) fn install_external_agent_runner_platform_session( user_id: &str, access_token: &str, api_base_url: &str, identity_generation: u64, revision: u64, ) -> Result<(), String> { let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; synchronize_external_agent_runner_platform_session_with( || { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), Some((user_id, access_token, api_base_url)), identity_generation, revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { validate_external_agent_runner_platform_session_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, &endpoint, Some((user_id, access_token, api_base_url)), identity_generation, revision, ) }) }, || shutdown_external_agent_runner_at(&config_dir), ) } pub(crate) fn clear_external_agent_runner_platform_session( identity_generation: u64, revision: u64, ) -> Result<(), String> { let Some(config_dir) = external_agent_runner_config_dir() else { return Ok(()); }; synchronize_external_agent_runner_platform_session_with( || { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), None, identity_generation, revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { validate_external_agent_runner_platform_session_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, &endpoint, None, identity_generation, revision, ) }) }, || shutdown_external_agent_runner_at(&config_dir), ) } fn validate_external_agent_runner_platform_session_attachment( state: &Mutex, config_dir: &Path, endpoint: &ExternalAgentRunnerEndpoint, session: Option<(&str, &str, &str)>, identity_generation: u64, revision: u64, ) -> Result<(), String> { let state = lock_unpoisoned(state); let registration = state.registration.as_ref().ok_or_else(|| { "Agent Runner 尚未建立带 owner epoch 的 GUI owner 登记,平台登录态拒绝下发".to_string() })?; let expected_user_id = session.map(|(user_id, _, _)| user_id); let expected_access_token = session.map(|(_, access_token, _)| access_token); let expected_api_base_url = session.map(|(_, _, api_base_url)| api_base_url); if registration.config_dir != config_dir || registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()) || registration.params.gui_owner_epoch.is_none() || registration.params.gui_owner_session_revision != Some(registration.generation) || registration.params.platform_auth_generation != Some(identity_generation) || registration.params.platform_auth_revision != Some(revision) || registration.params.platform_user_id.as_deref() != expected_user_id || registration.params.platform_access_token.as_deref() != expected_access_token || registration.params.platform_api_base_url.as_deref() != expected_api_base_url { return Err("Agent Runner 未确认当前 GUI owner epoch 的平台登录态".to_string()); } Ok(()) } pub(super) fn synchronize_external_agent_runner_platform_session_with( sync_attempt: impl FnOnce() -> Result<(), String>, fence_runner: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { let sync_result = sync_attempt(); let Err(sync_error) = sync_result else { return Ok(()); }; match fence_runner() { Ok(()) => Err(format!( "{sync_error};为避免旧账号继续执行,Agent Runner 已停止,后续请求将按当前账号重建" )), Err(fence_error) => Err(format!( "{sync_error};阻断旧账号 Agent Runner 失败:{fence_error}" )), } } pub(super) fn remember_external_agent_runner_platform_session( state: &Mutex, session: Option<(&str, &str, &str)>, identity_generation: u64, revision: u64, ) -> Result<(), String> { remember_external_agent_runner_platform_session_with( state, session, identity_generation, revision, publish_external_agent_runner_gui_owner_claim, ) } pub(super) fn remember_external_agent_runner_platform_session_with( state: &Mutex, session: Option<(&str, &str, &str)>, identity_generation: u64, revision: u64, publish_claim: impl FnOnce(&Path, u64) -> Result, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_ref() else { return Ok(()); }; // 写入顺序只认 revision;身份代次只表达主体归属,同一账号续期会推进 revision // 但保持 identity generation 不变。 let current_revision = registration.params.platform_auth_revision.unwrap_or(0); if revision < current_revision { return Ok(()); } if revision == current_revision { match session { Some((user_id, access_token, api_base_url)) if registration.params.platform_user_id.as_deref() == Some(user_id) && registration.params.platform_auth_generation == Some(identity_generation) && registration.params.platform_access_token.as_deref() == Some(access_token) && registration.params.platform_api_base_url.as_deref() == Some(api_base_url) => { return Ok(()); } Some(_) => return Ok(()), None if registration.params.platform_user_id.is_none() && registration.params.platform_access_token.is_none() => { return Ok(()); } None => {} } } state.generation = state.generation.wrapping_add(1); let registration_generation = state.generation; // 本窗口改动了平台登录态:发布新 epoch 的 claim,成为新的登录态权威。 // 并发发布以最后一次成功写入为准,落败窗口在 attach 阶段按最新 claim 重试。 // 只有已经建立过 claim 的登记才需要发布:没有 epoch 的登记(纯 CLI / 单元测试替身) // 不写任何 claim 文件。 let published_claim = state .registration .as_ref() .filter(|registration| registration.params.gui_owner_epoch.is_some()) .map(|registration| registration.config_dir.clone()) .map(|config_dir| publish_claim(&config_dir, registration_generation)) .transpose()?; let registration = state .registration .as_mut() .expect("checked GUI owner registration must remain present while locked"); registration.generation = registration_generation; registration.claim_mode = ExternalAgentRunnerGuiOwnerClaimMode::Publish; registration.attached_boot_id = None; registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string()); registration.params.platform_access_token = session.map(|(_, access_token, _)| access_token.to_string()); registration.params.platform_api_base_url = session.map(|(_, _, api_base_url)| api_base_url.to_string()); registration.params.platform_auth_generation = Some(identity_generation); registration.params.platform_auth_revision = Some(revision); if let Some(claim) = published_claim { registration.params.gui_owner_epoch = Some(claim.owner_epoch); registration.params.gui_owner_session_revision = Some(claim.session_revision); } Ok(()) } pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( result: &Value, ) -> Result<(), String> { if result.get("attached").and_then(Value::as_bool) == Some(true) && result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true) { Ok(()) } else { Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string()) } } fn attach_external_agent_runner_gui_owner_at( endpoint: &ExternalAgentRunnerEndpoint, params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?; validate_external_agent_runner_gui_owner_attachment_result(&result) } fn attach_registered_external_agent_runner_gui_owner_if_needed( config_dir: &Path, endpoint: &ExternalAgentRunnerEndpoint, ) -> Result<(), String> { attach_registered_external_agent_runner_gui_owner_if_needed_with( external_agent_runner_gui_owner_attachment_state(), config_dir, endpoint, attach_external_agent_runner_gui_owner_at, ) } /// 窗口退出的收尾:先释放本窗口参与锁,再决定 Runner 是否需要关闭。 /// /// 返回 `Ok(false)` 表示仍检测到其它窗口持有参与锁,Runner 必须保留给它们; /// 返回 `Ok(true)` 表示本窗口是最后一个界面进程,Runner 已请求关闭。 #[cfg(not(test))] pub(crate) fn shutdown_external_agent_runner_for_gui_exit() -> Result { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let Some(config_dir) = external_agent_runner_config_dir() else { return Ok(true); }; release_external_agent_runner_gui_participant_lock(); if external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( &config_dir, ))? { return Ok(false); } shutdown_external_agent_runner_at(&config_dir)?; Ok(true) } pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, executable_fingerprint: &str, ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT; loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return Err("外部 Agent Runner 未在启动期限内就绪".to_string()); } if let Some(endpoint) = read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint) { let ping_timeout = EXTERNAL_AGENT_RUNNER_IO_TIMEOUT.min(remaining); if ping_external_agent_runner_with_timeout(&endpoint, ping_timeout).is_ok() { return Ok(endpoint); } } if let Some(status) = child .try_wait() .map_err(|error| format!("检查外部 Agent Runner 子进程失败:{error}"))? { return Err(format!("外部 Agent Runner 在就绪前退出:{status}")); } if Instant::now() >= deadline { return Err("外部 Agent Runner 未在启动期限内就绪".to_string()); } thread::sleep(Duration::from_millis(50)); } } pub(super) fn ensure_external_agent_runner( config_dir: &Path, ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( config_dir, &endpoint_path, &executable_fingerprint, )? { return Ok(endpoint); } let mut launched = launch_external_agent_runner(config_dir)?; match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) { Ok(endpoint) => { thread::Builder::new() .name("agent-runner-reaper".to_string()) .spawn(move || { let _ = launched.child.wait(); }) .map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?; attach_registered_external_agent_runner_gui_owner_if_needed(config_dir, &endpoint)?; Ok(endpoint) } Err(error) => { let _ = launched.child.kill(); let _ = launched.child.wait(); // 同一 AppData 的另一个窗口可能在这段时间里已经启动了 Runner: // 实例锁竞争失败不能立刻报成启动失败,先按最新 endpoint 复用一次。 if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( config_dir, &endpoint_path, &executable_fingerprint, )? { return Ok(endpoint); } Err(error) } } } fn reuse_or_retire_external_agent_runner_endpoint( config_dir: &Path, endpoint_path: &Path, executable_fingerprint: &str, ) -> Result, String> { if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { match external_agent_runner_endpoint_reuse_decision(&endpoint, executable_fingerprint) { ExternalAgentRunnerReuseDecision::Reuse => { if ping_external_agent_runner(&endpoint).is_ok() { attach_registered_external_agent_runner_gui_owner_if_needed( config_dir, &endpoint, )?; return Ok(Some(endpoint)); } } ExternalAgentRunnerReuseDecision::Retire => { let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( &endpoint, endpoint.protocol_version, random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, "runner.ping", ExternalAgentRunnerRequestParams::default(), ); if incompatible_ping.is_ok() { retire_incompatible_external_agent_runner( endpoint_path, &endpoint, EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .load(std::sync::atomic::Ordering::Acquire), )?; } } } } Ok(None) } #[cfg(not(test))] pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> Result<(), String> { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; set_external_agent_runner_config_dir(config_dir); Ok(()) } pub(crate) fn call_external_managed_editor( editor: crate::editor_adapters::ManagedEditor, method: &str, mut params: Value, ) -> Result { let deadline = Instant::now() + Duration::from_secs(80); static UNITY_UNCERTAIN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); static GODOT_UNCERTAIN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); let execution_uncertain = match editor { crate::editor_adapters::ManagedEditor::Unity => &UNITY_UNCERTAIN, crate::editor_adapters::ManagedEditor::Godot => &GODOT_UNCERTAIN, }; let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; let uncertain_result = || serde_json::json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"runner-receipt-unconfirmed","message":"编辑器 执行回执未确认,核对后退出全部 AGC 和 Runner 再重新打开"}}); if method == "execute" && execution_uncertain.load(std::sync::atomic::Ordering::SeqCst) { return Ok(uncertain_result()); } if method == "execute" && crate::editor_adapters::editor_uncertain_fence_path(editor, &config_dir).exists() { return Ok(uncertain_result()); } let endpoint = if crate::editor_adapters::editor_execution_fence_path(editor, &config_dir) .exists() { // 在途 fence 可能只是正常并发;由活着的 owner 区分 busy 与 unknown。 // 此分支绝不自动重启 Runner,以免丢失未确认执行的进程内状态。 match read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir)) { Ok(endpoint) => endpoint, Err(_) if method == "execute" => return Ok(uncertain_result()), Err(error) => return Err(error), } } else { let _configure = match external_agent_runner_configure_lock().try_lock() { Ok(guard) => guard, Err(_) if method == "execute" => { return Ok(crate::editor_adapters::editor_not_dispatched( "Runner 正在配置,请等待当前操作完成", )) } Err(_) => return Err("Runner 正在配置,请等待当前操作完成".to_string()), }; ensure_external_agent_runner(&config_dir)? }; let remaining = deadline.saturating_duration_since(Instant::now()); if remaining < Duration::from_secs(16) { return if method == "execute" { Ok(crate::editor_adapters::editor_not_dispatched( "编辑器 调用启动预算已耗尽,未派发执行", )) } else { Err("编辑器 调用启动预算已耗尽".to_string()) }; } if let Some(params) = params.as_object_mut() { if params.get("timeoutMs").is_some_and(|value| { !value .as_u64() .is_some_and(|timeout| (1..=60_000).contains(&timeout)) }) { return if method == "execute" { Ok(crate::editor_adapters::editor_not_dispatched( "timeoutMs 必须在 1..=60000", )) } else { Err("timeoutMs 必须在 1..=60000".to_string()) }; } let requested = params .get("timeoutMs") .and_then(Value::as_u64) .unwrap_or(60_000); let bounded = requested.min(remaining.as_millis().saturating_sub(15_000) as u64); params.insert("timeoutMs".to_string(), serde_json::json!(bounded)); } let request_id = random_identifier(b"agc-editor-request")?; // 所有可能失败的随机身份生成必须在真实执行派发前完成。 let acknowledgement_id = random_identifier(b"agc-editor-ack")?; let persist_uncertain = || { execution_uncertain.store(true, std::sync::atomic::Ordering::SeqCst); let _ = crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config_dir); }; let request_params = ExternalAgentRunnerRequestParams { editor_rpc: Some( serde_json::json!({"method":method,"params":params,"deadlineMs":unix_millis()+remaining.as_millis() as u64}), ), ..Default::default() }; // 只发送一次,传输失败不能重新派发 execute。 let response = send_external_agent_runner_request_with_protocol_and_id_and_timeouts( &endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, request_id.clone(), editor.rpc_method(), request_params, Duration::from_secs(2), remaining.saturating_sub(Duration::from_secs(7)), Duration::from_secs(2), ); match response { Ok(mut value) => { if method == "execute" { if !crate::editor_adapters::editor_execute_receipt_is_valid(&value) { persist_uncertain(); return Ok(uncertain_result()); } if value["status"] == "needs-reconciliation" { persist_uncertain(); return Ok(value); } let Some(ack_required) = value.get("ackRequired").and_then(Value::as_bool) else { persist_uncertain(); return Ok(uncertain_result()); }; if let Some(object) = value.as_object_mut() { object.remove("ackRequired"); } if !ack_required { return Ok(value); } if Instant::now() + Duration::from_secs(3) >= deadline { persist_uncertain(); return Ok(uncertain_result()); } let acknowledgement = send_external_agent_runner_request_with_protocol_and_id_and_timeouts( &endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, acknowledgement_id, editor.ack_method(), ExternalAgentRunnerRequestParams { editor_rpc: Some(serde_json::json!({"requestId":request_id})), ..Default::default() }, Duration::from_millis(500), Duration::from_secs(1), Duration::from_millis(500), ); if !acknowledgement.is_ok_and(|response| response["acknowledged"] == true) { persist_uncertain(); return Ok(uncertain_result()); } } Ok(value) } Err(_) if method == "execute" => { persist_uncertain(); Ok(uncertain_result()) } Err(error) => Err(error), } } pub(crate) fn disconnect_external_managed_editor_project( editor: crate::editor_adapters::ManagedEditor, project: Option<&Path>, ) -> Result<(), String> { let Some(config_dir) = external_agent_runner_config_dir() else { return if editor == crate::editor_adapters::ManagedEditor::Godot && project .map(crate::editor_adapters::godot_project_cleanup_required) .transpose()? .unwrap_or(false) { Err("Godot 清理宿主尚未初始化,无法确认旧桥已卸载".into()) } else { Ok(()) }; }; if editor == crate::editor_adapters::ManagedEditor::Godot { return disconnect_external_godot_projects(&config_dir, project, |params| { call_external_managed_editor(editor, "disconnect", params) }); } let path = external_agent_runner_endpoint_path(&config_dir); if !path.exists() { return Ok(()); } let endpoint = read_external_agent_runner_endpoint(&path)?; send_external_agent_runner_request( &endpoint, editor.rpc_method(), ExternalAgentRunnerRequestParams { editor_rpc: Some(serde_json::json!({"method":"disconnect","params":{}})), ..Default::default() }, ) .map(|_| ()) } fn disconnect_external_godot_projects( config: &Path, explicit: Option<&Path>, mut cleanup: impl FnMut(Value) -> Result, ) -> Result<(), String> { // endpoint 丢失不等于编辑器桥消失;用持久授权根启动原 owner 的清理流程。 for project in crate::editor_adapters::godot_cleanup_projects_at(config, explicit)? { let result = cleanup(serde_json::json!({"projectPath":project}))?; if result["adapter"] != "godot-editor" || result["connected"] != false || result.get("error").is_some() || result.get("accepted").is_some() || result["status"] == "needs-reconciliation" { return Err("Godot 原生桥尚未确认卸载".into()); } } Ok(()) } #[cfg(test)] mod managed_cleanup_tests { use super::*; #[test] fn godot_cleanup_recovers_authorized_projects_without_runner_endpoint() { let config = tempfile::tempdir().unwrap(); let project = tempfile::tempdir().unwrap(); let expected = project.path().canonicalize().unwrap(); fs::write( config.path().join("godot-editor-authorized-projects.json"), serde_json::json!({ "schemaVersion":"agc.godot.authorized-projects.v1", "projects":[expected] }) .to_string(), ) .unwrap(); assert!(!external_agent_runner_endpoint_path(config.path()).exists()); let mut called = false; let result = disconnect_external_godot_projects(config.path(), None, |params| { called = true; assert_eq!(params["projectPath"], serde_json::json!(expected)); Ok(serde_json::json!({"accepted":true,"status":"shutting-down"})) }); assert!(called); assert!(result.is_err()); assert_eq!( crate::editor_adapters::godot_authorized_projects_at(config.path()).unwrap(), vec![expected] ); } } pub(crate) fn mark_external_editor_uncertain( editor: crate::editor_adapters::ManagedEditor, ) -> Result<(), String> { let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?; // 先保存 GUI 与 Runner 共享的单向 fence;网络丢失也不能解锁。 crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config_dir)?; let endpoint = read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir))?; send_external_agent_runner_request_with_protocol_and_id_and_timeouts( &endpoint, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, random_identifier(b"agc-editor-mark-uncertain")?, editor.mark_method(), ExternalAgentRunnerRequestParams::default(), Duration::from_millis(500), Duration::from_secs(1), Duration::from_millis(500), ) .map(|_| ()) } #[cfg(test)] mod diagnostic_log_tests { use super::*; #[test] fn runner_log_output_redacts_config_paths_and_credentials() { let config_dir = Path::new(r"C:\Users\example\AppData\Roaming\genarrative"); assert_eq!( sanitize_agent_runner_output( r"agent.runner.failed: failed to open C:\Users\example\AppData\Roaming\genarrative\state.json", config_dir, ), "agent.runner.failed: failed to open \\state.json" ); assert_eq!( sanitize_agent_runner_output("Authorization: Bearer secret", config_dir), "" ); assert_eq!( sanitize_agent_runner_output( r"agent.runner.failed: project C:\private\game\index.html failed", config_dir, ), "agent.runner.failed: project failed" ); assert_eq!( sanitize_agent_runner_output("normal model response body", config_dir), "" ); assert_eq!( sanitize_agent_runner_output( "agent.runner.failed: request failed https://example.invalid/api?value=1", config_dir, ), "agent.runner.failed: request failed https://example.invalid/api?" ); assert_eq!( sanitize_agent_runner_output("error password=hunter2", config_dir), "" ); } #[test] fn runner_log_line_reader_caps_long_lines_and_drains_to_next_line() { let mut input = vec![b'x'; AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES + 500]; input.extend_from_slice(b"\nerror: second line\n"); let mut reader = BufReader::new(std::io::Cursor::new(input)); let (first, first_truncated) = read_bounded_agent_runner_line(&mut reader) .expect("read first line") .expect("first line exists"); assert_eq!(first.len(), AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES); assert!(first_truncated); let (second, second_truncated) = read_bounded_agent_runner_line(&mut reader) .expect("read second line") .expect("second line exists"); assert_eq!(second, "error: second line"); assert!(!second_truncated); } }