Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs
T
lhk229 ec3a187dd7
Project CI / AI game creator shell Rust crates (push) Successful in 1m11s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / Backend tests (push) Successful in 4m46s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m31s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m41s
Project CI / Frontend tests (push) Successful in 2m19s
Project CI / Native shell tests (push) Successful in 7m5s
Project CI / AI game creator shell web tests (push) Successful in 1m54s
Project CI / Repository checks (push) Successful in 2m36s
处理编译大量warning问题 (#503)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/503
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-24 13:01:31 +08:00

2108 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*};
use crate::{AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink};
use serde_json::Value;
use sha2::{Digest as _, Sha256};
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<ExternalAgentRunnerGuiOwnerRegistration>,
}
struct ExternalAgentRunnerGuiOwnerRegistration {
generation: u64,
claim_mode: ExternalAgentRunnerGuiOwnerClaimMode,
config_dir: PathBuf,
params: ExternalAgentRunnerRequestParams,
attached_boot_id: Option<String>,
}
/// 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<ExternalAgentRunnerGuiOwnerAttachmentState>,
> = OnceLock::new();
static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock<
Mutex<Option<ExternalAgentRunnerGuiParticipantLock>>,
> = OnceLock::new();
fn external_agent_runner_gui_owner_attachment_state(
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE
.get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()))
}
fn external_agent_runner_gui_participant_lock(
) -> &'static Mutex<Option<ExternalAgentRunnerGuiParticipantLock>> {
EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None))
}
/// 取得并持有本窗口的界面参与锁,直到窗口退出。
///
/// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测
/// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。
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(())
}
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
) -> 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<ExternalAgentRunnerGuiOwnerClaim, String> {
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<F>(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
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}?<query-redacted>")
} else {
token.to_string()
}
})
.collect::<Vec<_>>()
.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 "<sensitive runner output redacted>".to_string();
}
if lowercase.contains("panic") {
return "<runner panic details redacted>".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))
{
"<runner error details omitted>"
} else if ["warning", "warn:"]
.iter()
.any(|marker| lowercase.contains(marker))
{
"<runner warning details omitted>"
} else {
"<non-diagnostic runner output omitted>"
};
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<R: BufRead>(
reader: &mut R,
) -> io::Result<Option<(String, bool)>> {
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<R>(
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<LaunchedExternalAgentRunner, String> {
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<OsString> {
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<Value, String> {
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<Value, String> {
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::<ExternalAgentRunnerResponse>(&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 {
"runtime.compact" => EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT,
"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<Value, String> {
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<Value, String> {
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<bool, String> {
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_if_idle_at(config_dir: &Path) -> Result<bool, String> {
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
let lock_path = external_agent_runner_lock_path(config_dir);
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_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(true);
}
if Instant::now() >= deadline {
return Err(
"Agent Runner 启动锁仍被占用,但 endpoint 未在期限内就绪".to_string()
);
}
thread::sleep(Duration::from_millis(50));
}
Err(error) => {
return Err(format!(
"读取 Agent Runner endpoint 元数据失败:{}: {error}",
endpoint_path.display()
));
}
}
}
let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?;
request_external_agent_runner_shutdown_if_idle_at(&endpoint_path, &endpoint)
}
pub(crate) fn shutdown_external_agent_runner_if_idle() -> Result<bool, String> {
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
shutdown_external_agent_runner_if_idle_at(&config_dir)
}
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<Option<ExternalAgentRunnerPidFd>, 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<bool, String> {
// 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::<libc::siginfo_t>(),
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::<FileTime>() };
let mut exit = unsafe { std::mem::zeroed::<FileTime>() };
let mut kernel = unsafe { std::mem::zeroed::<FileTime>() };
let mut user = unsafe { std::mem::zeroed::<FileTime>() };
// 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())
}
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>,
identity_generation: u64,
revision: u64,
publish_claim: impl FnOnce(&Path, u64) -> Result<ExternalAgentRunnerGuiOwnerClaim, String>,
) -> 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 已请求关闭。
pub(crate) fn shutdown_external_agent_runner_for_gui_exit() -> Result<bool, String> {
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<ExternalAgentRunnerEndpoint, String> {
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<ExternalAgentRunnerEndpoint, String> {
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<Option<ExternalAgentRunnerEndpoint>, 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)
}
pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef<Path>) -> 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 configure_external_agent_runner_read_only(
config_dir: impl AsRef<Path>,
) -> Result<(), String> {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
let config_dir = inspect_external_agent_runner_config_dir(config_dir.as_ref())?;
set_external_agent_runner_config_dir(config_dir);
Ok(())
}
pub(crate) fn ensure_external_agent_runner_started() -> Result<(), String> {
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())?;
ensure_external_agent_runner(&config_dir).map(|_| ())
}
pub(crate) fn require_external_agent_runner_for_cli_runtime_write(
root: &Path,
) -> Result<(), String> {
require_external_agent_runner_configured_for_cli_runtime_write(root)?;
ensure_external_agent_runner_started()
}
pub(crate) fn require_external_agent_runner_configured_for_cli_runtime_write(
root: &Path,
) -> Result<(), String> {
if external_agent_runner_is_server_process() {
return Err("Agent Runner 进程不能作为普通 CLI 执行 Runtime 写命令".to_string());
}
let config_dir = external_agent_runner_config_dir().ok_or_else(|| {
"Agent Runtime 写命令必须显式传入 --config-dir <项目外 AppData 绝对路径>".to_string()
})?;
if !root.is_absolute() {
return Err("Agent Runtime 写命令的项目路径必须是绝对路径".to_string());
}
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, root)
}
pub(super) fn send_external_agent_runner_runtime_request(
root: &Path,
method: &str,
agent: Option<&str>,
run_id: Option<&str>,
action_id: Option<&str>,
steer_id: Option<&str>,
) -> Result<Value, String> {
send_external_agent_runner_runtime_request_with_stable_identity(
root, method, agent, run_id, action_id, steer_id, None,
)
}
pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
root: &Path,
method: &str,
agent: Option<&str>,
run_id: Option<&str>,
action_id: Option<&str>,
steer_id: Option<&str>,
stable_identity: Option<&str>,
) -> Result<Value, String> {
let root = canonicalize_external_agent_runner_project_root(root)?;
let root = root
.to_str()
.ok_or_else(|| "通知 Agent Runner 的项目 root 必须是 UTF-8 路径".to_string())?;
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置".to_string())?;
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, Path::new(root))?;
// Establish liveness before the write request. The write itself is sent exactly once.
let endpoint = {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
ensure_external_agent_runner(&config_dir)?
};
let params = ExternalAgentRunnerRequestParams {
root: Some(root.to_string()),
editor_rpc: None,
agent: agent.map(str::to_string),
session_id: None,
run_id: run_id.map(str::to_string),
action_id: action_id.map(str::to_string),
steer_id: steer_id.map(str::to_string),
event_sink_port: None,
event_sink_token: None,
gui_owner_epoch: None,
gui_owner_session_revision: None,
platform_user_id: None,
platform_access_token: None,
platform_api_base_url: None,
platform_auth_generation: None,
platform_auth_revision: None,
};
match stable_identity {
Some(stable_identity) => {
let identity = format!("{root}\n{method}\n{stable_identity}");
let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes()));
send_external_agent_runner_request_with_id(
&endpoint,
format!("runtime-parent-wake-{}", &fingerprint[..32]),
method,
params,
)
}
None => send_external_agent_runner_request(&endpoint, method, params),
}
}
pub(crate) fn call_external_managed_editor(
editor: crate::editor_adapters::ManagedEditor,
method: &str,
mut params: Value,
) -> Result<Value, String> {
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<Value, String>,
) -> 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(|_| ())
}
pub(crate) fn compact_external_agent_runner_context(
root: &Path,
agent: &str,
session_id: Option<&str>,
) -> Result<AgentRuntimeContextCompactionResult, String> {
let agent = agent.trim();
if agent.is_empty() {
return Err("手动压缩 Agent 上下文必须提供 agent".to_string());
}
let root = canonicalize_external_agent_runner_project_root(root)?;
let root_text = root
.to_str()
.ok_or_else(|| "通知 Agent Runner 的项目 root 必须是 UTF-8 路径".to_string())?;
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置".to_string())?;
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, &root)?;
let endpoint = {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
ensure_external_agent_runner(&config_dir)?
};
let result = send_external_agent_runner_request(
&endpoint,
"runtime.compact",
ExternalAgentRunnerRequestParams {
root: Some(root_text.to_string()),
agent: Some(agent.to_string()),
session_id: session_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
..ExternalAgentRunnerRequestParams::default()
},
)?;
serde_json::from_value(result)
.map_err(|error| format!("解析 Agent Runner 上下文压缩结果失败:{error}"))
}
pub(crate) fn wake_external_agent_runner_pending(root: &Path) -> Result<(), String> {
send_external_agent_runner_runtime_request(root, "runtime.wake_pending", None, None, None, None)
.map(|_| ())
}
pub(crate) fn wake_external_agent_runner_pending_for_run(
root: &Path,
agent: &str,
run_id: &str,
loop_iteration: u32,
) -> Result<(), String> {
if agent.trim().is_empty() || run_id.trim().is_empty() {
return Err("父 run wake 必须同时提供 agent 和 runId".to_string());
}
let stable_identity = format!("{agent}\n{run_id}\n{loop_iteration}");
send_external_agent_runner_runtime_request_with_stable_identity(
root,
"runtime.wake_pending",
Some(agent),
Some(run_id),
None,
None,
Some(&stable_identity),
)
.map(|_| ())
}
pub(crate) fn resume_external_agent_runner(root: &Path) -> Result<(), String> {
send_external_agent_runner_runtime_request(root, "runtime.resume", None, None, None, None)
.map(|_| ())
}
pub(crate) fn continue_external_agent_runner_action(
root: &Path,
agent: &str,
run_id: &str,
action_id: &str,
) -> Result<(), String> {
if [agent, run_id, action_id]
.into_iter()
.any(|value| value.trim().is_empty())
{
return Err("继续 Agent Runtime 动作必须同时提供 agent/runId/actionId".to_string());
}
send_external_agent_runner_runtime_request(
root,
"runtime.continue_action",
Some(agent),
Some(run_id),
Some(action_id),
None,
)
.map(|_| ())
}
pub(crate) fn steer_external_agent_runner(
root: &Path,
agent: &str,
run_id: &str,
steer_id: &str,
) -> Result<bool, String> {
if [agent, run_id, steer_id]
.into_iter()
.any(|value| value.trim().is_empty())
{
return Err("追加 Agent 指令必须同时提供 agent/runId/steerId".to_string());
}
let agent = agent.trim();
let run_id = run_id.trim();
let steer_id = steer_id.trim();
let result = send_external_agent_runner_runtime_request(
root,
"runtime.steer",
Some(agent),
Some(run_id),
None,
Some(steer_id),
)?;
parse_external_agent_runner_steer_result(&result)
}
pub(crate) fn pause_external_agent_runner(
root: &Path,
agent: &str,
run_id: &str,
) -> Result<bool, String> {
if [agent, run_id]
.into_iter()
.any(|value| value.trim().is_empty())
{
return Err("暂停 Agent Goal 必须同时提供 agent/runId".to_string());
}
let result = send_external_agent_runner_runtime_request(
root,
"runtime.pause",
Some(agent.trim()),
Some(run_id.trim()),
None,
None,
)?;
result
.get("providerInterrupted")
.and_then(Value::as_bool)
.ok_or_else(|| "Agent Runner runtime.pause 响应缺少 providerInterrupted".to_string())
}
pub(crate) fn cancel_external_agent_runner_goal(
root: &Path,
agent: &str,
run_id: &str,
) -> Result<bool, String> {
if [agent, run_id]
.into_iter()
.any(|value| value.trim().is_empty())
{
return Err("清理 Agent Goal 必须同时提供 agent/runId".to_string());
}
let result = send_external_agent_runner_runtime_request(
root,
"runtime.cancel",
Some(agent.trim()),
Some(run_id.trim()),
None,
None,
)?;
result
.get("providerInterrupted")
.and_then(Value::as_bool)
.ok_or_else(|| "Agent Runner runtime.cancel 响应缺少 providerInterrupted".to_string())
}
pub(super) fn parse_external_agent_runner_steer_result(result: &Value) -> Result<bool, String> {
result
.get("providerInterrupted")
.and_then(Value::as_bool)
.ok_or_else(|| "Agent Runner runtime.steer 响应缺少 providerInterrupted".to_string())
}
pub(super) fn read_external_agent_runner_status_at(
config_dir: Option<&Path>,
) -> ExternalAgentRunnerStatus {
let Some(config_dir) = config_dir else {
return ExternalAgentRunnerStatus::disabled();
};
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
let endpoint = match read_external_agent_runner_endpoint(&endpoint_path) {
Ok(endpoint) => endpoint,
Err(error) => {
return ExternalAgentRunnerStatus {
enabled: true,
running: false,
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
pid: None,
boot_id: None,
port: None,
heartbeat_at: None,
error: Some(error),
};
}
};
let mut fallback = ExternalAgentRunnerStatus::from_endpoint(&endpoint, false);
if endpoint.protocol_version != EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION {
fallback.error = Some("Agent Runner endpoint 协议版本不兼容".to_string());
return fallback;
}
match send_external_agent_runner_request(
&endpoint,
"runner.status",
ExternalAgentRunnerRequestParams::default(),
) {
Ok(value) => match serde_json::from_value::<ExternalAgentRunnerStatus>(value) {
Ok(mut status) => {
status.enabled = true;
status.error = None;
status
}
Err(_) => {
fallback.error = Some("解析 Agent Runner 状态失败".to_string());
fallback
}
},
Err(error) => {
fallback.error = Some(redact_runner_secret(&error, &endpoint.token));
fallback
}
}
}
pub(crate) fn read_external_agent_runner_status() -> ExternalAgentRunnerStatus {
let config_dir = external_agent_runner_config_dir();
read_external_agent_runner_status_at(config_dir.as_deref())
}
#[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 <appdata>\\state.json"
);
assert_eq!(
sanitize_agent_runner_output("Authorization: Bearer secret", config_dir),
"<sensitive runner output redacted>"
);
assert_eq!(
sanitize_agent_runner_output(
r"agent.runner.failed: project C:\private\game\index.html failed",
config_dir,
),
"agent.runner.failed: project <absolute-path> failed"
);
assert_eq!(
sanitize_agent_runner_output("normal model response body", config_dir),
"<non-diagnostic runner output omitted>"
);
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?<query-redacted>"
);
assert_eq!(
sanitize_agent_runner_output("error password=hunter2", config_dir),
"<sensitive runner output redacted>"
);
}
#[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);
}
}