合并 origin/master 并保留资源画布改动

合并主分支 External Editor 账号绑定与幂等接口实现
保留项目资源预览刷新与运行提示修复
适配素材画布生成器到新的 External Editor 调用契约
合并并保留双方项目决策记录
This commit is contained in:
2026-08-24 12:17:12 +08:00
49 changed files with 13827 additions and 1382 deletions
File diff suppressed because it is too large Load Diff
@@ -19,8 +19,10 @@ pub(crate) use canvas_generation::{
external_generation_result_has_download_reference,
external_generation_submit_rejection_is_definitive,
platform_art_generation_error_needs_reconciliation, prepare_external_canvas_generation_context,
submit_external_generation_request, wait_for_external_generation_result,
ExternalCanvasGenerationContext, ExternalGenerationInitialResponse,
resolve_canvas_resource_download_with_access, submit_external_generation_request,
wait_for_external_generation_result, wait_for_external_generation_result_with_access,
ExternalCanvasGenerationContext,
ExternalGenerationInitialResponse,
};
pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
File diff suppressed because it is too large Load Diff
@@ -261,11 +261,12 @@ pub(crate) use entrypoints::{
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at,
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
generate_local_game_draft_at, install_game_creator_manifest_invalidation_event_sink,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
validate_game_creator_manifest_invalidation_event_sink,
};
#[cfg(test)]
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
@@ -145,6 +145,15 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
) -> Result<(), String> {
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
install_game_creator_manifest_invalidation_event_sink(sink);
Ok(())
}
pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
if port == 0 {
return Err("manifest 失效事件接收端口无效".to_string());
}
@@ -152,12 +161,16 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("manifest 失效事件接收令牌无效".to_string());
}
*lock_game_creator_manifest_invalidation_event_sink() =
Some(GameCreatorManifestInvalidationEventSink {
port,
token: token.to_string(),
});
Ok(())
Ok(GameCreatorManifestInvalidationEventSink {
port,
token: token.to_string(),
})
}
pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
sink: GameCreatorManifestInvalidationEventSink,
) {
*lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
}
#[cfg(test)]
@@ -444,12 +444,10 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at(
return Ok(true);
}
}
let generation_directory = root.join(".agent/runtime/canvas-generation-requests");
if durable_agent_runtime_recovery_directory_has_entries(&generation_directory)
&& !generation_directory_only_contains_retained_direct_taonier_states_at(root)
.unwrap_or(false)
{
return Ok(true);
match collect_agent_owned_platform_art_generation_runtime_identities_at(root) {
Ok(identities) if !identities.is_empty() => return Ok(true),
Err(_) => return Ok(true),
Ok(_) => {}
}
if durable_process_session_recovery_exists_at(root) {
return Ok(true);
@@ -490,87 +488,211 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo
false
}
fn generation_directory_only_contains_retained_direct_taonier_states_at(
root: &Path,
) -> Result<bool, String> {
let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?;
let agent_entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(format!("读取 External Editor 生成账本目录失败:{error}")),
};
let expected_agent_component =
agent_runtime_confirmation_path_component("direct-codex-art", "agent");
let mut found = false;
for agent_entry in agent_entries {
let agent_entry = agent_entry.map_err(|error| format!("遍历生成账本目录失败:{error}"))?;
let metadata = fs::symlink_metadata(agent_entry.path())
.map_err(|error| format!("读取生成账本目录元数据失败:{error}"))?;
if metadata.file_type().is_symlink()
|| !metadata.is_dir()
|| agent_entry.file_name().to_str() != Some(expected_agent_component.as_str())
{
return Ok(false);
}
for entry in fs::read_dir(agent_entry.path())
.map_err(|error| format!("读取 Direct 生成账本目录失败:{error}"))?
{
let entry = entry.map_err(|error| format!("遍历 Direct 生成账本失败:{error}"))?;
let metadata = fs::symlink_metadata(entry.path())
.map_err(|error| format!("读取 Direct 生成账本元数据失败:{error}"))?;
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.len() > 256 * 1024
{
return Ok(false);
}
let payload = fs::read(entry.path())
.map_err(|error| format!("读取 Direct 生成账本失败:{error}"))?;
let payload = serde_json::from_slice::<serde_json::Value>(&payload)
.map_err(|error| format!("解析 Direct 生成账本失败:{error}"))?;
if payload
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
!= Some(PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION)
|| payload.get("agentId").and_then(serde_json::Value::as_str)
!= Some("direct-codex-art")
{
return Ok(false);
}
let Some(run_id) = payload.get("runId").and_then(serde_json::Value::as_str) else {
return Ok(false);
};
let expected_file_name = format!(
"{}.json",
agent_runtime_confirmation_path_component(run_id, "run")
);
let expected_backup_name = format!(".{expected_file_name}.previous");
let Some(file_name) = entry.file_name().to_str().map(str::to_string) else {
return Ok(false);
};
if file_name != expected_file_name && file_name != expected_backup_name {
return Ok(false);
}
if !direct_taonier_regeneration_workflow_retains_stage_ledger_at(
root,
"direct-codex-art",
run_id,
)? {
return Ok(false);
}
found = true;
}
}
Ok(found)
fn lowercase_sha256(value: Option<&str>) -> bool {
value.is_some_and(|value| {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
})
}
fn cleanup_orphaned_platform_art_generation_runtime_states_at(
fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value) -> bool {
const ALLOWED_FIELDS: &[&str] = &[
"schemaVersion",
"projectId",
"agentId",
"taskId",
"sessionId",
"runId",
"source",
"actionId",
"actionFingerprint",
"externalConfigurationFingerprint",
"accessScheme",
"externalServiceOrigin",
"platformOwnerUserId",
"endpoint",
"canvasName",
"generationPrompt",
"requestBodySha256",
"requestBodyJson",
"idempotencyKey",
"status",
"operationId",
"pollAfterMs",
"legacyResult",
"createdAt",
"updatedAt",
];
if !payload.as_object().is_some_and(|object| {
object
.keys()
.all(|field| ALLOWED_FIELDS.contains(&field.as_str()))
}) {
return false;
}
let Some(request_body_json) = payload
.get("requestBodyJson")
.and_then(serde_json::Value::as_str)
else {
return false;
};
let expected_sha256 = format!("{:x}", Sha256::digest(request_body_json.as_bytes()));
if payload
.get("requestBodySha256")
.and_then(serde_json::Value::as_str)
!= Some(expected_sha256.as_str())
{
return false;
}
let Ok(request_body) = serde_json::from_str::<serde_json::Value>(request_body_json) else {
return false;
};
let endpoint = payload.get("endpoint").and_then(serde_json::Value::as_str);
let endpoint_is_valid = matches!(
endpoint,
Some(
"/api/external/v1/editor/images/generations"
| "/api/external/v1/editor/icon-spritesheets/generations"
)
);
endpoint_is_valid
&& payload
.get("canvasName")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& payload
.get("generationPrompt")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& json_string_field(&request_body, "projectId")
.is_some_and(|value| !value.trim().is_empty())
&& json_string_field(&request_body, "assetFolderId")
.is_some_and(|value| !value.trim().is_empty())
}
fn standalone_platform_art_generation_ledger_identity_is_valid(
payload: &serde_json::Value,
project_id: &str,
) -> bool {
let string_field = |name| payload.get(name).and_then(serde_json::Value::as_str);
let principal_is_valid = match string_field("schemaVersion") {
Some(PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION) => {
let Some(service_origin) = string_field("externalServiceOrigin") else {
return false;
};
let origin_is_canonical =
platform_art_generation_external_service_origin(service_origin)
.is_ok_and(|normalized| normalized == service_origin);
let access_is_valid = match string_field("accessScheme") {
Some("official-platform-v1") => string_field("platformOwnerUserId")
.is_some_and(|owner| !owner.trim().is_empty() && owner.len() <= 256),
Some("external-developer-v1") => payload
.get("platformOwnerUserId")
.is_none_or(serde_json::Value::is_null),
_ => false,
};
origin_is_canonical && access_is_valid
}
Some("agent-runtime-canvas-generation-request.v2") => {
!payload.as_object().is_some_and(|object| {
object.contains_key("accessScheme")
|| object.contains_key("externalServiceOrigin")
|| object.contains_key("platformOwnerUserId")
})
}
_ => false,
};
if string_field("projectId") != Some(project_id)
|| !lowercase_sha256(string_field("externalConfigurationFingerprint"))
|| !lowercase_sha256(string_field("requestBodySha256"))
|| !platform_art_generation_request_snapshot_is_valid(payload)
|| !principal_is_valid
{
return false;
}
let Some(agent_id) = string_field("agentId") else {
return false;
};
let Some(run_id) = string_field("runId") else {
return false;
};
let manual_identity_is_valid = if matches!(
agent_id,
"manual-canvas-asset-generate" | "manual-canvas-asset-generate-strict"
) {
let identity = format!("{agent_id}:{run_id}");
let current_identity_is_valid = run_id
.strip_prefix("slot-")
.is_some_and(|value| lowercase_sha256(Some(value)))
&& string_field("taskId") == Some(identity.as_str())
&& string_field("sessionId") == Some(identity.as_str())
&& string_field("source") == Some("tauri-command")
&& string_field("actionId") == Some(identity.as_str())
&& lowercase_sha256(string_field("actionFingerprint"));
let legacy_identity_is_valid = run_id == agent_id
&& string_field("taskId") == Some(agent_id)
&& string_field("sessionId") == Some(agent_id)
&& string_field("source") == Some("tauri-command")
&& string_field("actionId") == Some(agent_id)
&& string_field("actionFingerprint") == Some(agent_id);
current_identity_is_valid || legacy_identity_is_valid
} else {
false
};
let direct_identity_is_valid = if agent_id == "direct-codex-art" {
let expected_asset_kind = match run_id {
"art-spec" => Some("icon-spec"),
"game-background" => Some("game-background"),
"art-spritesheet" => Some("art-spritesheet"),
_ => None,
};
expected_asset_kind.is_some_and(|asset_kind| {
string_field("taskId") == Some(format!("direct-codex-art-{run_id}").as_str())
&& string_field("sessionId") == Some(project_id)
&& string_field("source") == Some("direct-codex")
&& string_field("actionId") == Some(format!("direct-taonier-{run_id}").as_str())
&& string_field("actionFingerprint")
== Some(format!("direct-taonier-art-v1:{asset_kind}:{run_id}").as_str())
})
} else {
false
};
let idempotency_key_is_valid = string_field("idempotencyKey").is_some_and(|value| {
!value.is_empty() && value.len() <= 128 && value.bytes().all(|byte| byte.is_ascii_graphic())
});
let status_is_valid = match string_field("status") {
Some("prepared") => true,
Some("accepted") => string_field("operationId").is_some_and(|value| !value.is_empty()),
Some("legacy-completed") => payload
.get("legacyResult")
.is_some_and(|result| !result.is_null()),
_ => false,
};
(manual_identity_is_valid || direct_identity_is_valid)
&& idempotency_key_is_valid
&& status_is_valid
&& payload
.get("createdAt")
.and_then(serde_json::Value::as_u64)
.is_some_and(|created| {
created > 0
&& payload
.get("updatedAt")
.and_then(serde_json::Value::as_u64)
.is_some_and(|updated| updated >= created)
})
}
fn collect_agent_owned_platform_art_generation_runtime_identities_at(
root: &Path,
) -> Result<usize, String> {
) -> Result<std::collections::BTreeSet<(String, String)>, String> {
let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?;
let project_id = game_creator_agent_runtime_context_project_id(root)?;
let agent_entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
Err(error) => {
return Err(format!(
"读取 External Editor 生成账本目录失败:{}: {error}",
@@ -645,7 +767,11 @@ fn cleanup_orphaned_platform_art_generation_runtime_states_at(
.get("schemaVersion")
.and_then(serde_json::Value::as_str)
.unwrap_or("(missing)");
if schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION {
if !matches!(
schema_version,
PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION
| "agent-runtime-canvas-generation-request.v2"
) {
return Err(format!(
"External Editor 生成账本版本无效:{file_name}: {schema_version}"
));
@@ -672,10 +798,38 @@ fn cleanup_orphaned_platform_art_generation_runtime_states_at(
{
return Err("External Editor 生成账本路径与内部身份不一致".to_string());
}
if standalone_platform_art_generation_ledger_identity_is_valid(&payload, &project_id) {
if agent_id != "direct-codex-art"
|| direct_taonier_regeneration_workflow_retains_stage_ledger_at(
root, agent_id, run_id,
)?
{
continue;
}
// A valid Direct stage identity without the exact v4 workflow owner is still
// recoverable Agent work. Keep it visible so cleanup fails closed rather than
// silently treating a paid generation as a standalone manual request.
identities.insert((agent_id.to_string(), run_id.to_string()));
continue;
}
if matches!(
agent_id,
"manual-canvas-asset-generate"
| "manual-canvas-asset-generate-strict"
| "direct-codex-art"
) {
return Err("standalone External Editor 生成账本身份无效".to_string());
}
identities.insert((agent_id.to_string(), run_id.to_string()));
}
}
Ok(identities)
}
fn cleanup_orphaned_platform_art_generation_runtime_states_at(
root: &Path,
) -> Result<usize, String> {
let identities = collect_agent_owned_platform_art_generation_runtime_identities_at(root)?;
let mut removed = 0_usize;
for (agent_id, run_id) in identities {
if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &run_id) {
@@ -1465,7 +1619,10 @@ mod orphaned_external_generation_recovery_tests {
"source": "direct-codex",
"actionId": "direct-taonier-art-spec",
"actionFingerprint": "direct-taonier-art-v1:icon-spec:art-spec",
"externalConfigurationFingerprint": "test-service",
"externalConfigurationFingerprint": "c".repeat(64),
"accessScheme": "external-developer-v1",
"externalServiceOrigin": "https://editor.example.test",
"platformOwnerUserId": null,
"endpoint": "/api/external/v1/editor/images/generations",
"canvasName": "Direct TaoNier",
"generationPrompt": "Direct art spec",
@@ -1533,6 +1690,108 @@ mod orphaned_external_generation_recovery_tests {
assert!(stage_path.is_file());
}
#[test]
fn standalone_generation_ledgers_do_not_trigger_agent_recovery_or_orphan_cleanup() {
let temporary = crate::tests::canonical_test_tempdir("standalone-generation-scan-");
let root = temporary.path();
init_local_game_project_at(root, "standalone-generation-scan", "手工生成账本扫描测试")
.expect("init standalone scan project");
let project_id = game_creator_agent_runtime_context_project_id(root)
.expect("read standalone scan project id");
let mut persisted_paths = Vec::new();
for (index, agent_id) in [
"manual-canvas-asset-generate",
"manual-canvas-asset-generate-strict",
]
.into_iter()
.enumerate()
{
let run_id = format!("slot-{}", if index == 0 { "a" } else { "b" }.repeat(64));
let identity = format!("{agent_id}:{run_id}");
let request_body = serde_json::json!({
"prompt": "standalone scanner fixture",
"kind": "spec",
"projectId": "standalone-remote-project",
"assetFolderId": "standalone-remote-folder",
"referenceImageSrcs": []
});
let request_body_json =
serde_json::to_string(&request_body).expect("serialize standalone request body");
let relative_path = format!(
".agent/runtime/canvas-generation-requests/{}/{}.json",
agent_runtime_confirmation_path_component(agent_id, "agent"),
agent_runtime_confirmation_path_component(&run_id, "run")
);
let mut ledger = serde_json::json!({
"schemaVersion": PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION,
"projectId": project_id,
"agentId": agent_id,
"taskId": identity,
"sessionId": identity,
"runId": run_id,
"source": "tauri-command",
"actionId": identity,
"actionFingerprint": "c".repeat(64),
"externalConfigurationFingerprint": "d".repeat(64),
"accessScheme": "external-developer-v1",
"externalServiceOrigin": "https://editor.example.test",
"platformOwnerUserId": null,
"endpoint": "/api/external/v1/editor/images/generations",
"canvasName": "standalone scanner canvas",
"generationPrompt": "standalone scanner fixture",
"requestBodySha256": format!("{:x}", Sha256::digest(request_body_json.as_bytes())),
"requestBodyJson": request_body_json,
"idempotencyKey": format!("standalone-scanner-{index}"),
"status": "prepared",
"createdAt": 1,
"updatedAt": 1
});
if index == 1 {
let object = ledger
.as_object_mut()
.expect("standalone scanner fixture is an object");
object.insert(
"schemaVersion".to_string(),
serde_json::Value::String(
"agent-runtime-canvas-generation-request.v2".to_string(),
),
);
object.remove("accessScheme");
object.remove("externalServiceOrigin");
object.remove("platformOwnerUserId");
}
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&relative_path,
"standalone scanner fixture",
&ledger,
256 * 1024,
)
.expect("write standalone scanner ledger");
let path = resolve_local_project_path(root, &relative_path)
.expect("resolve standalone scanner ledger");
persisted_paths.push((
path.clone(),
fs::read(path).expect("read standalone ledger"),
));
}
assert!(
!has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("standalone ledgers must not look like Agent recovery work")
);
let resumed = resume_game_creator_agent_background_tasks_at(root)
.expect("standalone ledgers must not block Agent recovery scan");
assert!(resumed.is_empty());
for (path, original) in persisted_paths {
assert_eq!(
fs::read(path).expect("standalone ledger remains"),
original,
"Agent orphan cleanup must preserve standalone ledger bytes"
);
}
}
#[test]
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-");
@@ -2202,7 +2202,7 @@ pub(super) fn try_acquire_game_creator_agent_runtime_task_lock_with_wait(
}
#[cfg(unix)]
pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file(
root: &Path,
relative_path: &str,
) -> Result<Option<File>, String> {
@@ -2311,7 +2311,7 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
}
#[cfg(windows)]
pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file(
root: &Path,
relative_path: &str,
) -> Result<Option<File>, String> {
@@ -2442,7 +2442,7 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
}
#[cfg(not(any(unix, windows)))]
pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file(
root: &Path,
relative_path: &str,
) -> Result<Option<File>, String> {
@@ -1065,7 +1065,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
.await
}
async fn resolve_canvas_resource_download_with_limit_and_route(
pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route(
_client: &reqwest::Client,
api_base_url: &str,
bearer_token: &str,
@@ -1073,6 +1073,30 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
max_bytes: usize,
read_url_route: &str,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit_route_and_fence(
_client,
api_base_url,
bearer_token,
resource,
max_bytes,
read_url_route,
|| Ok(()),
)
.await
}
pub(crate) async fn resolve_canvas_resource_download_with_limit_route_and_fence<F>(
_client: &reqwest::Client,
api_base_url: &str,
bearer_token: &str,
resource: &serde_json::Value,
max_bytes: usize,
read_url_route: &str,
mut fence: F,
) -> Result<Option<CanvasResourceDownload>, String>
where
F: FnMut() -> Result<(), String>,
{
if max_bytes == 0 {
return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string());
}
@@ -1091,10 +1115,11 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
api_base_url,
percent_encode_query_component(object_key)
);
(
Some(resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await?),
true,
)
fence()?;
let signed_url =
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await;
fence()?;
(Some(signed_url?), true)
} else if let Some(image_src) = image_src.as_deref() {
if image_src.starts_with('/') {
let read_url = format!(
@@ -1102,13 +1127,11 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
api_base_url,
percent_encode_query_component(image_src)
);
(
Some(
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url)
.await?,
),
true,
)
fence()?;
let signed_url =
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await;
fence()?;
(Some(signed_url?), true)
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
(Some(image_src.to_string()), false)
} else {
@@ -1121,14 +1144,15 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
return Ok(None);
};
let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?;
let download_client =
build_external_asset_download_client(&url, api_base_url, came_from_stable_reference)
.await?;
let mut response = download_client
.get(url)
.send()
.await
.map_err(|error| format!("下载画板资产失败:{error}"))?;
fence()?;
let download_client_result =
build_external_asset_download_client(&url, api_base_url, came_from_stable_reference).await;
fence()?;
let download_client = download_client_result?;
fence()?;
let response_result = download_client.get(url).send().await;
fence()?;
let mut response = response_result.map_err(|error| format!("下载画板资产失败:{error}"))?;
let status = response.status();
if status.is_redirection() {
return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string());
@@ -1160,11 +1184,14 @@ async fn resolve_canvas_resource_download_with_limit_and_route(
.unwrap_or_default()
.min(max_bytes),
);
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("读取画板资产失败:{error}"))?
{
loop {
fence()?;
let chunk = response.chunk().await;
fence()?;
let Some(chunk) = chunk.map_err(|error| format!("读取画板资产失败:{error}"))?
else {
break;
};
let next_len = bytes
.len()
.checked_add(chunk.len())
@@ -1884,7 +1911,7 @@ mod tests {
assert!(!external_editor_api_credentials_override_is_active());
}
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
fn read_asset_test_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set asset test read timeout");
@@ -1897,6 +1924,7 @@ mod tests {
}
bytes.extend_from_slice(&buffer[..read]);
}
String::from_utf8_lossy(&bytes).into_owned()
}
#[test]
@@ -2109,4 +2137,74 @@ mod tests {
server.join().expect("join bounded fixture");
assert!(error.contains("下载预算"));
}
#[tokio::test]
async fn canvas_download_revalidates_session_after_signed_url_before_media_get() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind fenced download fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let signed_url = format!("{base_url}/signed.png");
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let (mut signing, _) = listener.accept().expect("accept signing request");
request_sender
.send(read_asset_test_request(&mut signing))
.expect("capture signing request");
let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string();
write!(
signing,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write signing response");
listener
.set_nonblocking(true)
.expect("set fenced fixture nonblocking");
let deadline = std::time::Instant::now() + Duration::from_millis(200);
while std::time::Instant::now() < deadline {
match listener.accept() {
Ok((mut media, _)) => {
request_sender
.send(read_asset_test_request(&mut media))
.expect("capture forbidden media request");
break;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => panic!("accept fenced media request: {error}"),
}
}
});
let mut fence_calls = 0_u8;
let error = match resolve_canvas_resource_download_with_limit_route_and_fence(
&reqwest::Client::new(),
&base_url,
"test-api-key",
&serde_json::json!({"objectKey": "stable/slice.png"}),
1024,
"/api/external/v1/assets/read-url",
|| {
fence_calls = fence_calls.saturating_add(1);
if fence_calls == 2 {
Err("session-switched-after-read-url".to_string())
} else {
Ok(())
}
},
)
.await
{
Err(error) => error,
Ok(_) => panic!("session switch after signed URL must stop before media GET"),
};
server.join().expect("join fenced download fixture");
assert_eq!(error, "session-switched-after-read-url");
let requests = request_receiver.try_iter().collect::<Vec<_>>();
assert_eq!(requests.len(), 1, "media GET is forbidden after switch");
assert!(requests[0].starts_with("GET /api/external/v1/assets/read-url?"));
}
}
@@ -1585,6 +1585,11 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
check_game_creator_llm_config_from_config()
}
#[tauri::command]
pub(crate) fn read_platform_account_session_generation() -> u64 {
current_platform_session_generation()
}
#[tauri::command]
pub(crate) fn install_platform_account_session(
user_id: String,
@@ -1592,20 +1597,20 @@ pub(crate) fn install_platform_account_session(
api_base_url: String,
generation: u64,
) -> Result<(), String> {
install_platform_session(&user_id, &access_token, &api_base_url, generation)?;
let _ = install_external_agent_runner_platform_session(
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
generation,
);
Ok(())
)?;
install_platform_session(&user_id, &access_token, &api_base_url, generation)
}
#[tauri::command]
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(generation);
let _ = clear_external_agent_runner_platform_session(generation);
Ok(())
}
@@ -2217,32 +2217,15 @@ fn main() {
format!("获取 GUI owner 锁失败:{error}"),
)
})?;
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
app.manage(gui_owner_lock);
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin");
}
ensure_external_agent_runner_started_for_gui()
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
let _ = append_bounded_diagnostic_line(
path,
&format!("startup.runner.start.failed details={details}"),
);
show_startup_error_dialog(path);
}
})
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("启动 Agent Runner 失败:{error}"),
)
})?;
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
let manifest_event_sink =
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
attach_external_agent_runner_gui_owner(&manifest_event_sink)
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
.inspect_err(|error| {
if let Some(path) = setup_log.as_deref() {
let details =
@@ -2305,6 +2288,7 @@ fn main() {
confirm_resume_game_creator_agent_runtime_tasks,
schedule_game_creator_agent_ready_tasks,
check_game_creator_llm_config,
read_platform_account_session_generation,
install_platform_account_session,
clear_platform_account_session,
read_game_creator_app_config,
@@ -1,3 +1,4 @@
use sha2::{Digest, Sha256};
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -86,6 +87,27 @@ pub(crate) fn install_platform_session(
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
let snapshot =
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
install_platform_session_in(
&mut current,
&snapshot.user_id,
&snapshot.access_token,
&snapshot.api_base_url,
snapshot.generation,
);
Ok(())
}
fn validated_platform_session_snapshot(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<PlatformSessionSnapshot, String> {
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
}
@@ -98,17 +120,62 @@ pub(crate) fn install_platform_session(
if access_token.is_empty() || access_token.len() > 16 * 1024 {
return Err("陶泥儿登录凭据无效".to_string());
}
Ok(PlatformSessionSnapshot {
user_id: user_id.to_string(),
access_token: access_token.to_string(),
api_base_url,
generation,
})
}
pub(crate) fn validate_platform_session_input(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ())
}
pub(crate) fn replace_platform_session_for_gui_owner(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
let snapshot =
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
current.generation = snapshot.generation;
current.snapshot = Some(snapshot);
Ok(())
}
pub(crate) fn install_platform_session_checked(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
let snapshot =
validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?;
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
install_platform_session_in(
&mut current,
user_id,
access_token,
&api_base_url,
generation,
&snapshot.user_id,
&snapshot.access_token,
&snapshot.api_base_url,
snapshot.generation,
);
Ok(())
if current.snapshot.as_ref() == Some(&snapshot) {
Ok(())
} else {
Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string())
}
}
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
@@ -144,6 +211,26 @@ pub(crate) fn clear_platform_session(generation: u64) {
clear_platform_session_in(&mut current, generation);
}
pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
current.generation = generation;
current.snapshot = None;
}
pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_platform_session_in(&mut current, generation);
if current.generation == generation && current.snapshot.is_none() {
Ok(())
} else {
Err("authentication-required: 平台登出 generation 已过期".to_string())
}
}
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
platform_session()
.lock()
@@ -152,6 +239,13 @@ pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
.clone()
}
pub(crate) fn current_platform_session_generation() -> u64 {
platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.generation
}
pub(crate) fn validate_platform_session_snapshot(
expected: &PlatformSessionSnapshot,
) -> Result<(), String> {
@@ -165,6 +259,53 @@ pub(crate) fn validate_platform_session_snapshot(
}
}
pub(crate) fn with_validated_platform_session_fingerprint<T>(
expected_user_id: &str,
expected_api_base_url: &str,
expected_generation: u64,
expected_access_token_sha256: &str,
action: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let lease = acquire_validated_platform_session_fingerprint(
expected_user_id,
expected_api_base_url,
expected_generation,
expected_access_token_sha256,
)?;
let result = action();
drop(lease);
result
}
pub(crate) struct ValidatedPlatformSessionLease {
_guard: std::sync::MutexGuard<'static, PlatformSessionState>,
}
pub(crate) fn acquire_validated_platform_session_fingerprint(
expected_user_id: &str,
expected_api_base_url: &str,
expected_generation: u64,
expected_access_token_sha256: &str,
) -> Result<ValidatedPlatformSessionLease, String> {
let current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let matches = current.snapshot.as_ref().is_some_and(|snapshot| {
snapshot.user_id == expected_user_id
&& snapshot.api_base_url == expected_api_base_url
&& snapshot.generation == expected_generation
&& format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes()))
== expected_access_token_sha256
});
if !matches {
return Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
);
}
Ok(ValidatedPlatformSessionLease { _guard: current })
}
fn platform_session_snapshot_matches(
current: Option<&PlatformSessionSnapshot>,
expected: &PlatformSessionSnapshot,
@@ -237,6 +378,27 @@ pub(crate) fn install_test_platform_session(
}
}
/// Holds the shared test-session lock while presenting an explicit logged-out
/// state. Tests that assert missing-login behavior must use this guard so they
/// cannot observe a platform session installed by another parallel test.
#[cfg(test)]
pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard {
let isolation = PLATFORM_SESSION_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = std::mem::take(&mut *current);
*current = PlatformSessionState::default();
drop(current);
TestPlatformSessionGuard {
_isolation: isolation,
previous: Some(previous),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -321,6 +483,22 @@ mod tests {
);
}
#[test]
fn current_generation_preserves_the_floor_after_session_clear() {
let _session = clear_test_platform_session();
install_platform_session(
"generation-floor-user",
"generation-floor-token",
"https://dev.genarrative.world",
41,
)
.expect("install session generation floor");
clear_platform_session(42);
assert_eq!(current_platform_session_generation(), 42);
assert!(current_platform_session().is_none());
}
#[test]
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
assert_eq!(
@@ -386,4 +564,53 @@ mod tests {
));
}
}
#[test]
fn validated_session_lease_linearizes_local_commit_with_account_switch() {
let _session = install_test_platform_session(
"lease-user-a",
"lease-token-a",
"https://dev.genarrative.world",
);
let expected = current_platform_session().expect("current lease session");
let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes()));
let lease = acquire_validated_platform_session_fingerprint(
&expected.user_id,
&expected.api_base_url,
expected.generation,
&token_sha256,
)
.expect("acquire validated session lease");
let (started_sender, started_receiver) = std::sync::mpsc::channel();
let (finished_sender, finished_receiver) = std::sync::mpsc::channel();
let switcher = std::thread::spawn(move || {
started_sender.send(()).expect("signal account switch");
install_platform_session(
"lease-user-b",
"lease-token-b",
"https://dev.genarrative.world",
2,
)
.expect("switch account after lease release");
finished_sender.send(()).expect("signal switched account");
});
started_receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("switcher started");
assert!(
finished_receiver
.recv_timeout(std::time::Duration::from_millis(100))
.is_err(),
"account switch must wait until the synchronous local commit lease is released"
);
drop(lease);
finished_receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("account switch completed after lease release");
switcher.join().expect("join account switcher");
assert_eq!(
current_platform_session().map(|session| session.user_id),
Some("lease-user-b".to_string())
);
}
}
@@ -8,6 +8,7 @@ mod asset_canvas;
mod checkpoint;
mod conversation;
mod export;
mod external_editor_bindings;
mod filesystem;
mod manifest;
mod memory;
@@ -21,6 +22,7 @@ pub(crate) use asset_canvas::*;
pub(crate) use checkpoint::*;
pub(crate) use conversation::*;
pub(crate) use export::*;
pub(crate) use external_editor_bindings::*;
pub(crate) use filesystem::*;
pub(crate) use manifest::*;
pub(crate) use memory::*;
@@ -984,6 +984,27 @@ fn take_agent_db_record_failure_injection(
}
}
#[cfg(test)]
fn take_agent_db_record_after_sync_failure_injection(
root: &Path,
record_type: Option<&str>,
) -> Result<(), String> {
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record-after-sync");
match fs::read_to_string(&failure_path) {
Ok(expected_record_type) if record_type == Some(expected_record_type.trim()) => {
fs::remove_file(&failure_path)
.map_err(|error| format!("清理 Agent DB 测试落盘后失败注入标记失败:{error}"))?;
Err(format!(
"测试注入 Agent DB 记录已落盘后返回失败:{}",
expected_record_type.trim()
))
}
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("读取 Agent DB 测试落盘后失败注入标记失败:{error}")),
}
}
#[cfg(test)]
fn take_conversation_audit_failure_injection(root: &Path, message_id: &str) -> Result<(), String> {
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record");
@@ -1027,7 +1048,17 @@ fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Re
verify_agent_db_storage_current(&storage)?;
let line = serialize_agent_db_record(record)?;
validate_agent_db_append_class_record_size(append_class, &line)?;
append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)
append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?;
#[cfg(test)]
take_agent_db_record_after_sync_failure_injection(
root,
serde_json::from_str::<serde_json::Value>(&line)
.ok()
.as_ref()
.and_then(|record| record.get("recordType"))
.and_then(serde_json::Value::as_str),
)?;
Ok(())
}
#[cfg(test)]
@@ -2,6 +2,8 @@ use super::*;
use reqwest::multipart::{Form, Part};
use std::collections::{BTreeMap, HashMap};
use crate::agent::wait_for_external_generation_result;
const ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION: &str =
"game-creator-asset-canvas-generation.v1";
pub(crate) const ASSET_CANVAS_GENERATION_PROGRESS_EVENT: &str =
@@ -516,7 +518,7 @@ fn ensure_frozen_generation_platform_session(
fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String {
let _ = mode;
platform_art_generation_external_service_fingerprint(api_base_url)
platform_art_generation_external_service_fingerprint(api_base_url, None)
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -581,6 +583,7 @@ fn prepare_generation_service_identity(
ledger.api_identity_fingerprint.as_deref(),
api_base_url,
api_mode.bearer_token(),
None,
);
match identity_match {
PlatformArtGenerationServiceIdentityMatch::Current
@@ -2597,8 +2600,8 @@ async fn prepare_canvas_generation_context(
api_base_url: &str,
api_mode: &CanvasGenerationApiMode,
) -> Result<ExternalCanvasGenerationContext, String> {
prepare_external_canvas_generation_context(root, client, api_base_url, api_mode.bearer_token())
.await
let access = ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), None)?;
prepare_external_canvas_generation_context(root, client, &access).await
}
fn build_generation_request_snapshot(
@@ -3874,6 +3877,7 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at(
ledger.api_identity_fingerprint.as_deref(),
&api_base_url,
api_mode.bearer_token(),
None,
),
PlatformArtGenerationServiceIdentityMatch::LegacyUnverified
| PlatformArtGenerationServiceIdentityMatch::Unbound
File diff suppressed because it is too large Load Diff
@@ -921,15 +921,38 @@ pub(crate) fn validate_manifest_required_visual_asset(
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?;
if canvas_project_id != art_spec_project_id {
return Err(format!(
"派生视觉资产与当前统一视觉规范图不属于同一画布项目:{expected_path}"
));
}
if asset.source.reference_resource_ids.as_slice() != [art_spec_resource_id] {
let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else {
return Err(format!(
"派生视觉资产未精确引用当前统一视觉规范图:{expected_path}"
));
};
let original_provenance_matches =
canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id;
let rebound_local_source_matches = if original_provenance_matches {
true
} else {
let art_spec_bytes = resolve_local_project_path(root, &art_spec.local_path)
.ok()
.and_then(|path| fs::read(path).ok())
.ok_or_else(|| "读取统一视觉规范图失败".to_string())?;
let source_identity = new_external_editor_source_identity(
&art_spec.id,
&format!("{:x}", Sha256::digest(&art_spec_bytes)),
&art_spec.media_type,
&art_spec.kind,
)?;
external_editor_remote_reference_matches_local_source_at(
root,
&manifest.project_id,
canvas_project_id,
reference_resource_id,
&source_identity,
)?
};
if !rebound_local_source_matches {
return Err(format!(
"派生视觉资产未绑定当前统一视觉规范图的本地内容身份:{expected_path}"
));
}
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -46,17 +46,22 @@ fn external_agent_runner_gui_owner_attachment_state(
pub(super) fn register_external_agent_runner_gui_owner_attachment(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
config_dir: &Path,
params: ExternalAgentRunnerRequestParams,
) {
mut params: ExternalAgentRunnerRequestParams,
) -> Result<(), String> {
let mut state = lock_unpoisoned(state);
state.generation = state.generation.wrapping_add(1);
let generation = state.generation;
params.gui_owner_session_revision = Some(generation);
if let Some(owner_epoch) = params.gui_owner_epoch.as_deref() {
write_external_agent_runner_gui_owner_claim_atomic(config_dir, owner_epoch, generation)?;
}
state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration {
generation,
config_dir: config_dir.to_path_buf(),
params,
attached_boot_id: None,
});
Ok(())
}
pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F>(
@@ -990,6 +995,7 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
pub(crate) fn attach_external_agent_runner_gui_owner(
event_sink: &GameCreatorManifestInvalidationEventSink,
gui_owner_epoch: &str,
) -> Result<(), String> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
.store(true, std::sync::atomic::Ordering::Release);
@@ -1003,6 +1009,7 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
ExternalAgentRunnerRequestParams {
event_sink_port: Some(event_sink.port),
event_sink_token: Some(event_sink.token.clone()),
gui_owner_epoch: Some(gui_owner_epoch.to_string()),
platform_user_id: platform_session
.as_ref()
.map(|session| session.user_id.clone()),
@@ -1015,7 +1022,7 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
platform_auth_generation: platform_session.map(|session| session.generation),
..ExternalAgentRunnerRequestParams::default()
},
);
)?;
ensure_external_agent_runner(&config_dir).map(|_| ())
}
@@ -1025,67 +1032,130 @@ pub(crate) fn install_external_agent_runner_platform_session(
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
Some((user_id, access_token, api_base_url)),
generation,
);
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?;
let endpoint = {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
ensure_external_agent_runner(&config_dir)?
};
send_external_agent_runner_request(
&endpoint,
"platform.session.install",
ExternalAgentRunnerRequestParams {
platform_user_id: Some(user_id.to_string()),
platform_access_token: Some(access_token.to_string()),
platform_api_base_url: Some(api_base_url.to_string()),
platform_auth_generation: Some(generation),
..ExternalAgentRunnerRequestParams::default()
synchronize_external_agent_runner_platform_session_with(
|| {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
Some((user_id, access_token, api_base_url)),
generation,
)
.and_then(|_| ensure_external_agent_runner(&config_dir))
.and_then(|endpoint| {
validate_external_agent_runner_platform_session_attachment(
external_agent_runner_gui_owner_attachment_state(),
&config_dir,
&endpoint,
Some((user_id, access_token, api_base_url)),
generation,
)
})
},
|| shutdown_external_agent_runner_at(&config_dir),
)
.map(|_| ())
}
pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> {
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
None,
generation,
);
let Some(config_dir) = external_agent_runner_config_dir() else {
return Ok(());
};
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) else {
synchronize_external_agent_runner_platform_session_with(
|| {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
None,
generation,
)
.and_then(|_| ensure_external_agent_runner(&config_dir))
.and_then(|endpoint| {
validate_external_agent_runner_platform_session_attachment(
external_agent_runner_gui_owner_attachment_state(),
&config_dir,
&endpoint,
None,
generation,
)
})
},
|| shutdown_external_agent_runner_at(&config_dir),
)
}
fn validate_external_agent_runner_platform_session_attachment(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
config_dir: &Path,
endpoint: &ExternalAgentRunnerEndpoint,
session: Option<(&str, &str, &str)>,
generation: u64,
) -> Result<(), String> {
let state = lock_unpoisoned(state);
let registration = state.registration.as_ref().ok_or_else(|| {
"Agent Runner 尚未建立带 owner epoch 的 GUI owner 登记,平台登录态拒绝下发".to_string()
})?;
let expected_user_id = session.map(|(user_id, _, _)| user_id);
let expected_access_token = session.map(|(_, access_token, _)| access_token);
let expected_api_base_url = session.map(|(_, _, api_base_url)| api_base_url);
if registration.config_dir != config_dir
|| registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())
|| registration.params.gui_owner_epoch.is_none()
|| registration.params.gui_owner_session_revision != Some(registration.generation)
|| registration.params.platform_auth_generation != Some(generation)
|| registration.params.platform_user_id.as_deref() != expected_user_id
|| registration.params.platform_access_token.as_deref() != expected_access_token
|| registration.params.platform_api_base_url.as_deref() != expected_api_base_url
{
return Err("Agent Runner 未确认当前 GUI owner epoch 的平台登录态".to_string());
}
Ok(())
}
pub(super) fn synchronize_external_agent_runner_platform_session_with(
sync_attempt: impl FnOnce() -> Result<(), String>,
fence_runner: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let sync_result = sync_attempt();
let Err(sync_error) = sync_result else {
return Ok(());
};
send_external_agent_runner_request(
&endpoint,
"platform.session.clear",
ExternalAgentRunnerRequestParams {
platform_auth_generation: Some(generation),
..ExternalAgentRunnerRequestParams::default()
},
)
.map(|_| ())
match fence_runner() {
Ok(()) => Err(format!(
"{sync_error};为避免旧账号继续执行,Agent Runner 已停止,后续请求将按当前账号重建"
)),
Err(fence_error) => Err(format!(
"{sync_error};阻断旧账号 Agent Runner 失败:{fence_error}"
)),
}
}
pub(super) fn remember_external_agent_runner_platform_session(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>,
generation: u64,
) {
) -> Result<(), String> {
remember_external_agent_runner_platform_session_with(
state,
session,
generation,
write_external_agent_runner_gui_owner_claim_atomic,
)
}
pub(super) fn remember_external_agent_runner_platform_session_with(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>,
generation: u64,
write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>,
) -> Result<(), String> {
let mut state = lock_unpoisoned(state);
let Some(registration) = state.registration.as_mut() else {
return;
let Some(registration) = state.registration.as_ref() else {
return Ok(());
};
let current_generation = registration.params.platform_auth_generation.unwrap_or(0);
if generation < current_generation {
return;
return Ok(());
}
if generation == current_generation {
match session {
@@ -1096,23 +1166,43 @@ pub(super) fn remember_external_agent_runner_platform_session(
&& registration.params.platform_api_base_url.as_deref()
== Some(api_base_url) =>
{
return;
return Ok(());
}
Some(_) => return,
Some(_) => return Ok(()),
None if registration.params.platform_user_id.is_none()
&& registration.params.platform_access_token.is_none() =>
{
return;
return Ok(());
}
None => {}
}
}
state.generation = state.generation.wrapping_add(1);
let registration_generation = state.generation;
let claim = state.registration.as_ref().and_then(|registration| {
registration
.params
.gui_owner_epoch
.as_deref()
.map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string()))
});
if let Some((config_dir, owner_epoch)) = claim {
write_claim(&config_dir, &owner_epoch, registration_generation)?;
}
let registration = state
.registration
.as_mut()
.expect("checked GUI owner registration must remain present while locked");
registration.generation = registration_generation;
registration.attached_boot_id = None;
registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string());
registration.params.platform_access_token =
session.map(|(_, access_token, _)| access_token.to_string());
registration.params.platform_api_base_url =
session.map(|(_, _, api_base_url)| api_base_url.to_string());
registration.params.platform_auth_generation = Some(generation);
registration.params.gui_owner_session_revision = Some(registration_generation);
Ok(())
}
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
@@ -1405,6 +1495,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
steer_id: steer_id.map(str::to_string),
event_sink_port: None,
event_sink_token: None,
gui_owner_epoch: None,
gui_owner_session_revision: None,
platform_user_id: None,
platform_access_token: None,
platform_api_base_url: None,
@@ -1,5 +1,8 @@
use super::{endpoint::*, project_owner::*, protocol::*, state::*};
use crate::configure_game_creator_manifest_invalidation_event_sink;
use crate::{
install_game_creator_manifest_invalidation_event_sink,
validate_game_creator_manifest_invalidation_event_sink,
};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest as _, Sha256};
@@ -92,6 +95,134 @@ pub(super) fn external_agent_runner_request_agent(
Ok(agent.to_string())
}
pub(super) fn apply_external_agent_runner_gui_owner_platform_session(
state: &ExternalAgentRunnerServerState,
params: &ExternalAgentRunnerRequestParams,
) -> Result<(), String> {
apply_external_agent_runner_gui_owner_attachment(state, params, None)
}
fn apply_external_agent_runner_gui_owner_attachment(
state: &ExternalAgentRunnerServerState,
params: &ExternalAgentRunnerRequestParams,
event_sink: Option<crate::GameCreatorManifestInvalidationEventSink>,
) -> Result<(), String> {
let requested_epoch = params
.gui_owner_epoch
.as_deref()
.filter(|value| uuid::Uuid::parse_str(value).is_ok())
.ok_or_else(|| "Agent Runner GUI owner 缺少有效 owner epoch".to_string())?;
let requested_revision = params
.gui_owner_session_revision
.ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?;
let config_dir = state
.gui_owner_lock_path
.parent()
.ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?;
let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim);
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?;
if durable_claim.owner_epoch != requested_epoch
|| durable_claim.session_revision != requested_revision
{
return Err("Agent Runner GUI owner claim 已过期".to_string());
}
let requested_claim = (requested_epoch.to_string(), requested_revision);
let replace_claim = active_claim.as_ref() != Some(&requested_claim);
let result = match (
params.platform_user_id.as_deref(),
params.platform_access_token.as_deref(),
params.platform_api_base_url.as_deref(),
params.platform_auth_generation,
) {
(Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => {
if replace_claim {
crate::replace_platform_session_for_gui_owner(
user_id,
access_token,
api_base_url,
generation,
)
} else {
crate::install_platform_session_checked(
user_id,
access_token,
api_base_url,
generation,
)
}
}
(None, None, None, Some(generation)) => {
if replace_claim {
crate::clear_platform_session_for_gui_owner(generation);
Ok(())
} else {
crate::clear_platform_session_checked(generation)
}
}
(None, None, None, None) if replace_claim => {
crate::clear_platform_session_for_gui_owner(0);
Ok(())
}
(None, None, None, None) => Ok(()),
_ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()),
};
result?;
let committed_claim = match read_external_agent_runner_gui_owner_claim(config_dir) {
Ok(claim) => claim,
Err(error) => {
*active_claim = None;
crate::clear_platform_session_for_gui_owner(0);
return Err(format!(
"Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}"
));
}
};
if committed_claim.owner_epoch != requested_epoch
|| committed_claim.session_revision != requested_revision
{
*active_claim = None;
crate::clear_platform_session_for_gui_owner(0);
return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string());
}
if let Some(event_sink) = event_sink {
install_game_creator_manifest_invalidation_event_sink(event_sink);
}
*active_claim = Some(requested_claim);
Ok(())
}
pub(super) fn validate_external_agent_runner_gui_owner_claim_current(
state: &ExternalAgentRunnerServerState,
) -> Result<(), String> {
let config_dir = state
.gui_owner_lock_path
.parent()
.ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?;
let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim);
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir);
let matches = durable_claim.as_ref().is_ok_and(|claim| {
active_claim.as_ref() == Some(&(claim.owner_epoch.clone(), claim.session_revision))
});
if matches {
return Ok(());
}
*active_claim = None;
crate::clear_platform_session_for_gui_owner(0);
match durable_claim {
Ok(_) => Err(
"authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离"
.to_string(),
),
Err(error) => Err(format!(
"authentication-required: Agent Runner GUI owner claim 无法核验,平台登录态已隔离:{error}"
)),
}
}
fn external_agent_runner_method_requires_current_gui_owner_claim(method: &str) -> bool {
method == "mcp.status" || method.starts_with("runtime.")
}
pub(super) fn external_agent_runner_request_session_id(
request: &ExternalAgentRunnerRequest,
) -> Result<Option<String>, String> {
@@ -406,7 +537,8 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
claim_owner: impl Fn(&Path) -> Result<ExternalAgentRunnerProjectExecutionOwnerClaim, String>,
) -> ExternalAgentRunnerResponse {
let fingerprint = external_agent_runner_request_fingerprint(request);
{
let use_request_cache = request.method != "runner.attach_gui_owner";
if use_request_cache {
let cache = lock_unpoisoned(&state.write_request_cache);
if let Some(cached) = cache.find(&request.request_id) {
if cached.fingerprint == fingerprint {
@@ -466,15 +598,17 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
};
let mut cache = lock_unpoisoned(&state.write_request_cache);
if let Some(cached) = cache.find(&request.request_id) {
if cached.fingerprint == fingerprint {
return cached.response.clone();
if use_request_cache {
if let Some(cached) = cache.find(&request.request_id) {
if cached.fingerprint == fingerprint {
return cached.response.clone();
}
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"request-id-conflict",
"同一 requestId 不能用于不同请求",
);
}
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"request-id-conflict",
"同一 requestId 不能用于不同请求",
);
}
let response = match request.method.as_str() {
@@ -644,38 +778,28 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
"Agent Runner GUI owner 缺少 manifest 事件接收端".to_string()
})
.and_then(|(port, token)| {
configure_game_creator_manifest_invalidation_event_sink(port, token)
validate_game_creator_manifest_invalidation_event_sink(port, token)
});
if let Err(error) = event_sink {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"event-sink-invalid",
redact_runner_secret(&error, &token),
);
}
if let (
Some(user_id),
Some(access_token),
Some(api_base_url),
Some(generation),
) = (
request.params.platform_user_id.as_deref(),
request.params.platform_access_token.as_deref(),
request.params.platform_api_base_url.as_deref(),
request.params.platform_auth_generation,
) {
if let Err(error) = crate::install_platform_session(
user_id,
access_token,
api_base_url,
generation,
) {
let event_sink = match event_sink {
Ok(event_sink) => event_sink,
Err(error) => {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-invalid",
error,
"event-sink-invalid",
redact_runner_secret(&error, &token),
);
}
};
if let Err(error) = apply_external_agent_runner_gui_owner_attachment(
state,
&request.params,
Some(event_sink),
) {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-invalid",
error,
);
}
state.gui_owner_attached.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
@@ -695,40 +819,12 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
),
}
}
"platform.session.install" => {
let result = request
.params
.platform_user_id
.as_deref()
.zip(request.params.platform_access_token.as_deref())
.zip(request.params.platform_api_base_url.as_deref())
.zip(request.params.platform_auth_generation)
.ok_or_else(|| "平台登录态同步参数不完整".to_string())
.and_then(|(((user_id, access_token), api_base_url), generation)| {
crate::install_platform_session(user_id, access_token, api_base_url, generation)
});
match result {
Ok(()) => ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "installed": true }),
),
Err(error) => ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-invalid",
error,
),
}
}
"platform.session.clear" => {
let Some(generation) = request.params.platform_auth_generation else {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-invalid",
"平台登录态清除缺少 authGeneration",
);
};
crate::clear_platform_session(generation);
ExternalAgentRunnerResponse::success(&request.request_id, json!({ "cleared": true }))
"platform.session.install" | "platform.session.clear" => {
ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-epoch-required",
"平台登录态只能通过当前 GUI owner epoch 的 runner.attach_gui_owner 同步",
)
}
"runner.shutdown" | "shutdown" => {
let provider_requests_interrupted =
@@ -862,12 +958,14 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
"Agent Runner 不支持该方法",
),
};
cache_external_agent_runner_response_if_cacheable(
&mut cache,
&request.request_id,
&fingerprint,
&response,
);
if use_request_cache {
cache_external_agent_runner_response_if_cacheable(
&mut cache,
&request.request_id,
&fingerprint,
&response,
);
}
response
}
@@ -904,6 +1002,17 @@ pub(super) fn handle_external_agent_runner_request(
"Agent Runner method 无效",
);
}
if state.gui_owner_attached.load(Ordering::Acquire)
&& external_agent_runner_method_requires_current_gui_owner_claim(&request.method)
{
if let Err(error) = validate_external_agent_runner_gui_owner_claim_current(state) {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-claim-stale",
redact_runner_secret(&error, &expected_token),
);
}
}
match request.method.as_str() {
"runner.ping" => ExternalAgentRunnerResponse::success(
@@ -309,6 +309,38 @@ pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> Pa
config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)
}
pub(super) fn external_agent_runner_gui_owner_claim_path(config_dir: &Path) -> PathBuf {
config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME)
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ExternalAgentRunnerGuiOwnerClaim {
pub(super) owner_epoch: String,
pub(super) session_revision: u64,
}
pub(super) fn read_external_agent_runner_gui_owner_claim(
config_dir: &Path,
) -> Result<ExternalAgentRunnerGuiOwnerClaim, String> {
let path = external_agent_runner_gui_owner_claim_path(config_dir);
let file = open_external_agent_runner_endpoint_file(&path)?;
validate_external_agent_runner_endpoint_metadata(&file, &path)?;
let mut content = Vec::new();
file.take(EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES + 1)
.read_to_end(&mut content)
.map_err(|error| format!("读取 Agent Runner GUI owner claim 失败:{error}"))?;
if content.len() as u64 > EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES {
return Err("Agent Runner GUI owner claim 超过大小上限".to_string());
}
let claim = serde_json::from_slice::<ExternalAgentRunnerGuiOwnerClaim>(&content)
.map_err(|_| "解析 Agent Runner GUI owner claim 失败".to_string())?;
if uuid::Uuid::parse_str(&claim.owner_epoch).is_err() {
return Err("Agent Runner GUI owner claim 缺少有效 ownerEpoch".to_string());
}
Ok(claim)
}
pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result<bool, String> {
match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? {
Some(lock) => {
@@ -905,10 +937,13 @@ pub(crate) fn acquire_external_agent_runner_gui_owner_lock(
else {
return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string());
};
let owner_epoch = uuid::Uuid::new_v4().to_string();
let acquired_at = unix_millis();
let diagnostic = serde_json::to_vec(&json!({
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
"pid": std::process::id(),
"acquiredAt": unix_millis(),
"ownerEpoch": owner_epoch,
"acquiredAt": acquired_at,
}))
.map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?;
file.set_len(0)
@@ -921,5 +956,68 @@ pub(crate) fn acquire_external_agent_runner_gui_owner_lock(
path.display()
)
})?;
Ok(ExternalAgentRunnerGuiOwnerLock { _file: file })
write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?;
Ok(ExternalAgentRunnerGuiOwnerLock {
_file: file,
owner_epoch,
})
}
pub(super) fn write_external_agent_runner_gui_owner_claim_atomic(
config_dir: &Path,
owner_epoch: &str,
session_revision: u64,
) -> Result<(), String> {
let path = external_agent_runner_gui_owner_claim_path(config_dir);
let content = serde_json::to_vec(&json!({
"schemaVersion": "agent-runner-gui-owner-claim.v1",
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
"pid": std::process::id(),
"ownerEpoch": owner_epoch,
"sessionRevision": session_revision,
"updatedAt": unix_millis(),
}))
.map_err(|error| format!("生成 Agent Runner GUI owner claim 失败:{error}"))?;
if content.len() as u64 > EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES {
return Err("Agent Runner GUI owner claim 超过大小上限".to_string());
}
let mut temporary = None;
for sequence in 0..16_u32 {
let candidate = config_dir.join(format!(
".{EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME}.{}.{}.tmp",
std::process::id(),
sequence
));
match private_create_new_file(&candidate) {
Ok(file) => {
temporary = Some((candidate, file));
break;
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"创建 Agent Runner GUI owner claim 临时文件失败:{error}"
));
}
}
}
let (temporary_path, mut temporary_file) = temporary
.ok_or_else(|| "创建 Agent Runner GUI owner claim 临时文件重试耗尽".to_string())?;
let write_result = temporary_file
.write_all(&content)
.and_then(|_| temporary_file.sync_all());
drop(temporary_file);
if let Err(error) = write_result {
let _ = fs::remove_file(&temporary_path);
return Err(format!("写入 Agent Runner GUI owner claim 失败:{error}"));
}
if let Err(error) = replace_file_atomically(&temporary_path, &path) {
let _ = fs::remove_file(&temporary_path);
return Err(format!("提交 Agent Runner GUI owner claim 失败:{error}"));
}
let persisted = read_external_agent_runner_gui_owner_claim(config_dir)?;
if persisted.owner_epoch != owner_epoch || persisted.session_revision != session_revision {
return Err("Agent Runner GUI owner claim 写入后回读不一致".to_string());
}
Ok(())
}
@@ -9,12 +9,14 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7;
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str =
"agent-runner.gui-owner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str =
"agent-runner.gui-owner.claim.json";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_FILE_NAME: &str =
"execution-owner.json";
@@ -269,6 +271,10 @@ pub(super) struct ExternalAgentRunnerRequestParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) gui_owner_epoch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) gui_owner_session_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_access_token: Option<String>,
@@ -152,18 +152,27 @@ pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Resu
}
}
fn external_agent_runner_watchdog_tick(state: &ExternalAgentRunnerServerState) -> bool {
if !state.gui_owner_attached.load(Ordering::Acquire) {
return false;
}
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
Ok(true) => {
let _ = validate_external_agent_runner_gui_owner_claim_current(state);
false
}
Ok(false) | Err(_) => {
request_external_agent_runner_forced_shutdown(state);
true
}
}
}
#[cfg(test)]
pub(super) fn external_agent_runner_shutdown_if_gui_owner_lost(
state: &ExternalAgentRunnerServerState,
) -> Result<bool, String> {
if !state.gui_owner_attached.load(Ordering::Acquire) {
return Ok(false);
}
if external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path)? {
return Ok(false);
}
request_external_agent_runner_forced_shutdown(state);
Ok(true)
Ok(external_agent_runner_watchdog_tick(state))
}
pub(super) fn spawn_external_agent_runner_gui_owner_watchdog(
@@ -174,25 +183,10 @@ pub(super) fn spawn_external_agent_runner_gui_owner_watchdog(
thread::Builder::new()
.name("agent-runner-gui-owner-watchdog".to_string())
.spawn(move || loop {
if !state.gui_owner_attached.load(Ordering::Acquire) {
if !external_agent_runner_watchdog_tick(&state) {
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL);
continue;
}
let owner_lost =
match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) {
Ok(locked) => !locked,
Err(_) => true,
};
if !owner_lost {
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL);
continue;
}
state.draining.store(true, Ordering::Release);
state
.force_shutdown_requested
.store(true, Ordering::Release);
state.shutdown_requested.store(true, Ordering::Release);
thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT);
let _ = crate::agent::shutdown_game_creator_codex_app_servers();
remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id);
@@ -12,6 +12,7 @@ pub(super) struct ExternalAgentRunnerServerState {
pub(super) shutdown_requested: AtomicBool,
pub(super) force_shutdown_requested: AtomicBool,
pub(super) gui_owner_attached: AtomicBool,
pub(super) gui_owner_platform_session_claim: Mutex<Option<(String, u64)>>,
pub(super) draining: AtomicBool,
pub(super) active_connections: AtomicUsize,
pub(super) known_roots: Mutex<BTreeSet<PathBuf>>,
@@ -89,6 +90,7 @@ impl ExternalAgentRunnerServerState {
shutdown_requested: AtomicBool::new(false),
force_shutdown_requested: AtomicBool::new(false),
gui_owner_attached: AtomicBool::new(false),
gui_owner_platform_session_claim: Mutex::new(None),
draining: AtomicBool::new(false),
active_connections: AtomicUsize::new(0),
known_roots: Mutex::new(BTreeSet::new()),
@@ -232,6 +234,13 @@ pub(super) struct ExternalAgentRunnerInstanceLock {
#[derive(Debug)]
pub(crate) struct ExternalAgentRunnerGuiOwnerLock {
pub(super) _file: File,
pub(super) owner_epoch: String,
}
impl ExternalAgentRunnerGuiOwnerLock {
pub(crate) fn owner_epoch(&self) -> &str {
&self.owner_epoch
}
}
pub(super) struct ExternalAgentRunnerProjectOwnerStorage {
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More