合并 origin/master 并保留资源画布改动
合并主分支 External Editor 账号绑定与幂等接口实现 保留项目资源预览刷新与运行提示修复 适配素材画布生成器到新的 External Editor 调用契约 合并并保留双方项目决策记录
This commit is contained in:
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
+516
-92
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
Reference in New Issue
Block a user