Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs
T
kdletters 0594a90bdd
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m15s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m58s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust crates (push) Successful in 3m1s
Project CI / Frontend tests (push) Failing after 4m24s
Project CI / Repository checks (push) Successful in 4m1s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / AI game creator shell web tests (push) Failing after 3m4s
Project CI / Backend tests (push) Successful in 8m20s
合入平台会话身份与凭据分离
平台会话快照拆分身份代次与写入 revision,凭据续期轮换不再中断在途生成
AGC Runner 请求参数新增 platform_auth_revision,并按 revision 做单调写入判定
解决与多窗口共享 Runner 的冲突,保留 claim adopt/publish 与界面参与锁语义
同步刷新轮换竞争、appSurface 鉴权用例与客户端 API 测试
补录平台会话身份与凭据分离的实施计划、共享记忆与决策记录
2026-09-16 17:42:00 +08:00

4309 lines
160 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::{
client::*, dispatch::*, endpoint::*, project_owner::*, protocol::*, server::*, state::*,
};
use serde_json::{json, Value};
use sha2::{Digest as _, Sha256};
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Cursor};
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{mpsc, Arc, Barrier, Mutex};
use std::time::{Duration, Instant};
use crate::{
default_agent_runtime_run_profile, unix_timestamp, AgentRuntimeTaskRecord,
ProcessSessionRecord, AGENT_RUNTIME_SCHEMA_VERSION,
};
static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TestDirectoryGuard(PathBuf);
impl Drop for TestDirectoryGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn unique_test_directory() -> TestDirectoryGuard {
let sequence = TEST_DIRECTORY_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"genarrative-agent-runner-test-{}-{}-{sequence}",
std::process::id(),
unix_millis()
));
fs::create_dir_all(&path).expect("create runner test directory");
TestDirectoryGuard(path)
}
fn private_runner_test_config_dir(directory: &TestDirectoryGuard) -> PathBuf {
crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData")
}
/// 模拟一个界面窗口:持有界面参与锁,并发布自己的 owner claim。
struct TestGuiParticipant {
_lock: ExternalAgentRunnerGuiParticipantLock,
owner_epoch: String,
}
fn acquire_test_gui_participant(config_dir: &Path, session_revision: u64) -> TestGuiParticipant {
let lock = acquire_external_agent_runner_gui_participant_lock(config_dir)
.expect("acquire GUI participant lock");
let claim = publish_external_agent_runner_gui_owner_claim(config_dir, session_revision)
.expect("publish GUI owner claim");
TestGuiParticipant {
_lock: lock,
owner_epoch: claim.owner_epoch,
}
}
fn acquire_project_owner_after_release(
root: &Path,
boot_id: &str,
) -> ExternalAgentRunnerProjectExecutionOwner {
let mut last_error = None;
for attempt in 0..100 {
match acquire_external_agent_runner_project_execution_owner(
root,
boot_id,
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
) {
Ok(owner) => return owner,
Err(error) if error.contains("另一个 Agent Runner") && attempt < 99 => {
last_error = Some(error);
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("OS lock owner recovery failed: {error}"),
}
}
panic!(
"OS lock owner was not released: {}",
last_error.unwrap_or_else(|| "unknown lock error".to_string())
);
}
#[test]
fn context_compaction_client_uses_long_response_timeout_without_widening_other_methods() {
assert_eq!(
external_agent_runner_client_read_timeout("runtime.compact"),
EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT
);
assert!(
external_agent_runner_client_read_timeout("runtime.compact")
> EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
assert_eq!(
external_agent_runner_client_read_timeout("runtime.start"),
EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 7);
}
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
ExternalAgentRunnerEndpoint {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
pid: std::process::id(),
boot_id: boot_id.to_string(),
port,
token: token.to_string(),
heartbeat_at: 1_725_000_000_000,
executable_fingerprint: Some("a".repeat(64)),
process_start_identity: None,
}
}
fn spawn_identity_ping_fixture(
token: &str,
endpoint_boot_id: &str,
response_boot_id: &str,
) -> (ExternalAgentRunnerEndpoint, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind identity ping fixture");
let port = listener
.local_addr()
.expect("identity fixture address")
.port();
let endpoint = test_endpoint(token, endpoint_boot_id, port);
let response_boot_id = response_boot_id.to_string();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept identity ping");
let payload = read_external_agent_runner_frame(&mut stream).expect("read identity ping");
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
.expect("parse identity ping");
let response = ExternalAgentRunnerResponse::success(
&request.request_id,
json!({
"status": "ok",
"pid": std::process::id(),
"bootId": response_boot_id,
}),
);
let response = serde_json::to_vec(&response).expect("serialize identity ping response");
write_external_agent_runner_frame(&mut stream, &response)
.expect("write identity ping response");
});
(endpoint, handle)
}
#[test]
fn legacy_runner_force_migration_requires_authenticated_exact_ping_identity() {
let token = "legacy-ping-token-legacy-ping-token";
let (endpoint, server) =
spawn_identity_ping_fixture(token, "legacy-ping-boot", "legacy-ping-boot");
verify_external_agent_runner_ping_identity(&endpoint)
.expect("matching authenticated ping authorizes legacy identity");
server.join().expect("join matching identity fixture");
let (endpoint, server) =
spawn_identity_ping_fixture(token, "legacy-ping-boot", "different-boot");
let error = verify_external_agent_runner_ping_identity(&endpoint)
.expect_err("mismatched boot must reject legacy identity");
assert!(error.contains("身份与 endpoint 不匹配"));
server.join().expect("join mismatched identity fixture");
}
#[test]
fn client_exit_client_returns_busy_without_waiting_and_accepts_idle_shutdown() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
let token = "client-exit-response-token-client-exit-response-token";
let busy_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind busy client-exit fixture");
let busy_endpoint = test_endpoint(
token,
"client-exit-busy-response-boot",
busy_listener
.local_addr()
.expect("busy fixture address")
.port(),
);
write_external_agent_runner_endpoint_atomic(&endpoint_path, &busy_endpoint)
.expect("write busy client-exit endpoint");
let busy_server = std::thread::spawn(move || {
let (mut stream, _) = busy_listener.accept().expect("accept busy client exit");
let payload = read_external_agent_runner_frame(&mut stream).expect("read busy client exit");
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
.expect("parse busy client exit");
assert_eq!(request.method, "runner.shutdown_for_client_exit");
let response = ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": false, "busy": true, "willShutdown": false }),
);
write_external_agent_runner_frame(
&mut stream,
&serde_json::to_vec(&response).expect("serialize busy client-exit response"),
)
.expect("write busy client-exit response");
});
let started = Instant::now();
assert!(
!shutdown_external_agent_runner_for_client_exit_at(&config_dir)
.expect("busy client exit remains a successful refusal")
);
assert!(
started.elapsed() < Duration::from_secs(2),
"busy client exit must not wait for Runner boot shutdown"
);
busy_server.join().expect("join busy client-exit fixture");
let idle_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind idle client-exit fixture");
let idle_endpoint = test_endpoint(
token,
"client-exit-idle-response-boot",
idle_listener
.local_addr()
.expect("idle fixture address")
.port(),
);
write_external_agent_runner_endpoint_atomic(&endpoint_path, &idle_endpoint)
.expect("write idle client-exit endpoint");
let idle_endpoint_path = endpoint_path.clone();
let idle_server = std::thread::spawn(move || {
let (mut stream, _) = idle_listener.accept().expect("accept idle client exit");
let payload = read_external_agent_runner_frame(&mut stream).expect("read idle client exit");
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
.expect("parse idle client exit");
assert_eq!(request.method, "runner.shutdown_for_client_exit");
let response = ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "accepted": true, "busy": false, "willShutdown": true }),
);
write_external_agent_runner_frame(
&mut stream,
&serde_json::to_vec(&response).expect("serialize idle client-exit response"),
)
.expect("write idle client-exit response");
drop(stream);
fs::remove_file(idle_endpoint_path).expect("remove idle endpoint after shutdown response");
});
assert!(
shutdown_external_agent_runner_for_client_exit_at(&config_dir)
.expect("idle client exit must complete Runner shutdown")
);
idle_server.join().expect("join idle client-exit fixture");
}
#[test]
fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() {
let endpoint = test_endpoint(
"shape-private-token-shape-private-token",
"shape-boot-id",
12001,
);
let mut legacy_value = serde_json::to_value(&endpoint).expect("serialize endpoint");
legacy_value
.as_object_mut()
.expect("endpoint object")
.remove("executableFingerprint");
let mut endpoint = serde_json::from_value::<ExternalAgentRunnerEndpoint>(legacy_value)
.expect("deserialize legacy endpoint without fingerprint");
assert_eq!(endpoint.executable_fingerprint, None);
endpoint
.validate_shape()
.expect("legacy endpoint remains readable for orderly retirement");
endpoint.executable_fingerprint = Some("f".repeat(63));
assert!(endpoint.validate_shape().is_err());
endpoint.executable_fingerprint = Some(format!("{}g", "f".repeat(63)));
assert!(endpoint.validate_shape().is_err());
endpoint.executable_fingerprint = Some("ABCDEF0123456789".repeat(4));
endpoint
.validate_shape()
.expect("64 hexadecimal digits are valid");
}
#[test]
fn executable_fingerprint_hashes_file_contents_with_sha256() {
let directory = unique_test_directory();
let executable = directory.0.join("runner-binary");
fs::write(&executable, b"abc").expect("write executable fixture");
assert_eq!(
external_agent_runner_executable_fingerprint_at(&executable)
.expect("fingerprint executable fixture"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() {
assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30));
}
#[test]
fn startup_runner_ping_timeout_rejects_an_exhausted_budget() {
let endpoint = test_endpoint(
"startup-ping-timeout-token",
"startup-ping-timeout-boot",
31_338,
);
let error = ping_external_agent_runner_with_timeout(&endpoint, Duration::ZERO)
.expect_err("an exhausted startup budget must not attempt a runner ping");
assert!(error.contains("超时预算已耗尽"), "{error}");
}
#[test]
fn forced_runner_drain_deadline_precedes_gui_hard_kill_deadline() {
assert!(
EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT
< EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
);
assert!(
EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
< EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT
);
assert!(
EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
< EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT
);
}
#[test]
fn gui_owned_runner_rejects_start_after_owner_was_already_lost() {
assert!(resolve_external_agent_runner_initial_gui_owner(true, false).is_err());
assert!(resolve_external_agent_runner_initial_gui_owner(true, true).unwrap());
assert!(!resolve_external_agent_runner_initial_gui_owner(false, false).unwrap());
}
#[test]
fn gui_runner_launch_arguments_bind_owner_requirement_to_gui_launches_only() {
let config_dir = Path::new("/private/app-data");
let gui_arguments = external_agent_runner_launch_arguments(config_dir, true);
let cli_arguments = external_agent_runner_launch_arguments(config_dir, false);
assert_eq!(
gui_arguments,
vec![
"--agent-runner",
"--config-dir",
"/private/app-data",
"--gui-owner-required"
]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
assert!(!cli_arguments
.iter()
.any(|argument| argument == "--gui-owner-required"));
}
#[test]
fn endpoint_reuse_requires_current_protocol_and_executable_identity() {
let current_fingerprint = "b".repeat(64);
let mut endpoint = test_endpoint(
"reuse-private-token-reuse-private-token",
"reuse-boot-id",
12002,
);
endpoint.executable_fingerprint = Some(current_fingerprint.clone());
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Reuse
);
endpoint.executable_fingerprint = None;
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Retire
);
endpoint.executable_fingerprint = Some("c".repeat(64));
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Retire
);
endpoint.executable_fingerprint = Some(current_fingerprint.clone());
endpoint.protocol_version += 1;
assert_eq!(
external_agent_runner_endpoint_reuse_decision(&endpoint, &current_fingerprint),
ExternalAgentRunnerReuseDecision::Retire
);
}
#[test]
fn idle_runner_shutdown_treats_missing_endpoint_as_already_stopped() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
assert!(shutdown_external_agent_runner_if_idle_at(&config_dir)
.expect("missing endpoint should already be stopped"));
}
#[test]
fn gui_runner_shutdown_rejects_missing_endpoint_while_runner_lock_is_held() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let _lock = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&config_dir),
"gui-shutdown-held-lock",
)
.expect("hold runner lock without endpoint");
let started = Instant::now();
let error = shutdown_external_agent_runner_at(&config_dir)
.expect_err("held Runner lock means missing endpoint is not proof of shutdown");
assert!(error.contains("实例锁仍被占用"));
assert!(
started.elapsed() < Duration::from_secs(2),
"GUI shutdown must not inherit the 30 second runner startup wait"
);
}
#[test]
fn gui_runner_shutdown_has_a_short_hard_timeout_for_an_unresponsive_endpoint() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind unresponsive runner fixture");
let port = listener.local_addr().expect("fixture address").port();
let endpoint = test_endpoint(
"gui-timeout-private-token-gui-timeout-private-token",
"gui-timeout-boot-id",
port,
);
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&config_dir),
&endpoint,
)
.expect("write unresponsive endpoint");
std::thread::spawn(move || {
let (_stream, _) = listener.accept().expect("accept GUI shutdown request");
std::thread::sleep(Duration::from_secs(2));
});
let started = Instant::now();
let error = shutdown_external_agent_runner_at(&config_dir)
.expect_err("unresponsive runner must hit the GUI shutdown deadline");
assert!(error.contains("读取 Agent Runner 响应失败"));
assert!(
started.elapsed() < Duration::from_secs(2),
"GUI shutdown must not block on the normal 10/30 second runner deadlines"
);
}
#[cfg(target_os = "linux")]
#[test]
fn gui_runner_shutdown_terminates_a_verified_unresponsive_runner_process() {
let directory = unique_test_directory();
let mut runner_process = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn unresponsive runner process fixture");
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
.expect("bind unresponsive runner fixture");
let port = listener.local_addr().expect("fixture address").port();
let mut endpoint = test_endpoint(
"gui-force-private-token-gui-force-private-token",
"gui-force-boot-id",
port,
);
endpoint.pid = runner_process.id();
endpoint.process_start_identity =
external_agent_runner_process_start_identity(runner_process.id())
.expect("read runner process start identity");
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&directory.0),
&endpoint,
)
.expect("write unresponsive endpoint");
std::thread::spawn(move || {
let (_stream, _) = listener.accept().expect("accept GUI shutdown request");
std::thread::sleep(Duration::from_secs(2));
});
let started = Instant::now();
shutdown_external_agent_runner_at(&directory.0)
.expect("verified unresponsive Runner must be terminated");
let status = runner_process
.wait()
.expect("reap terminated Runner fixture");
assert!(!status.success());
assert!(!external_agent_runner_endpoint_path(&directory.0).exists());
assert!(
started.elapsed() < Duration::from_secs(2),
"forced GUI shutdown must remain bounded"
);
}
#[cfg(target_os = "linux")]
#[test]
fn gui_runner_shutdown_fails_closed_without_exact_process_start_identity() {
let directory = unique_test_directory();
let mut candidate = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn candidate process fixture");
let mut endpoint = test_endpoint(
"legacy-force-private-token-legacy-force-private-token",
"legacy-force-boot-id",
9,
);
endpoint.pid = candidate.id();
endpoint.process_start_identity = None;
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&directory.0),
&endpoint,
)
.expect("write legacy endpoint");
let legacy_error = shutdown_external_agent_runner_at(&directory.0)
.expect_err("legacy endpoint must not authorize process termination");
assert!(legacy_error.contains("安全迁移失败") || legacy_error.contains("连接 Agent Runner"));
assert!(candidate.try_wait().expect("probe candidate").is_none());
endpoint.process_start_identity = Some("not-the-candidate-start-time".to_string());
write_external_agent_runner_endpoint_atomic(
&external_agent_runner_endpoint_path(&directory.0),
&endpoint,
)
.expect("write mismatched endpoint");
let mismatch_error = shutdown_external_agent_runner_at(&directory.0)
.expect_err("mismatched process identity must not authorize termination");
assert!(mismatch_error.contains("pid 已被其他进程复用"));
assert!(candidate.try_wait().expect("probe candidate").is_none());
candidate.kill().expect("stop candidate fixture");
candidate.wait().expect("reap candidate fixture");
}
#[cfg(unix)]
#[test]
fn idle_runner_shutdown_rejects_symlinked_endpoint() {
use std::os::unix::fs::symlink;
let directory = unique_test_directory();
let target = directory.0.join("endpoint-target.json");
fs::write(&target, b"{}").expect("write endpoint target");
symlink(
&target,
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
)
.expect("create endpoint symlink");
let error = shutdown_external_agent_runner_if_idle_at(&directory.0)
.expect_err("endpoint symlink must be rejected");
assert!(error.contains("符号链接"));
}
#[cfg(unix)]
#[test]
fn runner_endpoint_rejects_hard_links() {
let directory = unique_test_directory();
let endpoint_path = external_agent_runner_endpoint_path(&directory.0);
write_external_agent_runner_endpoint_atomic(
&endpoint_path,
&test_endpoint(
"hardlink-endpoint-token-hardlink-endpoint-token",
"hardlink-endpoint-boot",
31318,
),
)
.expect("write endpoint");
fs::hard_link(&endpoint_path, directory.0.join("endpoint-hardlink.json"))
.expect("create endpoint hard link");
let error = match read_external_agent_runner_endpoint(&endpoint_path) {
Ok(_) => panic!("hard-linked endpoint must be rejected"),
Err(error) => error,
};
assert!(error.contains("硬链接"));
}
#[test]
fn gui_owner_registration_replays_once_for_each_runner_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("registered-gui-appdata");
let event_sink_port = 31_317;
let event_sink_token = "a".repeat(64);
let params = ExternalAgentRunnerRequestParams {
event_sink_port: Some(event_sink_port),
event_sink_token: Some(event_sink_token.clone()),
..ExternalAgentRunnerRequestParams::default()
};
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
params,
)
.expect("register GUI owner attachment");
let calls = std::cell::RefCell::new(Vec::new());
let endpoint_a = test_endpoint(
"gui-owner-replay-token-gui-owner-replay-token",
"gui-owner-boot-a",
31318,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_a,
|endpoint, params| {
calls.borrow_mut().push((
endpoint.boot_id.clone(),
params
.event_sink_port
.expect("registered sink port is retained"),
params
.event_sink_token
.expect("registered sink token is retained"),
));
Ok(())
},
)
.expect("first boot attaches");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_a,
|_, _| panic!("same boot must not attach twice"),
)
.expect("same boot is idempotent");
let endpoint_b = test_endpoint(
"gui-owner-replay-token-gui-owner-replay-token",
"gui-owner-boot-b",
31319,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_b,
|endpoint, params| {
calls.borrow_mut().push((
endpoint.boot_id.clone(),
params
.event_sink_port
.expect("registered sink port is replayed"),
params
.event_sink_token
.expect("registered sink token is replayed"),
));
Ok(())
},
)
.expect("replacement boot reattaches");
assert_eq!(
calls.into_inner(),
vec![
(
"gui-owner-boot-a".to_string(),
event_sink_port,
event_sink_token.clone(),
),
(
"gui-owner-boot-b".to_string(),
event_sink_port,
event_sink_token,
),
]
);
}
#[test]
fn gui_owner_registration_replays_only_the_latest_platform_session() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("platform-session-replay-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_330),
event_sink_token: Some("f".repeat(64)),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("register GUI owner attachment");
remember_external_agent_runner_platform_session(
&state,
Some(("user-a", "token-a", "https://dev.genarrative.world")),
4,
4,
)
.expect("remember owner A session");
remember_external_agent_runner_platform_session(&state, None, 5, 5)
.expect("remember logged-out session");
remember_external_agent_runner_platform_session(
&state,
Some(("user-a", "late-token-a", "https://dev.genarrative.world")),
4,
4,
)
.expect("ignore stale owner A session");
remember_external_agent_runner_platform_session(
&state,
Some((
"user-a",
"same-generation-token",
"https://dev.genarrative.world",
)),
5,
5,
)
.expect("ignore conflicting same-generation session");
remember_external_agent_runner_platform_session(
&state,
Some(("user-b", "token-b", "https://dev.genarrative.world")),
6,
6,
)
.expect("remember latest owner B session");
let endpoint = test_endpoint(
"platform-session-replay-runner-token",
"platform-session-replay-boot",
31_330,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, params| {
assert_eq!(params.platform_user_id.as_deref(), Some("user-b"));
assert_eq!(params.platform_access_token.as_deref(), Some("token-b"));
assert_eq!(
params.platform_api_base_url.as_deref(),
Some("https://dev.genarrative.world")
);
assert_eq!(params.platform_auth_generation, Some(6));
Ok(())
},
)
.expect("latest platform session is replayed");
}
#[test]
fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("platform-session-dirty-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_331),
event_sink_token: Some("d".repeat(64)),
platform_user_id: Some("user-a".to_string()),
platform_access_token: Some("token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("register GUI owner attachment");
let endpoint = test_endpoint(
"platform-session-dirty-runner-token",
"platform-session-dirty-boot",
31_331,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, params| {
assert_eq!(params.platform_user_id.as_deref(), Some("user-a"));
Ok(())
},
)
.expect("attach owner A");
remember_external_agent_runner_platform_session(&state, None, 2, 2)
.expect("remember logged-out session");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, params| {
assert_eq!(params.platform_auth_generation, Some(2));
assert_eq!(params.platform_user_id, None);
assert_eq!(params.platform_access_token, None);
assert_eq!(params.platform_api_base_url, None);
Ok(())
},
)
.expect("same boot reattaches the logged-out generation");
}
#[test]
fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("platform-session-stale-attach-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams {
platform_user_id: Some("user-a".to_string()),
platform_access_token: Some("token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("register GUI owner attachment");
let endpoint = test_endpoint(
"platform-session-stale-attach-token",
"platform-session-stale-attach-boot",
31_332,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, params| {
assert_eq!(params.platform_user_id.as_deref(), Some("user-a"));
remember_external_agent_runner_platform_session(
&state,
Some(("user-b", "token-b", "https://dev.genarrative.world")),
2,
2,
)
.expect("remember owner B while owner A attach is in flight");
Ok(())
},
)
.expect("stale owner A attach response returns");
let retries = std::cell::Cell::new(0_u32);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, params| {
retries.set(retries.get() + 1);
assert_eq!(params.platform_user_id.as_deref(), Some("user-b"));
assert_eq!(params.platform_auth_generation, Some(2));
Ok(())
},
)
.expect("new owner B remains pending after stale completion");
assert_eq!(retries.get(), 1);
}
#[test]
fn gui_owner_platform_session_payload_clears_runner_session() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let owner = acquire_test_gui_participant(&config_dir, 0);
let state = ExternalAgentRunnerServerState::new(
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint("platform-clear-token", "platform-clear-boot", 31_333),
);
let _session = crate::install_test_platform_session(
"runner-owner-a",
"runner-token-a",
"https://dev.genarrative.world",
);
apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
platform_auth_generation: Some(2),
platform_auth_revision: Some(2),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("explicit logged-out payload clears Runner session");
assert_eq!(crate::current_platform_session(), None);
}
#[test]
fn gui_owner_partial_platform_session_payload_fails_without_mutation() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let owner = acquire_test_gui_participant(&config_dir, 0);
let state = ExternalAgentRunnerServerState::new(
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint("platform-partial-token", "platform-partial-boot", 31_334),
);
let _session = crate::install_test_platform_session(
"runner-owner-a",
"runner-token-a",
"https://dev.genarrative.world",
);
let before = crate::current_platform_session();
let error = apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
platform_user_id: Some("runner-owner-b".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(2),
platform_auth_revision: Some(2),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect_err("partial platform session must fail closed");
assert!(error.contains("参数不完整"), "{error}");
assert_eq!(crate::current_platform_session(), before);
}
#[test]
fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old_epoch() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let state = ExternalAgentRunnerServerState::new(
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint("platform-epoch-token", "platform-epoch-boot", 31_335),
);
let _session = crate::install_test_platform_session(
"runner-owner-seed",
"runner-token-seed",
"https://dev.genarrative.world",
);
let owner_a = acquire_test_gui_participant(&config_dir, 0);
let owner_a_epoch = owner_a.owner_epoch.clone();
apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner_a_epoch.clone()),
gui_owner_session_revision: Some(0),
platform_user_id: Some("runner-owner-a".to_string()),
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(10),
platform_auth_revision: Some(10),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("old GUI installs high-generation owner A");
drop(owner_a);
let owner_b = acquire_test_gui_participant(&config_dir, 0);
apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner_b.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
platform_user_id: Some("runner-owner-b".to_string()),
platform_access_token: Some("runner-token-b".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("new GUI epoch replaces higher-generation old owner");
assert_eq!(
crate::current_platform_session()
.map(|session| (session.user_id, session.identity_generation)),
Some(("runner-owner-b".to_string(), 1))
);
let stale_error = apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner_a_epoch),
gui_owner_session_revision: Some(0),
platform_user_id: Some("runner-owner-a".to_string()),
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(11),
platform_auth_revision: Some(11),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect_err("old GUI epoch must not overwrite the current owner");
assert!(stale_error.contains("claim 已过期"), "{stale_error}");
assert_eq!(
crate::current_platform_session().map(|session| session.user_id),
Some("runner-owner-b".to_string())
);
}
#[test]
fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_reattach() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let token = "platform-claim-gate-token-platform-claim-gate-token";
let state = ExternalAgentRunnerServerState::new(
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "platform-claim-gate-boot", 31_337),
);
let owner = acquire_test_gui_participant(&config_dir, 0);
let _session = crate::install_test_platform_session(
"runner-owner-seed",
"runner-token-seed",
"https://dev.genarrative.world",
);
apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
platform_user_id: Some("runner-owner-a".to_string()),
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(8),
platform_auth_revision: Some(8),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("attach owner A claim");
state.gui_owner_attached.store(true, Ordering::Release);
write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1)
.expect("advance durable claim before reattach");
assert!(
!external_agent_runner_shutdown_if_gui_owner_lost(&state)
.expect("watchdog observes a still-live GUI owner"),
"claim mismatch must isolate the platform session without stopping a live GUI owner"
);
assert_eq!(crate::current_platform_session(), None);
apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(1),
platform_user_id: Some("runner-owner-b".to_string()),
platform_access_token: Some("runner-token-b".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("current durable claim reattaches owner B");
validate_external_agent_runner_gui_owner_claim_current(&state)
.expect("reattached owner B claim is current");
assert_eq!(
crate::current_platform_session()
.map(|session| (session.user_id, session.identity_generation)),
Some(("runner-owner-b".to_string(), 1))
);
}
#[test]
fn failed_platform_session_sync_fences_runner_before_returning_error() {
let fenced = std::cell::Cell::new(false);
let error = synchronize_external_agent_runner_platform_session_with(
|| Err("injected platform-session sync failure".to_string()),
|| {
fenced.set(true);
Ok(())
},
)
.expect_err("sync failure must be returned after fencing");
assert!(fenced.get());
assert!(error.contains("Agent Runner 已停止"), "{error}");
let fence_error = synchronize_external_agent_runner_platform_session_with(
|| Err("injected platform-session sync failure".to_string()),
|| Err("injected runner fence failure".to_string()),
)
.expect_err("failed fence must report both errors");
assert!(fence_error.contains("阻断旧账号 Agent Runner 失败"));
assert!(fence_error.contains("injected runner fence failure"));
}
#[test]
fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let owner = acquire_test_gui_participant(&config_dir, 0);
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner.owner_epoch.clone()),
platform_user_id: Some("runner-owner-a".to_string()),
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("register owner A");
let fenced = std::cell::Cell::new(false);
let error = synchronize_external_agent_runner_platform_session_with(
|| {
remember_external_agent_runner_platform_session_with(
&state,
Some((
"runner-owner-b",
"runner-token-b",
"https://dev.genarrative.world",
)),
2,
2,
|_, _| Err("injected durable claim write failure".to_string()),
)
},
|| {
fenced.set(true);
Ok(())
},
)
.expect_err("claim write failure must fence the Runner");
assert!(fenced.get());
assert!(error.contains("durable claim write failure"), "{error}");
let endpoint = test_endpoint(
"claim-write-failure-token-claim-write-failure-token",
"claim-write-failure-boot",
31_336,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, params| {
assert_eq!(params.platform_user_id.as_deref(), Some("runner-owner-a"));
assert_eq!(params.platform_auth_generation, Some(1));
Ok(())
},
)
.expect("failed claim write must leave owner A registration intact");
}
#[test]
fn runner_output_redacts_camel_case_platform_access_token_fields() {
let config_dir = Path::new("private-appdata");
assert_eq!(
sanitize_agent_runner_output(
r#"request={"platformAccessToken":"must-not-leak"}"#,
config_dir,
),
"<sensitive runner output redacted>"
);
}
#[test]
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("retry-gui-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams::default(),
)
.expect("register GUI owner attachment");
let endpoint = test_endpoint(
"gui-owner-retry-token-gui-owner-retry-token",
"gui-owner-retry-boot",
31320,
);
let attempts = std::cell::Cell::new(0_u32);
let error = attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
Err("injected attach failure".to_string())
},
)
.expect_err("failed attach must remain pending");
assert_eq!(error, "injected attach failure");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
Ok(())
},
)
.expect("same boot retries after failure");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| panic!("successful retry must mark the boot attached"),
)
.expect("successful retry is idempotent");
assert_eq!(attempts.get(), 2);
}
#[test]
fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("missing-sink-confirmation-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_322),
event_sink_token: Some("c".repeat(64)),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("register GUI owner attachment");
let endpoint = test_endpoint(
"missing-sink-confirmation-runner-token",
"missing-sink-confirmation-boot",
31_322,
);
let attempts = std::cell::Cell::new(0_u32);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
validate_external_agent_runner_gui_owner_attachment_result(&json!({
"attached": true
}))
},
)
.expect_err("missing eventSinkAttached must fail");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
validate_external_agent_runner_gui_owner_attachment_result(&json!({
"attached": true,
"eventSinkAttached": true
}))
},
)
.expect("same boot retries after missing event sink confirmation");
assert_eq!(attempts.get(), 2);
}
#[test]
fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("false-sink-confirmation-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_323),
event_sink_token: Some("d".repeat(64)),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("register GUI owner attachment");
let endpoint = test_endpoint(
"false-sink-confirmation-runner-token",
"false-sink-confirmation-boot",
31_323,
);
let attempts = std::cell::Cell::new(0_u32);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
validate_external_agent_runner_gui_owner_attachment_result(&json!({
"attached": true,
"eventSinkAttached": false
}))
},
)
.expect_err("false eventSinkAttached must fail");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
validate_external_agent_runner_gui_owner_attachment_result(&json!({
"attached": true,
"eventSinkAttached": true
}))
},
)
.expect("same boot retries after false event sink confirmation");
assert_eq!(attempts.get(), 2);
}
#[test]
fn gui_owner_registration_does_not_cross_config_dirs() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let registered_config_dir = PathBuf::from("registered-gui-appdata");
let other_config_dir = PathBuf::from("other-gui-appdata");
let event_sink_token = "e".repeat(64);
register_external_agent_runner_gui_owner_attachment(
&state,
&registered_config_dir,
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_324),
event_sink_token: Some(event_sink_token.clone()),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("register GUI owner attachment");
let endpoint = test_endpoint(
"gui-owner-config-token-gui-owner-config-token",
"gui-owner-config-boot",
31321,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&other_config_dir,
&endpoint,
|_, _| panic!("GUI owner registration must stay bound to its AppData"),
)
.expect("other AppData remains unattached");
let calls = std::cell::Cell::new(0_u32);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&registered_config_dir,
&endpoint,
|_, params| {
calls.set(calls.get() + 1);
assert_eq!(params.event_sink_port, Some(31_324));
assert_eq!(
params.event_sink_token.as_deref(),
Some(event_sink_token.as_str())
);
Ok(())
},
)
.expect("registered AppData attaches");
assert_eq!(calls.get(), 1);
let unregistered = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&unregistered,
&registered_config_dir,
&endpoint,
|_, _| panic!("CLI state without GUI registration must not attach"),
)
.expect("unregistered CLI state remains unchanged");
}
#[test]
fn gui_participant_lock_allows_multiple_windows_and_tracks_liveness() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let participant_lock_path = external_agent_runner_gui_participant_lock_path(&config_dir);
assert!(!external_agent_runner_lock_is_held(&participant_lock_path)
.expect("probe without any window"));
let first = acquire_external_agent_runner_gui_participant_lock(&config_dir)
.expect("first window participates");
assert!(external_agent_runner_lock_is_held(&participant_lock_path)
.expect("first window keeps the runner alive"));
let second = acquire_external_agent_runner_gui_participant_lock(&config_dir)
.expect("second window shares the same AppData");
drop(second);
assert!(
external_agent_runner_lock_is_held(&participant_lock_path)
.expect("remaining window keeps the runner alive"),
"runner must survive while any window is still open"
);
drop(first);
assert!(
!external_agent_runner_lock_is_held(&participant_lock_path)
.expect("last window releases the participant lock"),
"runner may stop once every window has exited"
);
}
#[test]
fn gui_owner_claim_adoption_keeps_epoch_and_publication_rotates_it() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let published =
publish_external_agent_runner_gui_owner_claim(&config_dir, 3).expect("publish claim");
assert_eq!(published.session_revision, 3);
let adopted = adopt_or_publish_external_agent_runner_gui_owner_claim(&config_dir, 9)
.expect("adopt existing claim");
assert_eq!(adopted.owner_epoch, published.owner_epoch);
assert_eq!(
adopted.session_revision, 3,
"采纳路径必须沿用现有 claim,不能推进 revision 或换 epoch"
);
let rotated =
publish_external_agent_runner_gui_owner_claim(&config_dir, 9).expect("publish new claim");
assert_ne!(rotated.owner_epoch, published.owner_epoch);
assert_eq!(rotated.session_revision, 9);
assert_eq!(
read_external_agent_runner_gui_owner_claim(&config_dir)
.expect("read durable claim")
.session_revision,
9
);
}
#[test]
fn second_window_attach_with_same_claim_keeps_runner_platform_session() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let state = ExternalAgentRunnerServerState::new(
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"multi-window-claim-token-multi-window-claim-token",
"multi-window-claim-boot",
31_338,
),
);
let _session = crate::install_test_platform_session(
"runner-owner-a",
"runner-token-a",
"https://dev.genarrative.world",
);
let owner = acquire_test_gui_participant(&config_dir, 0);
apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
platform_user_id: Some("runner-owner-a".to_string()),
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(7),
platform_auth_revision: Some(7),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("first window installs its session");
assert_eq!(
crate::current_platform_session()
.map(|session| (session.user_id, session.identity_generation)),
Some(("runner-owner-a".to_string(), 7))
);
// 第二个窗口启动时本身还没有登录态:同 claim 的 attach 只能是空操作。
apply_external_agent_runner_gui_owner_platform_session(
&state,
&ExternalAgentRunnerRequestParams {
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("second window attaches with the same claim");
assert_eq!(
crate::current_platform_session()
.map(|session| (session.user_id, session.identity_generation)),
Some(("runner-owner-a".to_string(), 7)),
"同一 claim 的第二个窗口不得清空平台登录态"
);
}
#[test]
fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_up() {
let sink_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard();
assert_eq!(sink_guard.configured_sink(), None);
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let token = "gui-owner-monitor-token-gui-owner-monitor-token";
let state = ExternalAgentRunnerServerState::new(
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "gui-owner-monitor-boot", 31319),
);
let owner = acquire_test_gui_participant(&config_dir, 0);
let attached = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "gui-owner-attach-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_318),
event_sink_token: Some("b".repeat(64)),
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(attached.ok);
assert!(state.gui_owner_attached.load(Ordering::Acquire));
assert_eq!(
sink_guard.configured_sink(),
Some(crate::GameCreatorManifestInvalidationEventSink {
port: 31_318,
token: "b".repeat(64),
})
);
assert!(
!external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present")
);
write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1)
.expect("advance owner claim revision");
let replacement = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "gui-owner-attach-2".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_319),
event_sink_token: Some("c".repeat(64)),
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(replacement.ok);
assert_eq!(
sink_guard.configured_sinks(),
vec![
crate::GameCreatorManifestInvalidationEventSink {
port: 31_318,
token: "b".repeat(64),
},
crate::GameCreatorManifestInvalidationEventSink {
port: 31_319,
token: "c".repeat(64),
},
],
"第二个窗口 attach 必须让两个接收端同时保留"
);
let stale_replay = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "gui-owner-attach-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_318),
event_sink_token: Some("b".repeat(64)),
gui_owner_epoch: Some(owner.owner_epoch.clone()),
gui_owner_session_revision: Some(0),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(!stale_replay.ok);
assert_eq!(
stale_replay.error.as_ref().map(|error| error.code.as_str()),
Some("platform-session-invalid")
);
assert_eq!(
sink_guard.configured_sinks(),
vec![
crate::GameCreatorManifestInvalidationEventSink {
port: 31_318,
token: "b".repeat(64),
},
crate::GameCreatorManifestInvalidationEventSink {
port: 31_319,
token: "c".repeat(64),
},
],
"旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端"
);
drop(owner);
assert!(external_agent_runner_shutdown_if_gui_owner_lost(&state)
.expect("owner loss requests shutdown"));
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
drop(sink_guard);
let cleanup_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard();
assert_eq!(cleanup_guard.configured_sink(), None);
}
#[test]
fn gui_owner_attach_rejects_missing_owner_lock() {
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let token = "gui-owner-missing-token-gui-owner-missing-token";
let state = ExternalAgentRunnerServerState::new(
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "gui-owner-missing-boot", 31320),
);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "gui-owner-attach-missing-1".to_string(),
token: token.to_string(),
method: "runner.attach_gui_owner".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(!response.ok);
assert_eq!(
response.error.as_ref().map(|error| error.code.as_str()),
Some("gui-owner-missing")
);
assert!(!state.gui_owner_attached.load(Ordering::Acquire));
}
#[test]
fn framing_round_trips_length_prefixed_json() {
let payload = br#"{"method":"runner.ping","requestId":"request-1"}"#;
let mut framed = Vec::new();
write_external_agent_runner_frame(&mut framed, payload).expect("write frame");
assert_eq!(
&framed[..4],
&(payload.len() as u32).to_be_bytes(),
"frame prefix uses network byte order"
);
let decoded =
read_external_agent_runner_frame(&mut Cursor::new(framed)).expect("read framed payload");
assert_eq!(decoded, payload);
}
#[test]
fn framing_rejects_oversize_before_reading_payload() {
let declared = (EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES as u32) + 1;
let error = read_external_agent_runner_frame(&mut Cursor::new(declared.to_be_bytes()))
.expect_err("oversize frame must fail");
assert!(matches!(
error,
ExternalAgentRunnerFrameError::Oversize(value) if value == declared
));
let payload = vec![0_u8; EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES + 1];
let error = write_external_agent_runner_frame(&mut Vec::new(), &payload)
.expect_err("oversize response must fail");
assert!(matches!(error, ExternalAgentRunnerFrameError::Oversize(_)));
}
#[test]
fn authentication_rejects_wrong_token_without_echoing_secrets() {
let directory = unique_test_directory();
let endpoint = test_endpoint(
"correct-private-token-correct-private-token",
"test-boot-id",
12345,
);
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
endpoint,
);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "auth-request-1".to_string(),
token: "wrong-private-token-wrong-private-token".to_string(),
method: "runner.ping".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(!response.ok);
assert_eq!(
response.error.as_ref().map(|error| error.code.as_str()),
Some("unauthorized")
);
let serialized = serde_json::to_string(&response).expect("serialize auth response");
assert!(!serialized.contains("correct-private-token"));
assert!(!serialized.contains("wrong-private-token"));
assert!(!serialized.contains("\"token\""));
}
#[test]
fn runtime_error_redaction_hides_project_and_absolute_paths_from_runner_clients() {
let directory = unique_test_directory();
let token = "runner-private-token-runner-private-token";
let failing_path = directory
.0
.join(".agent/runtime/tool-plan-handoffs/broken-ledger.json");
let error = format!(
"读取 tool-plan 成功响应交接失败:{};token={token};backup=/home/private/ledger.previous",
failing_path.display()
);
let redacted = redact_external_agent_runner_runtime_error(&directory.0, &error, token);
assert!(!redacted.contains(directory.0.to_string_lossy().as_ref()));
assert!(!redacted.contains(token));
assert!(!redacted.contains("/home/private"));
assert!(redacted.contains("$PROJECT_ROOT"));
assert!(redacted.contains("<absolute-path>"));
}
#[test]
fn continuation_params_bind_agent_run_and_action_exactly() {
let request = ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "continue-exact-1".to_string(),
token: "continue-private-token-continue-private-token".to_string(),
method: "runtime.continue_action".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some("/tmp/exact-project".to_string()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-exact-7".to_string()),
action_id: Some("action-exact-9".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
};
assert_eq!(
external_agent_runner_request_agent(&request).as_deref(),
Ok("code-prototype")
);
assert_eq!(
external_agent_runner_request_run_id(&request).as_deref(),
Ok("run-exact-7")
);
assert_eq!(
external_agent_runner_request_action_id(&request).as_deref(),
Ok("action-exact-9")
);
let wire = serde_json::to_value(&request).expect("serialize exact continuation");
assert_eq!(wire["params"]["runId"], "run-exact-7");
assert_eq!(wire["params"]["actionId"], "action-exact-9");
}
#[test]
fn project_supervisor_targeted_wake_requires_exact_progress_or_terminal_state() {
let probe = |agent_id: &str, run_id: &str, status: &str, phase: &str| {
ExternalAgentRunnerTargetRunProbe {
agent_id: agent_id.to_string(),
run_id: run_id.to_string(),
status: status.to_string(),
phase: phase.to_string(),
}
};
let agent_id = "project-supervisor";
let run_id = "supervisor-parent-run-1";
let waiting = probe(agent_id, run_id, "running", "waiting-for-delegate-receipts");
let provider_retry_waiting = probe(agent_id, run_id, "running", "waiting-for-provider-retry");
assert_eq!(
classify_external_agent_runner_target_wake(agent_id, run_id, &[], Some(&waiting)),
Err(ExternalAgentRunnerTargetWakeRetry::StillPending),
"a busy target lane must not turn an empty global scan into success"
);
assert_eq!(
classify_external_agent_runner_target_wake(
agent_id,
run_id,
&[probe(
"design-foundation",
"child-run-1",
"running",
"planning"
)],
None,
),
Err(ExternalAgentRunnerTargetWakeRetry::NotObserved),
"advancing another lane must not acknowledge the target run"
);
assert_eq!(
classify_external_agent_runner_target_wake(
agent_id,
run_id,
std::slice::from_ref(&waiting),
Some(&waiting),
),
Err(ExternalAgentRunnerTargetWakeRetry::StillPending),
"observing the target without advancing it remains retryable"
);
assert_eq!(
classify_external_agent_runner_target_wake(
agent_id,
run_id,
std::slice::from_ref(&provider_retry_waiting),
Some(&provider_retry_waiting),
),
Err(ExternalAgentRunnerTargetWakeRetry::StillPending),
"a durable Provider retry wait remains retryable until the target advances"
);
let advanced = probe(agent_id, run_id, "running", "planning");
assert_eq!(
classify_external_agent_runner_target_wake(
agent_id,
run_id,
std::slice::from_ref(&advanced),
Some(&waiting),
),
Ok(()),
"the exact target may be acknowledged after the scan advances it"
);
let completed = probe(agent_id, run_id, "completed", "completed");
assert_eq!(
classify_external_agent_runner_target_wake(agent_id, run_id, &[], Some(&completed)),
Ok(()),
"a terminal target no longer needs wake processing"
);
}
#[test]
fn project_supervisor_retryable_targeted_wake_is_not_cached_before_success() {
let request_id = "runtime-parent-wake-stable-1";
let fingerprint = "stable-targeted-wake-fingerprint";
let mut cache = ExternalAgentRunnerRequestCache::default();
for retry in [
ExternalAgentRunnerTargetWakeRetry::NotObserved,
ExternalAgentRunnerTargetWakeRetry::StillPending,
] {
let response = external_agent_runner_target_wake_retryable_response(request_id, retry);
assert!(!response.ok);
assert_eq!(
response.error.as_ref().map(|error| error.code.as_str()),
Some(EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE)
);
cache_external_agent_runner_response_if_cacheable(
&mut cache,
request_id,
fingerprint,
&response,
);
assert!(
cache.find(request_id).is_none(),
"retryable wake must leave the stable requestId free for another attempt"
);
}
let success = ExternalAgentRunnerResponse::success(request_id, json!({ "accepted": true }));
cache_external_agent_runner_response_if_cacheable(
&mut cache,
request_id,
fingerprint,
&success,
);
assert_eq!(
cache.find(request_id).map(|cached| &cached.response),
Some(&success),
"the stable requestId becomes cacheable only after wake is satisfied"
);
}
#[test]
fn steer_params_bind_identity_without_instruction_body() {
let instruction = "把角色移动速度改快一些";
let request = ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "steer-wire-1".to_string(),
token: "steer-private-token-steer-private-token".to_string(),
method: "runtime.steer".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some("/tmp/steer-project".to_string()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-steer-7".to_string()),
steer_id: Some("steer-9".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
};
assert_eq!(
external_agent_runner_request_steer_id(&request).as_deref(),
Ok("steer-9")
);
let wire = serde_json::to_value(&request).expect("serialize steer request");
let params = wire["params"].as_object().expect("steer params object");
assert_eq!(params.len(), 4);
assert_eq!(
params.get("root").and_then(Value::as_str),
Some("/tmp/steer-project")
);
assert_eq!(
params.get("agent").and_then(Value::as_str),
Some("code-prototype")
);
assert_eq!(
params.get("runId").and_then(Value::as_str),
Some("run-steer-7")
);
assert_eq!(
params.get("steerId").and_then(Value::as_str),
Some("steer-9")
);
let wire = serde_json::to_string(&wire).expect("serialize steer wire value");
assert!(!wire.contains(instruction));
assert!(!wire.contains("instruction"));
assert!(!wire.contains("content"));
}
#[test]
fn typed_steer_result_requires_provider_interrupted_boolean() {
assert_eq!(
parse_external_agent_runner_steer_result(&json!({
"providerInterrupted": true,
})),
Ok(true)
);
assert_eq!(
parse_external_agent_runner_steer_result(&json!({
"providerInterrupted": false,
})),
Ok(false)
);
assert!(parse_external_agent_runner_steer_result(&json!({ "accepted": true })).is_err());
}
#[test]
fn runtime_steer_queues_without_interrupt_and_deduplicates_request_id() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(&root, "project-steer-rpc", "Runner steer 测试")
.expect("initialize steer project");
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
&root,
"code-prototype",
None,
"实现一个可验证的键盘操作原型",
"run-steer-rpc",
"agent-background-task",
"准备规划实现步骤",
vec!["读取项目".to_string(), "实现并验证".to_string()],
)
.expect("start steer runtime");
let persisted = crate::steer_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
&runtime.session_id,
"run-steer-rpc",
"steer-rpc-1",
"先停下当前方案,改用键盘操作。",
"runner-test",
)
.expect("persist steer before runner notification");
assert_eq!(persisted.steer_id, "steer-rpc-1");
let appdata = directory.0.join("appdata");
fs::create_dir_all(&appdata).expect("create runner appdata");
let token = "steer-rpc-private-token-steer-rpc-private-token";
let state = ExternalAgentRunnerServerState::new(
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "steer-rpc-boot", 30303),
);
let (provider_key, active_provider) =
crate::register_game_creator_agent_runtime_provider_request(
&root,
"code-prototype",
"run-steer-rpc",
0,
)
.expect("register active Provider before runtime.steer");
let request = ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "steer-rpc-request-1".to_string(),
token: token.to_string(),
method: "runtime.steer".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(root.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-steer-rpc".to_string()),
steer_id: Some("steer-rpc-1".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
};
let first = dispatch_external_agent_runner_runtime_request(&request, &state);
assert!(first.ok, "runtime.steer failed: {:?}", first.error);
assert_eq!(
first
.result
.as_ref()
.and_then(|value| value["providerInterrupted"].as_bool()),
Some(false)
);
assert!(
!active_provider.interrupted.load(Ordering::Acquire),
"runtime.steer must not interrupt an active Provider before the LLM decision"
);
let replay = dispatch_external_agent_runner_runtime_request(&request, &state);
assert_eq!(replay, first);
assert!(!active_provider.interrupted.load(Ordering::Acquire));
let mut conflict = request;
conflict.params.steer_id = Some("steer-rpc-2".to_string());
let conflict = dispatch_external_agent_runner_runtime_request(&conflict, &state);
assert!(!conflict.ok);
assert_eq!(
conflict.error.as_ref().map(|error| error.code.as_str()),
Some("request-id-conflict")
);
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
}
#[test]
fn runtime_interrupt_for_steer_decision_rejects_missing_durable_decision() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-steer-missing-decision-rpc",
"Runner steer 缺少判定测试",
)
.expect("initialize missing-decision project");
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
&root,
"code-prototype",
None,
"保持当前 Provider 请求运行",
"run-steer-missing-decision-rpc",
"agent-background-task",
"等待 Supervisor 判定",
vec!["完成当前任务".to_string()],
)
.expect("start missing-decision runtime");
let steer = crate::steer_game_creator_agent_runtime_task_at(
&root,
&runtime.agent_id,
&runtime.session_id,
&runtime.run_id,
"steer-missing-decision-rpc-1",
"先告诉我进度。",
"runner-test",
)
.expect("persist steer without decision");
assert_eq!(steer.sequence, 1);
let (provider_key, active_provider) =
crate::register_game_creator_agent_runtime_provider_request(
&root,
&runtime.agent_id,
&runtime.run_id,
0,
)
.expect("register active Provider before missing decision request");
let token = "steer-missing-decision-token-steer-missing-decision-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "steer-missing-decision-boot", 30304),
);
let response = dispatch_external_agent_runner_runtime_request(
&ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "steer-missing-decision-request-1".to_string(),
token: token.to_string(),
method: "runtime.interrupt_for_steer_decision".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(root.to_string_lossy().into_owned()),
agent: Some(runtime.agent_id.clone()),
run_id: Some(runtime.run_id.clone()),
steer_id: Some(steer.steer_id),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(!response.ok);
assert_eq!(
response.error.as_ref().map(|error| error.code.as_str()),
Some("runtime-error")
);
assert!(response
.error
.as_ref()
.is_some_and(|error| error.message.contains("尚无 LLM 中断判定")));
assert!(
!active_provider.interrupted.load(Ordering::Acquire),
"a missing durable LLM decision must leave the active Provider running"
);
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
}
#[test]
fn runtime_interrupt_for_false_steer_decision_keeps_provider_running() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-steer-false-decision-rpc",
"Runner steer 不中断判定测试",
)
.expect("initialize false-decision project");
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
&root,
"code-prototype",
None,
"保持当前 Provider 请求运行",
"run-steer-false-decision-rpc",
"agent-background-task",
"处理状态询问",
vec!["完成当前任务".to_string()],
)
.expect("start false-decision runtime");
let steer = crate::steer_game_creator_agent_runtime_task_at(
&root,
&runtime.agent_id,
&runtime.session_id,
&runtime.run_id,
"steer-false-decision-rpc-1",
"现在做到哪一步了?",
"runner-test",
)
.expect("persist status-question steer");
crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at(
&root,
&runtime,
&steer.steer_id,
steer.sequence,
crate::AgentRuntimeSteerDecision {
reply: "当前任务仍在继续,我会按现有方向完成。".to_string(),
interrupt_current_provider: false,
reason: "状态询问不会让当前方案过期。".to_string(),
},
)
.expect("persist non-interrupting LLM decision");
let (provider_key, active_provider) =
crate::register_game_creator_agent_runtime_provider_request(
&root,
&runtime.agent_id,
&runtime.run_id,
0,
)
.expect("register active Provider for false decision");
let token = "steer-false-decision-token-steer-false-decision-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "steer-false-decision-boot", 30305),
);
let response = dispatch_external_agent_runner_runtime_request(
&ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "steer-false-decision-request-1".to_string(),
token: token.to_string(),
method: "runtime.interrupt_for_steer_decision".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(root.to_string_lossy().into_owned()),
agent: Some(runtime.agent_id.clone()),
run_id: Some(runtime.run_id.clone()),
steer_id: Some(steer.steer_id),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(
response.ok,
"false decision RPC failed: {:?}",
response.error
);
assert_eq!(
response
.result
.as_ref()
.and_then(|value| value["providerInterrupted"].as_bool()),
Some(false)
);
assert!(!active_provider.interrupted.load(Ordering::Acquire));
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
}
#[test]
fn runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-steer-true-decision-rpc",
"Runner steer 条件中断测试",
)
.expect("initialize true-decision project");
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
&root,
"code-prototype",
None,
"按当前方向实现玩法",
"run-steer-true-decision-rpc",
"agent-background-task",
"生成当前方案",
vec!["完成当前任务".to_string()],
)
.expect("start true-decision runtime");
let steer = crate::steer_game_creator_agent_runtime_task_at(
&root,
&runtime.agent_id,
&runtime.session_id,
&runtime.run_id,
"steer-true-decision-rpc-1",
"停止旧方向,改成全新的玩法。",
"runner-test",
)
.expect("persist conflicting steer");
crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at(
&root,
&runtime,
&steer.steer_id,
steer.sequence,
crate::AgentRuntimeSteerDecision {
reply: "这个改动会让旧方案过期,我会安全切换方向。".to_string(),
interrupt_current_provider: true,
reason: "当前 Provider 正在生成已经冲突的旧方案。".to_string(),
},
)
.expect("persist interrupting LLM decision");
let token = "steer-true-decision-token-steer-true-decision-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "steer-true-decision-boot", 30306),
);
let request = |request_id: &str| ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: request_id.to_string(),
token: token.to_string(),
method: "runtime.interrupt_for_steer_decision".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(root.to_string_lossy().into_owned()),
agent: Some(runtime.agent_id.clone()),
run_id: Some(runtime.run_id.clone()),
steer_id: Some(steer.steer_id.clone()),
..ExternalAgentRunnerRequestParams::default()
},
};
let (old_provider_key, old_provider) =
crate::register_game_creator_agent_runtime_provider_request(
&root,
&runtime.agent_id,
&runtime.run_id,
steer.sequence.saturating_sub(1),
)
.expect("register Provider created before steer");
let old_response = dispatch_external_agent_runner_runtime_request(
&request("steer-true-decision-old-provider-request"),
&state,
);
assert!(
old_response.ok,
"old Provider interrupt RPC failed: {:?}",
old_response.error
);
assert_eq!(
old_response
.result
.as_ref()
.and_then(|value| value["providerInterrupted"].as_bool()),
Some(true)
);
assert!(old_provider.interrupted.load(Ordering::Acquire));
crate::unregister_game_creator_agent_runtime_provider_request(&old_provider_key, &old_provider);
let (new_provider_key, new_provider) =
crate::register_game_creator_agent_runtime_provider_request(
&root,
&runtime.agent_id,
&runtime.run_id,
steer.sequence,
)
.expect("register Provider created after steer was applied");
let new_response = dispatch_external_agent_runner_runtime_request(
&request("steer-true-decision-new-provider-request"),
&state,
);
assert!(
new_response.ok,
"new Provider keep-running RPC failed: {:?}",
new_response.error
);
assert_eq!(
new_response
.result
.as_ref()
.and_then(|value| value["providerInterrupted"].as_bool()),
Some(false)
);
assert!(
!new_provider.interrupted.load(Ordering::Acquire),
"a Provider created at the decided steer cursor must keep running"
);
crate::unregister_game_creator_agent_runtime_provider_request(&new_provider_key, &new_provider);
}
#[test]
fn typed_goal_pause_and_cancel_require_durable_intent_and_keep_exact_run() {
let pause_directory = unique_test_directory();
let pause_root = pause_directory.0.join("pause-project");
crate::init_local_game_project_at(
&pause_root,
"project-goal-pause-rpc",
"Runner Goal pause 测试",
)
.expect("initialize Goal pause project");
let mut pause_runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
&pause_root,
"code-prototype",
None,
"暂停同一 Goal run",
"run-goal-pause-rpc",
"agent-background-task",
"等待暂停",
vec!["保持同一 run".to_string()],
)
.expect("start Goal pause runtime");
let pause_goal = crate::seed_game_creator_agent_goal_for_runtime_test_at(
&pause_root,
&mut pause_runtime,
"暂停后继续同一 run",
crate::AGENT_GOAL_STATUS_PAUSE_REQUESTED,
)
.expect("seed pause-requested Goal");
let pause_token = "goal-pause-rpc-token-goal-pause-rpc-token";
let pause_state = ExternalAgentRunnerServerState::new(
pause_directory
.0
.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(pause_token, "goal-pause-rpc-boot", 30313),
);
let pause_response = dispatch_external_agent_runner_runtime_request(
&ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "goal-pause-rpc-request".to_string(),
token: pause_token.to_string(),
method: "runtime.pause".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(pause_root.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-goal-pause-rpc".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
},
&pause_state,
);
assert!(
pause_response.ok,
"runtime.pause failed: {:?}",
pause_response.error
);
assert_eq!(
pause_response
.result
.as_ref()
.and_then(|value| value["providerInterrupted"].as_bool()),
Some(false)
);
let paused = crate::read_game_creator_agent_runtime_at(&pause_root, "code-prototype")
.expect("read paused Goal runtime")
.state;
assert_eq!(paused.run_id, pause_goal.run_id);
assert_eq!(paused.status, "paused");
assert_eq!(
paused.goal_status.as_deref(),
Some(crate::AGENT_GOAL_STATUS_PAUSED)
);
let cancel_directory = unique_test_directory();
let cancel_root = cancel_directory.0.join("cancel-project");
crate::init_local_game_project_at(
&cancel_root,
"project-goal-cancel-rpc",
"Runner Goal cancel 测试",
)
.expect("initialize Goal cancel project");
let mut cancel_runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
&cancel_root,
"code-prototype",
None,
"清理同一 Goal run",
"run-goal-cancel-rpc",
"agent-background-task",
"等待清理",
vec!["清理同一 run".to_string()],
)
.expect("start Goal cancel runtime");
let cancel_goal = crate::seed_game_creator_agent_goal_for_runtime_test_at(
&cancel_root,
&mut cancel_runtime,
"清理当前 Goal",
crate::AGENT_GOAL_STATUS_CLEARING,
)
.expect("seed clearing Goal");
let cancel_token = "goal-cancel-rpc-token-goal-cancel-rpc-token";
let cancel_state = ExternalAgentRunnerServerState::new(
cancel_directory
.0
.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(cancel_token, "goal-cancel-rpc-boot", 30314),
);
let cancel_response = dispatch_external_agent_runner_runtime_request(
&ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "goal-cancel-rpc-request".to_string(),
token: cancel_token.to_string(),
method: "runtime.cancel".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(cancel_root.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-goal-cancel-rpc".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
},
&cancel_state,
);
assert!(
cancel_response.ok,
"runtime.cancel failed: {:?}",
cancel_response.error
);
assert_eq!(
cancel_response
.result
.as_ref()
.and_then(|value| value["providerInterrupted"].as_bool()),
Some(false)
);
let cancelled = crate::read_game_creator_agent_runtime_at(&cancel_root, "code-prototype")
.expect("read cancelled Goal runtime")
.state;
assert_eq!(cancelled.run_id, cancel_goal.run_id);
assert_eq!(cancelled.status, "cancelled");
let cleared = crate::read_game_creator_agent_goal_at(
&cancel_root,
"code-prototype",
&cancel_goal.session_id,
)
.expect("read cleared Goal")
.expect("cleared Goal exists");
assert_eq!(cleared.status, crate::AGENT_GOAL_STATUS_CLEARED);
}
#[test]
fn draining_rejects_runtime_steer_and_compact() {
let directory = unique_test_directory();
let token = "steer-draining-token-steer-draining-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "steer-draining-boot", 31312),
);
state.draining.store(true, Ordering::Release);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "steer-draining-1".to_string(),
token: token.to_string(),
method: "runtime.steer".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(directory.0.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-steer-draining".to_string()),
steer_id: Some("steer-draining".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(!response.ok);
assert_eq!(
response.error.as_ref().map(|error| error.code.as_str()),
Some("runner-draining")
);
let compact_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "draining-compact-1".to_string(),
token: token.to_string(),
method: "runtime.compact".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(directory.0.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
session_id: Some("agent-session-code-prototype".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(!compact_response.ok);
assert_eq!(
compact_response
.error
.as_ref()
.map(|error| error.code.as_str()),
Some("runner-draining")
);
}
#[test]
fn draining_rejects_new_runtime_writes() {
let directory = unique_test_directory();
let token = "draining-private-token-draining-private-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "draining-boot-id", 31313),
);
state.draining.store(true, Ordering::Release);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "draining-write-1".to_string(),
token: token.to_string(),
method: "runtime.continue_action".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(directory.0.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
run_id: Some("run-draining".to_string()),
action_id: Some("action-draining".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(!response.ok);
assert_eq!(
response.error.as_ref().map(|error| error.code.as_str()),
Some("runner-draining")
);
}
#[test]
fn durable_pending_action_prevents_shutdown_and_reopens_writes() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-1.json");
fs::create_dir_all(pending.parent().expect("pending parent"))
.expect("create pending directory");
fs::write(&pending, b"{}").expect("write pending action");
let token = "shutdown-private-token-shutdown-private-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "shutdown-boot-id", 32323),
);
state.remember_root(&root);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-pending-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(response.ok);
assert_eq!(
response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(false)
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
}
#[test]
fn forced_shutdown_is_accepted_even_when_runtime_is_busy() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-1.json");
fs::create_dir_all(pending.parent().expect("pending parent"))
.expect("create pending directory");
fs::write(&pending, b"{}").expect("write pending action");
let token = "forced-shutdown-token-forced-shutdown-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "forced-shutdown-boot", 32326),
);
state.remember_root(&root);
state.active_connections.store(8, Ordering::Release);
let response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "forced-shutdown-busy-1".to_string(),
token: token.to_string(),
method: "runner.shutdown".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(response.ok);
assert_eq!(
response
.result
.as_ref()
.and_then(|value| value["willShutdown"].as_bool()),
Some(true)
);
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
}
#[test]
fn shutdown_for_client_exit_rejects_busy_then_closes_idle_runner_idempotently() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json");
fs::create_dir_all(pending.parent().expect("pending parent"))
.expect("create pending directory");
let durable_bytes = br#"{"durable":true}"#;
fs::write(&pending, durable_bytes).expect("write pending action");
let token = "client-exit-private-token-client-exit-private-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "client-exit-boot-id", 32326),
);
state.remember_root(&root);
let unauthorized_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-unauthorized".to_string(),
token: "wrong-client-exit-private-token".to_string(),
method: "runner.shutdown_for_client_exit".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(!unauthorized_response.ok);
assert_eq!(
unauthorized_response
.error
.as_ref()
.map(|error| error.code.as_str()),
Some("unauthorized")
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
assert_eq!(
fs::read(&pending).expect("read pending action after rejected shutdown"),
durable_bytes
);
let idle_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-idle-check".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(idle_response.ok);
assert_eq!(
idle_response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(false)
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
let busy_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-busy-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_for_client_exit".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(busy_response.ok);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["accepted"].as_bool()),
Some(false)
);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["busy"].as_bool()),
Some(true)
);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["willShutdown"].as_bool()),
Some(false)
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
assert_eq!(
fs::read(&pending).expect("read pending action"),
durable_bytes
);
fs::remove_file(&pending).expect("clear pending action before idle client exit");
let shutdown_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-idle-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_for_client_exit".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(shutdown_response.ok);
assert_eq!(
shutdown_response
.result
.as_ref()
.and_then(|value| value["accepted"].as_bool()),
Some(true)
);
assert_eq!(
shutdown_response
.result
.as_ref()
.and_then(|value| value["busy"].as_bool()),
Some(false)
);
assert_eq!(
shutdown_response
.result
.as_ref()
.and_then(|value| value["willShutdown"].as_bool()),
Some(true)
);
assert!(state.shutdown_requested.load(Ordering::Acquire));
assert!(state.draining.load(Ordering::Acquire));
let repeated_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-client-exit-force-2".to_string(),
token: token.to_string(),
method: "runner.shutdown_for_client_exit".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(repeated_response.ok);
assert_eq!(
repeated_response
.result
.as_ref()
.and_then(|value| value["accepted"].as_bool()),
Some(true)
);
assert_eq!(
repeated_response
.result
.as_ref()
.and_then(|value| value["busy"].as_bool()),
Some(false)
);
assert_eq!(
repeated_response
.result
.as_ref()
.and_then(|value| value["willShutdown"].as_bool()),
Some(true)
);
assert!(!pending.exists());
}
#[test]
fn durable_tool_plan_handoff_prevents_shutdown_even_when_corrupt() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let handoff_path = root
.join(".agent/runtime/tool-plan-handoffs")
.join("agent-key")
.join("run-key.json");
fs::create_dir_all(handoff_path.parent().expect("tool-plan handoff parent"))
.expect("create tool-plan handoff directory");
fs::write(&handoff_path, b"{").expect("write corrupt tool-plan handoff");
assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary handoff"));
let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&handoff_path);
fs::rename(&handoff_path, &previous_path).expect("move tool-plan handoff to previous");
assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous handoff"));
fs::remove_file(previous_path).expect("remove tool-plan handoff previous");
assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root"));
}
#[test]
fn durable_provider_retry_prevents_shutdown_and_reopens_writes() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let identity = crate::provider_retry::AgentRuntimeProviderRetryIdentity {
project_id: "project-provider-retry-idle".to_string(),
agent_id: "code-prototype".to_string(),
task_id: "task-provider-retry-idle".to_string(),
session_id: "session-provider-retry-idle".to_string(),
run_id: "run-provider-retry-idle".to_string(),
source: "agent-chat".to_string(),
goal_id: None,
goal_revision: 0,
goal_snapshot_fingerprint: String::new(),
applied_steer_cursor: 0,
request_kind: "tool-plan".to_string(),
base_request_slot: "loop-0-repair-0".to_string(),
request_fingerprint: "a".repeat(64),
provider_config_fingerprint: "b".repeat(64),
web_search_enabled: false,
allow_idle_context_compaction: false,
};
let retry = crate::provider_retry::write_next_at(
&root,
&identity,
"loop-0-repair-0-transient-1",
1,
3,
250,
"timeout",
&"c".repeat(64),
)
.expect("write durable Provider retry");
assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary retry"));
let retry_path =
root.join(".agent/runtime/provider-retries/code-prototype/run-provider-retry-idle.json");
let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&retry_path);
fs::rename(&retry_path, &previous_path).expect("move Provider retry to previous");
assert_eq!(
crate::provider_retry::list_at(&root).expect("scan previous Provider retry"),
vec![retry]
);
assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous retry"));
let token = "provider-retry-shutdown-token-provider-retry-shutdown-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "provider-retry-shutdown-boot", 32324),
);
state.remember_root(&root);
let busy_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-provider-retry-busy-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(busy_response.ok);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(false)
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
crate::provider_retry::remove_at(&root, &identity.agent_id, &identity.run_id)
.expect("remove durable Provider retry");
assert!(crate::provider_retry::list_at(&root)
.expect("scan removed Provider retries")
.is_empty());
assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root"));
let idle_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-provider-retry-idle-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(idle_response.ok);
assert_eq!(
idle_response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(true)
);
assert!(state.shutdown_requested.load(Ordering::Acquire));
assert!(state.draining.load(Ordering::Acquire));
}
#[test]
fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
let directory = unique_test_directory();
let root = directory.0.join("project");
let identity = crate::provider_retry::AgentRuntimeProviderRetryIdentity {
project_id: "project-provider-handoff-idle".to_string(),
agent_id: "code-prototype".to_string(),
task_id: "provider-handoff-task-idle".to_string(),
session_id: "session-provider-handoff-idle".to_string(),
run_id: "run-provider-handoff-idle".to_string(),
source: "agent-chat".to_string(),
goal_id: None,
goal_revision: 0,
goal_snapshot_fingerprint: String::new(),
applied_steer_cursor: 0,
request_kind: "final-reply".to_string(),
base_request_slot: "final-reply-loop-1-revision-0".to_string(),
request_fingerprint: "d".repeat(64),
provider_config_fingerprint: "e".repeat(64),
web_search_enabled: false,
allow_idle_context_compaction: false,
};
let response = platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "provider-handoff-runner-test".to_string(),
text: "durable final reply".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("provider-handoff-response".to_string()),
usage: None,
tool_calls: Vec::new(),
responses_output: Vec::new(),
};
let provider_request_id = format!("provider-request-{}", "f".repeat(64));
crate::provider_handoff::write_at(
&root,
&identity,
&identity.base_request_slot,
0,
&provider_request_id,
&response,
)
.expect("write durable Provider handoff");
assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary handoff"));
let agent_key = format!("{:x}", Sha256::digest(identity.agent_id.as_bytes()));
let run_key = format!("{:x}", Sha256::digest(identity.run_id.as_bytes()));
let handoff_path = root
.join(".agent/runtime/provider-handoffs")
.join(agent_key)
.join(format!("{run_key}.json"));
let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&handoff_path);
fs::rename(&handoff_path, &previous_path).expect("move Provider handoff to previous");
assert_eq!(
crate::provider_handoff::read_for_run_at(&root, &identity.agent_id, &identity.run_id,)
.expect("recover previous Provider handoff")
.map(|record| record.to_llm_response()),
Some(response)
);
assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous handoff"));
fs::write(&previous_path, b"{").expect("corrupt Provider handoff");
crate::provider_handoff::read_for_run_at(&root, &identity.agent_id, &identity.run_id)
.expect_err("corrupt Provider handoff must enter recovery error handling");
assert!(!external_agent_runner_root_is_idle(&root).expect("scan corrupt handoff"));
let token = "provider-handoff-shutdown-token-provider-handoff-shutdown-token";
let state = ExternalAgentRunnerServerState::new(
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "provider-handoff-shutdown-boot", 32325),
);
state.remember_root(&root);
let busy_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-provider-handoff-busy-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(busy_response.ok);
assert_eq!(
busy_response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(false)
);
assert!(!state.shutdown_requested.load(Ordering::Acquire));
assert!(!state.draining.load(Ordering::Acquire));
crate::provider_handoff::remove_at(&root, &identity.agent_id, &identity.run_id)
.expect("remove corrupt Provider handoff");
assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root"));
let idle_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "shutdown-provider-handoff-idle-1".to_string(),
token: token.to_string(),
method: "runner.shutdown_if_idle".to_string(),
params: ExternalAgentRunnerRequestParams::default(),
},
&state,
);
assert!(idle_response.ok);
assert_eq!(
idle_response
.result
.as_ref()
.and_then(|value| value["idle"].as_bool()),
Some(true)
);
assert!(state.shutdown_requested.load(Ordering::Acquire));
assert!(state.draining.load(Ordering::Acquire));
}
#[test]
fn stale_protocol_endpoint_does_not_override_instance_lock_arbitration() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
let endpoint_path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME);
let mut stale = test_endpoint(
"stale-private-token-stale-private-token",
"stale-boot-id",
33333,
);
stale.protocol_version = EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION + 1;
write_external_agent_runner_endpoint_atomic(&endpoint_path, &stale)
.expect("write stale endpoint");
assert!(read_current_external_agent_runner_endpoint(
&endpoint_path,
stale
.executable_fingerprint
.as_deref()
.expect("test fingerprint"),
)
.is_none());
let boot_id = "current-lock-owner";
let lock = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&config_dir),
boot_id,
)
.expect("stale endpoint must not block the authoritative instance lock");
drop(lock);
}
#[test]
fn active_runner_lock_is_not_repaired_or_truncated() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
let lock_path = external_agent_runner_lock_path(&config_dir);
let first = acquire_external_agent_runner_instance_lock(&lock_path, "first-active-boot")
.expect("acquire first runner lock");
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "second-boot") {
Ok(_) => panic!("active runner lock must reject a second owner"),
Err(error) => error,
};
assert!(error.contains("其他进程运行"));
drop(first);
let diagnostic: Value = serde_json::from_slice(
&fs::read(&lock_path).expect("read runner lock after rejected acquisition"),
)
.expect("parse runner lock after rejected acquisition");
assert_eq!(diagnostic["bootId"], "first-active-boot");
}
#[cfg(windows)]
#[test]
fn windows_runner_lock_rejects_unrecognized_fixed_name() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
let unexpected_path = config_dir.join("unexpected-runner.lock");
let error = try_open_external_agent_runner_lock(&unexpected_path, "unexpected Runner lock")
.expect_err("only the instance and GUI owner lock names are allowed");
assert!(error.contains("固定锁路径"));
assert!(!unexpected_path.exists());
}
#[cfg(windows)]
#[test]
fn windows_stale_runner_lock_is_reowned_for_token_user() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
let lock_path = external_agent_runner_lock_path(&config_dir);
fs::write(&lock_path, b"stale-lock-from-token-default-owner")
.expect("create stale runner lock");
if !crate::tests::configuration::set_windows_test_path_owner_to_distinct_token_owner(&lock_path)
{
eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID");
return;
}
assert!(
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false).is_err(),
"fixture lock must start with a foreign owner"
);
let lock = acquire_external_agent_runner_instance_lock(&lock_path, "reowned-boot")
.expect("repair and acquire stale runner lock");
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)
.expect("runner lock owner must match TokenUser SID");
drop(lock);
let diagnostic: Value = serde_json::from_slice(
&fs::read(&lock_path).expect("read repaired runner lock diagnostic"),
)
.expect("parse repaired runner lock diagnostic");
assert_eq!(diagnostic["bootId"], "reowned-boot");
}
#[cfg(windows)]
#[test]
fn windows_runner_lock_rejects_hard_link_without_touching_target() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
let target = config_dir.join("lock-target.txt");
let lock_path = external_agent_runner_lock_path(&config_dir);
fs::write(&target, b"do-not-truncate").expect("write lock target");
fs::hard_link(&target, &lock_path).expect("create runner lock hard link");
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "hard-link-boot") {
Ok(_) => panic!("runner lock hard link must be rejected"),
Err(error) => error,
};
assert!(error.contains("硬链接"));
assert_eq!(
fs::read(&target).expect("read untouched lock target"),
b"do-not-truncate"
);
}
#[cfg(windows)]
#[test]
fn windows_runner_lock_rejects_symlink_without_touching_target_when_supported() {
use std::os::windows::fs::symlink_file;
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
let target = config_dir.join("lock-symlink-target.txt");
let lock_path = external_agent_runner_lock_path(&config_dir);
fs::write(&target, b"do-not-truncate").expect("write lock symlink target");
if symlink_file(&target, &lock_path).is_err() {
eprintln!("skip: 当前 Windows 环境不允许创建文件符号链接");
return;
}
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "symlink-boot") {
Ok(_) => panic!("runner lock symlink must be rejected"),
Err(error) => error,
};
assert!(error.contains("reparse point") || error.contains("普通文件"));
assert_eq!(
fs::read(&target).expect("read untouched lock symlink target"),
b"do-not-truncate"
);
}
#[cfg(windows)]
#[test]
fn windows_runner_lock_busy_error_classification_is_exact() {
assert!(windows_external_agent_runner_lock_is_busy_error(
&io::Error::from_raw_os_error(32)
));
assert!(windows_external_agent_runner_lock_is_busy_error(
&io::Error::from_raw_os_error(33)
));
assert!(!windows_external_agent_runner_lock_is_busy_error(
&io::Error::from_raw_os_error(5)
));
}
#[cfg(unix)]
#[test]
fn runner_lock_rejects_symlink_without_touching_target() {
use std::os::unix::fs::symlink;
let directory = unique_test_directory();
let target = directory.0.join("lock-target.txt");
let lock_path = directory.0.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME);
fs::write(&target, b"do-not-truncate").expect("write lock target");
symlink(&target, &lock_path).expect("create runner lock symlink");
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "symlink-boot") {
Ok(_) => panic!("runner lock symlink must be rejected"),
Err(error) => error,
};
assert!(error.contains("锁"));
assert_eq!(
fs::read(&target).expect("read untouched lock target"),
b"do-not-truncate"
);
}
#[cfg(unix)]
#[test]
fn runner_lock_rejects_hard_link_without_touching_target() {
let directory = unique_test_directory();
let target = directory.0.join("hard-link-target.txt");
let lock_path = directory.0.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME);
fs::write(&target, b"do-not-truncate").expect("write lock target");
fs::hard_link(&target, &lock_path).expect("create runner lock hard link");
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "hard-link-boot") {
Ok(_) => panic!("runner lock hard link must be rejected"),
Err(error) => error,
};
assert!(error.contains("硬链接"));
assert_eq!(
fs::read(&target).expect("read untouched lock target"),
b"do-not-truncate"
);
}
#[test]
fn project_execution_owner_is_unique_across_appdata_and_records_recovery() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(&root, "project-owner-test", "Runner owner 测试")
.expect("initialize owner project");
let config_a = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("app-a"))
.expect("prepare appdata a");
let config_b = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("app-b"))
.expect("prepare appdata b");
let state_a = ExternalAgentRunnerServerState::new(
config_a.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"owner-private-token-a-owner-private-token-a",
"owner-boot-a",
41001,
),
);
let state_b = ExternalAgentRunnerServerState::new(
config_b.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"owner-private-token-b-owner-private-token-b",
"owner-boot-b",
41002,
),
);
let first_claim = state_a
.claim_project_execution_owner(&root)
.expect("first appdata owns project");
assert!(first_claim.recovered_from_boot_id.is_none());
let conflict = state_b
.claim_project_execution_owner(&root)
.expect_err("second appdata must not own the same project");
assert!(conflict.contains("execution-owner"));
drop(state_a);
let mut recovered = false;
for attempt in 0..100 {
match state_b.claim_project_execution_owner(&root) {
Ok(claim) => {
assert_eq!(
claim.recovered_from_boot_id.as_deref(),
Some("owner-boot-a")
);
recovered = true;
break;
}
Err(error) if error.contains("另一个 Agent Runner") && attempt < 99 => {
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("released OS lock recovery failed: {error}"),
}
}
assert!(recovered, "released OS lock was not reacquired");
let record = serde_json::from_slice::<ExternalAgentRunnerProjectExecutionOwnerRecord>(
&fs::read(root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH))
.expect("read project owner record"),
)
.expect("parse project owner record");
assert_eq!(
record.protocol_version,
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION
);
assert_eq!(record.boot_id, "owner-boot-b");
assert_eq!(
record.recovered_from_boot_id.as_deref(),
Some("owner-boot-a")
);
#[cfg(windows)]
crate::secure_windows_game_creator_path_for_current_user(
&root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH),
false,
false,
)
.expect("project owner diagnostic must match TokenUser SID");
}
#[test]
fn project_execution_owner_cross_boot_claim_recovers_durable_runtime_once() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-owner-auto-recovery",
"Runner owner 自动恢复测试",
)
.expect("initialize owner auto recovery project");
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare owner auto recovery appdata");
let previous = acquire_external_agent_runner_project_execution_owner(
&root,
"owner-auto-recovery-boot-a",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
)
.expect("acquire previous owner");
drop(previous);
let state = ExternalAgentRunnerServerState::new(
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"owner-auto-recovery-token-owner-auto-recovery-token",
"owner-auto-recovery-boot-b",
41003,
),
);
let task = AgentRuntimeTaskRecord {
goal_id: None,
goal_revision: 0,
goal_status: None,
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: "design-director".to_string(),
task_id: "design-director".to_string(),
session_id: "agent-session-design-director".to_string(),
run_id: "owner-auto-recovery-run".to_string(),
source: "agent-background-task".to_string(),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
parent_agent_id: None,
parent_run_id: None,
delegation_id: None,
task: "跨 boot 恢复 durable task".to_string(),
status: "running".to_string(),
phase: "planning".to_string(),
current_action: "上一 boot 正在规划".to_string(),
terminal_detail: None,
error: None,
updated_at: unix_timestamp().saturating_sub(60),
};
let task_path = root.join(".agent/runtime/tasks/design-director.jsonl");
fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir");
fs::write(
&task_path,
format!(
"{}\n",
serde_json::to_string(&task).expect("serialize durable task")
),
)
.expect("write durable task");
let mut runtime = crate::agent_runtime_state_from_task_record(&task);
crate::write_game_creator_agent_runtime_state(&root, &runtime)
.expect("write stale runtime state");
let project_id = crate::game_creator_agent_runtime_context_project_id(&root)
.expect("read runtime project id");
let process_id = "proc-0123456789abcdef0123456789abcdef";
let now = unix_timestamp();
let process_record = ProcessSessionRecord {
schema_version: "3".to_string(),
project_id,
agent_id: task.agent_id.clone(),
task_id: task.task_id.clone(),
conversation_session_id: task.session_id.clone(),
run_id: task.run_id.clone(),
start_action_id: "action-0123456789abcdef01234567".to_string(),
start_action_fingerprint: "a".repeat(64),
process_id: process_id.to_string(),
owner_boot_id: "owner-auto-recovery-boot-a".to_string(),
command_id: "command-owner-auto-recovery".to_string(),
program: "node".to_string(),
cwd: ".".to_string(),
sandbox_backend: "test-unknown".to_string(),
sandbox_mode: "unknown".to_string(),
network_access: "unknown".to_string(),
sandbox_profile_version: "test-v1".to_string(),
sandbox_establishment: "established".to_string(),
target_exec: "established".to_string(),
launch_failure_kind: None,
sandbox_ready_at: Some(now),
exec_established_at: Some(now),
status: "running".to_string(),
exit_code: None,
signal: None,
stdin_open: true,
output_bytes: 0,
output_sha256: format!("{:x}", Sha256::digest([])),
output_ref: None,
source_fingerprint_before: "b".repeat(64),
source_fingerprint_after: None,
source_changed: None,
needs_reconciliation: false,
started_at: now,
terminal_at: None,
updated_at: now,
};
let process_path = root.join(format!(".agent/runtime/process-sessions/{process_id}.json"));
fs::create_dir_all(process_path.parent().expect("process session parent"))
.expect("process session directory");
fs::write(
&process_path,
serde_json::to_vec_pretty(&process_record).expect("serialize process session"),
)
.expect("write previous boot process session");
let first = state
.claim_project_execution_owner(&root)
.expect("cross boot claim automatically scans recovery");
assert_eq!(
first.recovered_from_boot_id.as_deref(),
Some("owner-auto-recovery-boot-a")
);
runtime = crate::read_game_creator_agent_runtime_at(&root, "design-director")
.expect("read reconciled runtime")
.state;
assert_eq!(runtime.run_id, "owner-auto-recovery-run");
assert_eq!(runtime.phase, "needs-reconciliation");
assert!(runtime
.error
.as_deref()
.is_some_and(|error| error.contains("禁止按 PID 重连或重放启动动作")));
let process = serde_json::from_slice::<ProcessSessionRecord>(
&fs::read(&process_path).expect("read reconciled process session"),
)
.expect("parse reconciled process session");
assert_eq!(process.status, "needs-reconciliation");
assert!(process.needs_reconciliation);
let records = fs::read_to_string(root.join(".agent/agent.db")).expect("read recovery audit");
assert_eq!(
records
.matches("agent.runtime.process_session.reconciled_after_runner_restart")
.count(),
1
);
assert!(!records.contains("agent.runtime.tool_action.executing"));
assert!(!records.contains("agent.runtime.agent.delegate"));
let task_records_before = fs::read_to_string(&task_path).expect("read reconciled task journal");
let task_record_count_before = task_records_before
.lines()
.filter(|line| !line.trim().is_empty())
.count();
let conversation_directory = root.join(".agent/conversations");
let conversation_snapshot_before = if conversation_directory.exists() {
let mut files = fs::read_dir(&conversation_directory)
.expect("read conversation directory")
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file())
.map(|path| {
(
path.clone(),
fs::read(path).expect("read conversation file"),
)
})
.collect::<Vec<_>>();
files.sort_by(|left, right| left.0.cmp(&right.0));
files
} else {
Vec::new()
};
let second = state
.claim_project_execution_owner(&root)
.expect("same boot claim is idempotent");
assert!(second.recovered_from_boot_id.is_none());
let records_after =
fs::read_to_string(root.join(".agent/agent.db")).expect("read idempotent recovery audit");
assert_eq!(records_after, records);
let task_records_after =
fs::read_to_string(&task_path).expect("reread reconciled task journal");
assert_eq!(
task_records_after
.lines()
.filter(|line| !line.trim().is_empty())
.count(),
task_record_count_before,
"same-boot reentry must not duplicate durable task/child projection"
);
let conversation_snapshot_after = if conversation_directory.exists() {
let mut files = fs::read_dir(&conversation_directory)
.expect("reread conversation directory")
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file())
.map(|path| {
(
path.clone(),
fs::read(path).expect("reread conversation file"),
)
})
.collect::<Vec<_>>();
files.sort_by(|left, right| left.0.cmp(&right.0));
files
} else {
Vec::new()
};
assert_eq!(conversation_snapshot_after, conversation_snapshot_before);
}
#[test]
fn project_execution_owner_recovery_runs_after_owner_map_lock_release() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-owner-lock-order",
"Runner owner 恢复锁顺序测试",
)
.expect("initialize owner lock order project");
let previous = acquire_external_agent_runner_project_execution_owner(
&root,
"owner-lock-order-boot-a",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
)
.expect("acquire previous owner");
drop(previous);
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare owner lock order appdata");
let state = ExternalAgentRunnerServerState::new(
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"owner-lock-order-token-owner-lock-order-token",
"owner-lock-order-boot-b",
41004,
),
);
let claim = state
.claim_project_execution_owner_with_recovery(&root, |claimed_root| {
let owners = state
.project_execution_owners
.try_lock()
.expect("recovery callback must run after owner-map lock release");
assert!(owners.contains_key(claimed_root));
Ok(())
})
.expect("claim with lock-order probe");
assert_eq!(
claim.recovered_from_boot_id.as_deref(),
Some("owner-lock-order-boot-a")
);
}
#[test]
fn project_execution_owner_recovery_failure_retries_in_same_boot() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-owner-recovery-retry",
"Runner owner 恢复重试测试",
)
.expect("initialize owner recovery retry project");
let previous = acquire_external_agent_runner_project_execution_owner(
&root,
"owner-recovery-retry-boot-a",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
)
.expect("acquire previous owner");
drop(previous);
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare owner recovery retry appdata");
let state = ExternalAgentRunnerServerState::new(
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"owner-recovery-retry-token-owner-retry-token",
"owner-recovery-retry-boot-b",
41005,
),
);
let recoveries = AtomicUsize::new(0);
let first = state
.claim_project_execution_owner_with_recovery(&root, |_| {
recoveries.fetch_add(1, Ordering::SeqCst);
Err("injected recovery failure".to_string())
})
.expect_err("first recovery attempt must surface its failure");
assert!(first.contains("injected recovery failure"));
assert_eq!(recoveries.load(Ordering::SeqCst), 1);
let second = state
.claim_project_execution_owner_with_recovery(&root, |_| {
recoveries.fetch_add(1, Ordering::SeqCst);
Ok(())
})
.expect("same boot must retry failed recovery");
assert_eq!(
second.recovered_from_boot_id.as_deref(),
Some("owner-recovery-retry-boot-a")
);
assert_eq!(recoveries.load(Ordering::SeqCst), 2);
let third = state
.claim_project_execution_owner_with_recovery(&root, |_| {
recoveries.fetch_add(1, Ordering::SeqCst);
Ok(())
})
.expect("completed recovery remains idempotent");
assert!(third.recovered_from_boot_id.is_none());
assert_eq!(recoveries.load(Ordering::SeqCst), 2);
}
#[test]
fn project_execution_owner_concurrent_claims_single_flight_recovery() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-owner-recovery-single-flight",
"Runner owner 单飞恢复测试",
)
.expect("initialize owner recovery single-flight project");
let previous = acquire_external_agent_runner_project_execution_owner(
&root,
"owner-recovery-single-flight-boot-a",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
)
.expect("acquire previous owner");
drop(previous);
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare owner recovery single-flight appdata");
let state = Arc::new(ExternalAgentRunnerServerState::new(
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"owner-recovery-single-flight-token-single-flight",
"owner-recovery-single-flight-boot-b",
41006,
),
));
let recoveries = Arc::new(AtomicUsize::new(0));
let recovery_entered = Arc::new(Barrier::new(2));
let (release_recovery_tx, release_recovery_rx) = mpsc::channel::<()>();
let first_state = Arc::clone(&state);
let first_root = root.clone();
let first_recoveries = Arc::clone(&recoveries);
let first_recovery_entered = Arc::clone(&recovery_entered);
let first = std::thread::spawn(move || {
first_state.claim_project_execution_owner_with_recovery(&first_root, |_| {
first_recoveries.fetch_add(1, Ordering::SeqCst);
first_recovery_entered.wait();
release_recovery_rx
.recv_timeout(Duration::from_secs(5))
.expect("test must release the active recovery");
Ok(())
})
});
recovery_entered.wait();
let second_state = Arc::clone(&state);
let second_root = root.clone();
let second_recoveries = Arc::clone(&recoveries);
let (second_started_tx, second_started_rx) = mpsc::channel();
let second = std::thread::spawn(move || {
second_started_tx.send(()).expect("signal second claim");
second_state.claim_project_execution_owner_with_recovery(&second_root, |_| {
second_recoveries.fetch_add(1, Ordering::SeqCst);
Ok(())
})
});
second_started_rx
.recv_timeout(Duration::from_secs(5))
.expect("second claim must start");
std::thread::sleep(Duration::from_millis(50));
assert_eq!(
recoveries.load(Ordering::SeqCst),
1,
"concurrent claim must wait instead of duplicating recovery"
);
release_recovery_tx
.send(())
.expect("release active recovery");
let first_claim = first
.join()
.expect("first claim thread")
.expect("first claim");
let second_claim = second
.join()
.expect("second claim thread")
.expect("second claim");
assert_eq!(
first_claim.recovered_from_boot_id.as_deref(),
Some("owner-recovery-single-flight-boot-a")
);
assert!(second_claim.recovered_from_boot_id.is_none());
assert_eq!(recoveries.load(Ordering::SeqCst), 1);
}
#[test]
fn project_execution_owner_recovery_panic_resets_running_and_wakes_waiter() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-owner-recovery-panic",
"Runner owner 恢复 panic 测试",
)
.expect("initialize owner recovery panic project");
let previous = acquire_external_agent_runner_project_execution_owner(
&root,
"owner-recovery-panic-boot-a",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
)
.expect("acquire previous owner");
drop(previous);
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare owner recovery panic appdata");
let state = Arc::new(ExternalAgentRunnerServerState::new(
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(
"owner-recovery-panic-token-owner-panic-token",
"owner-recovery-panic-boot-b",
41007,
),
));
let recoveries = Arc::new(AtomicUsize::new(0));
let (first_entered_tx, first_entered_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let first_state = Arc::clone(&state);
let first_root = root.clone();
let first_recoveries = Arc::clone(&recoveries);
let first = std::thread::spawn(move || {
first_state.claim_project_execution_owner_with_recovery(&first_root, |_| {
first_recoveries.fetch_add(1, Ordering::SeqCst);
first_entered_tx.send(()).expect("signal panic recovery");
release_first_rx
.recv_timeout(Duration::from_secs(5))
.expect("release panic recovery");
panic!("injected recovery callback panic");
})
});
first_entered_rx
.recv_timeout(Duration::from_secs(5))
.expect("first recovery must enter");
let second_state = Arc::clone(&state);
let second_root = root.clone();
let second_recoveries = Arc::clone(&recoveries);
let (second_started_tx, second_started_rx) = mpsc::channel();
let second = std::thread::spawn(move || {
second_started_tx.send(()).expect("signal waiting claim");
second_state.claim_project_execution_owner_with_recovery(&second_root, |_| {
second_recoveries.fetch_add(1, Ordering::SeqCst);
Ok(())
})
});
second_started_rx
.recv_timeout(Duration::from_secs(5))
.expect("waiting claim must start");
std::thread::sleep(Duration::from_millis(50));
assert_eq!(
recoveries.load(Ordering::SeqCst),
1,
"waiter must not run while the first recovery is Running"
);
release_first_tx
.send(())
.expect("release the panicking recovery");
let first_error = first
.join()
.expect("panic must be contained inside owner recovery")
.expect_err("panicking recovery must fail its own claim");
assert!(first_error.contains("恢复回调异常终止"));
let second_claim = second
.join()
.expect("waiting claim thread must be woken")
.expect("waiting claim must retry recovery");
assert_eq!(
second_claim.recovered_from_boot_id.as_deref(),
Some("owner-recovery-panic-boot-a")
);
assert_eq!(recoveries.load(Ordering::SeqCst), 2);
}
#[test]
fn project_execution_owner_recovery_runs_without_request_cache_and_failure_is_retryable() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(
&root,
"project-owner-request-cache",
"Runner owner 请求缓存锁测试",
)
.expect("initialize owner request cache project");
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare owner request cache appdata");
let token = "owner-request-cache-token-owner-request-cache-token";
let state = ExternalAgentRunnerServerState::new(
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
test_endpoint(token, "owner-request-cache-boot", 41008),
);
let request = ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "owner-request-cache-request".to_string(),
token: token.to_string(),
method: "runtime.resume".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(root.to_string_lossy().into_owned()),
..ExternalAgentRunnerRequestParams::default()
},
};
let attempts = AtomicUsize::new(0);
let first =
dispatch_external_agent_runner_runtime_request_with_owner_claim(&request, &state, |_| {
attempts.fetch_add(1, Ordering::SeqCst);
let cache = state
.write_request_cache
.try_lock()
.expect("owner recovery must not run under the global request-cache mutex");
assert!(cache.find(&request.request_id).is_none());
Err("injected owner recovery failure".to_string())
});
assert!(!first.ok);
assert_eq!(
first.error.as_ref().map(|error| error.code.as_str()),
Some("project-execution-owned")
);
assert!(lock_unpoisoned(&state.write_request_cache)
.find(&request.request_id)
.is_none());
let second = dispatch_external_agent_runner_runtime_request_with_owner_claim(
&request,
&state,
|requested_root| {
attempts.fetch_add(1, Ordering::SeqCst);
Ok(ExternalAgentRunnerProjectExecutionOwnerClaim {
root: canonicalize_external_agent_runner_project_root(requested_root)?,
recovered_from_boot_id: Some("previous-boot".to_string()),
})
},
);
assert!(second.ok, "same requestId retry failed: {:?}", second.error);
assert_eq!(attempts.load(Ordering::SeqCst), 2);
let replay = dispatch_external_agent_runner_runtime_request_with_owner_claim(
&request,
&state,
|_| -> Result<ExternalAgentRunnerProjectExecutionOwnerClaim, String> {
panic!("cached replay must not claim the owner again")
},
);
assert_eq!(replay, second);
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[test]
fn corrupt_legacy_owner_diagnostic_does_not_block_lock_recovery() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(&root, "project-owner-recovery", "Runner owner 恢复测试")
.expect("initialize owner recovery project");
let owner_path = root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_PATH);
let first = acquire_external_agent_runner_project_execution_owner(
&root,
"owner-recovery-boot-a",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
)
.expect("acquire first owner");
drop(first);
fs::remove_file(root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH))
.expect("remove new diagnostic to exercise legacy recovery");
fs::write(&owner_path, br#"{"protocolVersion":1,"bootId":"partial"#)
.expect("write partial legacy owner diagnostic");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&owner_path, fs::Permissions::from_mode(0o600))
.expect("keep legacy owner lock private");
}
let recovered = acquire_project_owner_after_release(&root, "owner-recovery-boot-b");
let conflict = match acquire_external_agent_runner_project_execution_owner(
&root,
"owner-recovery-boot-c",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
) {
Ok(_) => panic!("diagnostic recovery must not permit split-brain"),
Err(error) => error,
};
assert!(conflict.contains("execution-owner"));
drop(recovered);
}
#[test]
fn partial_owner_diagnostic_is_atomically_recovered_after_os_lock() {
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(&root, "project-owner-diagnostic", "Runner 诊断恢复测试")
.expect("initialize owner diagnostic project");
let diagnostic_path = root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH);
let first = acquire_external_agent_runner_project_execution_owner(
&root,
"owner-diagnostic-boot-a",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
)
.expect("acquire first diagnostic owner");
drop(first);
fs::write(
&diagnostic_path,
br#"{"protocolVersion":1,"bootId":"partial"#,
)
.expect("write partial owner diagnostic");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&diagnostic_path, fs::Permissions::from_mode(0o600))
.expect("keep partial diagnostic private");
}
let recovered = acquire_project_owner_after_release(&root, "owner-diagnostic-boot-b");
let record = serde_json::from_slice::<ExternalAgentRunnerProjectExecutionOwnerRecord>(
&fs::read(&diagnostic_path).expect("read recovered diagnostic"),
)
.expect("parse recovered diagnostic");
assert_eq!(record.boot_id, "owner-diagnostic-boot-b");
let conflict = match acquire_external_agent_runner_project_execution_owner(
&root,
"owner-diagnostic-boot-c",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
) {
Ok(_) => panic!("diagnostic repair must not permit split-brain"),
Err(error) => error,
};
assert!(conflict.contains("execution-owner"));
drop(recovered);
}
#[cfg(unix)]
#[test]
fn project_owner_relative_open_does_not_follow_parent_replacement_race() {
use std::os::unix::fs::symlink;
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(&root, "project-owner-race", "Runner owner 竞态测试")
.expect("initialize owner race project");
let root_directory = open_unix_project_owner_root(&root).expect("open project root handle");
let agent_directory =
open_unix_project_owner_directory_at(&root_directory, ".agent", "项目 .agent 目录", false)
.expect("open project agent handle");
let original_agent = root.join(".agent-original");
fs::rename(root.join(".agent"), &original_agent).expect("move original agent directory");
let outside_agent = directory.0.join("outside-agent");
let outside_runtime = outside_agent.join("runtime");
fs::create_dir_all(&outside_runtime).expect("create outside agent runtime");
let outside_lock = outside_runtime.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME);
fs::write(&outside_lock, b"outside-sentinel").expect("write outside lock sentinel");
symlink(&outside_agent, root.join(".agent")).expect("replace agent path with symlink");
let original_runtime = open_unix_project_owner_directory_at(
&agent_directory,
"runtime",
"项目 Runtime owner 目录",
false,
)
.expect("relative open must stay on the original agent directory");
let lock_path = original_agent.join("runtime/execution-owner.lock");
let lock = try_open_unix_project_owner_lock_at(&original_runtime, &lock_path)
.expect("open owner lock relative to original runtime")
.expect("lock original runtime");
assert_eq!(
fs::read(&outside_lock).expect("read untouched outside lock"),
b"outside-sentinel"
);
assert!(lock_path.is_file());
assert!(verify_unix_project_owner_entry(
&root_directory,
".agent",
&agent_directory,
true,
"项目 .agent 目录",
)
.is_err());
drop(lock);
}
#[cfg(unix)]
#[test]
fn project_execution_owner_rejects_symlinked_runtime_parent_without_touching_target() {
use std::os::unix::fs::symlink;
let directory = unique_test_directory();
let root = directory.0.join("project");
crate::init_local_game_project_at(&root, "project-owner-parent", "Runner owner 父目录测试")
.expect("initialize owner parent project");
let runtime_dir = root.join(".agent/runtime");
fs::remove_dir_all(&runtime_dir).expect("remove real runtime directory");
let outside_runtime = directory.0.join("outside-runtime");
fs::create_dir(&outside_runtime).expect("create outside runtime directory");
let outside_lock = outside_runtime.join("execution-owner.lock");
fs::write(&outside_lock, b"outside-sentinel").expect("write outside sentinel");
symlink(&outside_runtime, &runtime_dir).expect("link runtime to outside directory");
let error = match acquire_external_agent_runner_project_execution_owner(
&root,
"owner-parent-boot",
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
) {
Ok(_) => panic!("symlinked Runtime owner parent must be rejected"),
Err(error) => error,
};
assert!(error.contains("Runtime owner") || error.contains("链接"));
assert_eq!(
fs::read(&outside_lock).expect("read untouched outside sentinel"),
b"outside-sentinel"
);
}
#[test]
fn runner_status_read_does_not_create_or_start_runner() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare status appdata");
let status = read_external_agent_runner_status_at(Some(&config_dir));
assert!(status.enabled);
assert!(!status.running);
assert!(!external_agent_runner_endpoint_path(&config_dir).exists());
assert!(!external_agent_runner_lock_path(&config_dir).exists());
}
#[test]
fn runner_status_read_does_not_create_missing_appdata() {
let directory = unique_test_directory();
let config_dir = directory.0.join("missing-appdata");
let status = read_external_agent_runner_status_at(Some(&config_dir));
assert!(status.enabled);
assert!(!status.running);
assert!(!config_dir.exists());
}
#[test]
fn runner_listener_retries_fallback_ports_only_after_address_in_use() {
let mut attempts = Vec::new();
let bound_port = bind_external_agent_runner_listener_with(
|| vec![61_000, 61_001],
|port| {
attempts.push(port);
if matches!(port, 0 | 61_000) {
Err(io::Error::new(io::ErrorKind::AddrInUse, "occupied"))
} else {
Ok(port)
}
},
)
.expect("fallback listener");
assert_eq!(bound_port, 61_001);
assert_eq!(attempts, vec![0, 61_000, 61_001]);
}
#[test]
fn runner_listener_preserves_non_address_in_use_failure() {
let mut attempts = Vec::new();
let error = bind_external_agent_runner_listener_with(
|| vec![61_000],
|port| {
attempts.push(port);
Err::<(), _>(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
},
)
.expect_err("permission failure must not use fallback ports");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(attempts, vec![0]);
}
#[test]
fn runner_listener_does_not_load_fallback_ports_when_port_zero_succeeds() {
let mut fallback_loaded = false;
let bound_port = bind_external_agent_runner_listener_with(
|| {
fallback_loaded = true;
vec![61_000]
},
Ok,
)
.expect("port zero listener");
assert_eq!(bound_port, 0);
assert!(!fallback_loaded);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_runner_fallback_ports_stay_outside_ephemeral_range() {
assert_eq!(
parse_external_agent_runner_linux_ephemeral_port_range("32768\t60999\n"),
Some((32_768, 60_999))
);
assert_eq!(
parse_external_agent_runner_linux_ephemeral_port_range("60999 32768"),
None
);
assert_eq!(
parse_external_agent_runner_linux_ephemeral_port_range("32768 60999 extra"),
None
);
assert_eq!(
parse_external_agent_runner_linux_single_port("32768\n"),
Some(32_768)
);
assert_eq!(
parse_external_agent_runner_linux_single_port("32768 extra"),
None
);
assert_eq!(
parse_external_agent_runner_linux_reserved_ports("61001-61003, 65535\n"),
Some(vec![(61_001, 61_003), (65_535, 65_535)])
);
assert_eq!(
parse_external_agent_runner_linux_reserved_ports("61003-61001"),
None
);
let ports = external_agent_runner_linux_fallback_ports(
"runner-listener-fallback-test-boot",
(32_768, 60_999),
32_768,
&[(61_001, 61_003), (65_535, 65_535)],
);
assert_eq!(ports.len(), 4_532);
assert!(ports.iter().all(|port| {
*port >= EXTERNAL_AGENT_RUNNER_FALLBACK_PORT_START
&& !(32_768..=60_999).contains(port)
&& !(61_001..=61_003).contains(port)
&& *port != 65_535
}));
assert_eq!(
ports.iter().copied().collect::<BTreeSet<_>>().len(),
ports.len()
);
let hardened_ports = external_agent_runner_linux_fallback_ports(
"runner-listener-hardened-boot",
(32_768, 60_999),
62_000,
&[],
);
assert!(hardened_ports.iter().all(|port| *port >= 62_000));
assert!(external_agent_runner_linux_fallback_ports(
"runner-listener-exhausted-boot",
(32_768, 60_999),
65_535,
&[(65_535, 65_535)],
)
.is_empty());
}
#[cfg(unix)]
#[test]
fn read_only_runner_configuration_does_not_chmod_appdata() {
use std::os::unix::fs::PermissionsExt;
let directory = unique_test_directory();
let config_dir = directory.0.join("broad-appdata");
fs::create_dir(&config_dir).expect("create broad appdata");
fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o755))
.expect("set broad appdata mode");
let error = configure_external_agent_runner_read_only(&config_dir)
.expect_err("read-only configuration must reject broad AppData without tightening it");
assert!(error.contains("0700"));
assert_eq!(
fs::metadata(&config_dir)
.expect("read broad appdata metadata")
.permissions()
.mode()
& 0o777,
0o755
);
assert!(!external_agent_runner_endpoint_path(&config_dir).exists());
assert!(!external_agent_runner_lock_path(&config_dir).exists());
}
#[test]
fn endpoint_write_is_atomic_and_private() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
let path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME);
let first = test_endpoint(
"first-private-token-first-private-token",
"boot-first",
10101,
);
let second = test_endpoint(
"second-private-token-second-private-token",
"boot-second",
20202,
);
write_external_agent_runner_endpoint_atomic(&path, &first).expect("write first endpoint");
write_external_agent_runner_endpoint_atomic(&path, &second)
.expect("replace endpoint atomically");
let persisted = read_external_agent_runner_endpoint(&path).expect("read endpoint");
assert_eq!(persisted.boot_id, "boot-second");
assert_eq!(persisted.port, 20202);
assert_eq!(persisted.token, "second-private-token-second-private-token");
let names = fs::read_dir(&config_dir)
.expect("list endpoint directory")
.map(|entry| {
entry
.expect("endpoint directory entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect::<Vec<_>>();
assert_eq!(names, vec![EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path)
.expect("endpoint metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[cfg(windows)]
crate::secure_windows_game_creator_path_for_current_user(&path, false, false)
.expect("endpoint owner must match TokenUser SID");
}
#[test]
fn runner_child_exit_is_reported_without_waiting_for_start_timeout() {
let directory = unique_test_directory();
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
.expect("prepare private runner AppData");
#[cfg(windows)]
let mut child = std::process::Command::new("cmd.exe")
.args(["/D", "/C", "exit", "/B", "7"])
.spawn()
.expect("spawn immediately failing child");
#[cfg(unix)]
let mut child = std::process::Command::new("/bin/sh")
.args(["-c", "exit 7"])
.spawn()
.expect("spawn immediately failing child");
let started = Instant::now();
let error = match wait_for_external_agent_runner(&config_dir, &mut child, &"a".repeat(64)) {
Ok(_) => panic!("exited child must fail runner startup"),
Err(error) => error,
};
assert!(error.contains("在就绪前退出"));
assert!(
started.elapsed() < Duration::from_secs(2),
"exited child must not wait for the full startup deadline"
);
}
#[test]
fn public_status_never_serializes_endpoint_token() {
let secret = "status-private-token-status-private-token";
let endpoint = test_endpoint(secret, "status-boot-id", 30303);
let status = ExternalAgentRunnerStatus::from_endpoint(&endpoint, true);
let serialized = serde_json::to_string(&status).expect("serialize runner status");
assert!(serialized.contains("status-boot-id"));
assert!(serialized.contains("30303"));
assert!(!serialized.contains(secret));
assert!(!serialized.contains("\"token\""));
}