Compare commits

..

7 Commits

Author SHA1 Message Date
kdletters b48293fb1f 收敛平台会话 epoch 重装语义,避免原生计数与渲染层漂移
- GUI owner 重装与清除改为按 authority 快照重定基准,保持原生计数与渲染层认知一致
- 注释写明 epoch 交接的授权前提,以及写入单调性由渲染层 reserve 与 durable session revision 保证
- 同主体换凭据的重装保持身份代次,在途 operation 的冻结会话继续有效
- 补充 epoch 重装与换主体失效的回归用例,替换原先错误假设原生计数器单调的用例
2026-09-16 17:08:47 +08:00
kdletters e08171fd2e 补记平台会话身份与凭据分离的规范与记忆
- 主规范新增平台会话身份与凭据分离条款,并修正长回合保活的口径
- 共享记忆补充并发生图登录态冲突的排障口径与验证方式
- 决策记录补充身份代次、凭据轮换、刷新失败语义的长期决策
- 新增本次里程碑与实施计划,记录证据与未验证项
2026-09-16 17:08:47 +08:00
kdletters 479120d368 修复刷新轮换竞争被放大成登出
- refresh 接口的轮换失败不再下发清空 refresh cookie 的响应,避免删掉并发请求刚写入的有效 cookie
- 缺少 refresh cookie 的失败保持无副作用,cookie 失效只由吊销、过期或身份变更决定
- 网站前端在刷新返回 401 时先用当前 cookie 收敛重试一次,重试仍被拒绝才判权威失效
- 网站前端只在当前鉴权代次未变化时重试刷新,避免旧账号结论污染新代次
- 更新 api-server 与前端断言,覆盖轮换竞争与收敛重试两条路径
2026-09-16 17:08:47 +08:00
kdletters 4182979a19 平台会话身份与凭据分离,续期不再中断在途生成
- 原生会话快照拆成身份代次与写入 revision,安装与清除按 revision 排序、按身份归属判定
- 冻结平台会话校验改为只比身份,同账号 access token 轮换不再让在途生成、编辑、上传、确认和下载失败
- 同一身份代次禁止更换登录主体或服务 origin,换号、退出和 origin 变化继续失败关闭
- Runner attach 新增 platform_auth_revision,平台会话安装与校验按 revision 排序、按身份归属判定
- GUI owner 替换与清除改为计数器只增不减,同一主体重装保持身份代次,避免旧 epoch 迟到写入复活
- renderer 拆分平台原生身份代次与写入 revision,同账号续期只更新凭据,不推进身份代次
- 刷新失败结果新增权威失效判定,只有服务端明确 401 与 403 才清除本地会话,网络错误和 5xx 保留会话
- 收敛刷新判据,重复的刷新工具函数合并为一个对外判定,去掉一次性包装
- 补齐平台会话身份判据、续期与迟到写入的 Rust 与前端回归用例
2026-09-16 17:08:46 +08:00
lhk229 262deaf9b7 接入做成游戏后的策划文档指令 (#383)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m46s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m16s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m17s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m14s
Project CI / AI game creator shell Rust crates (push) Successful in 4m1s
Project CI / Frontend tests (push) Successful in 4m32s
Project CI / Backend tests (push) Successful in 7m54s
Project CI / Repository checks (push) Successful in 4m19s
Project CI / AI game creator shell web tests (push) Failing after 4m9s
Project CI / Native shell tests (push) Successful in 9m12s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m35s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m39s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m3s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m9s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m40s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m16s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 6m10s
Project CI / Native shell tests (pull_request) Successful in 8m50s
## 变更说明

点击策划阶段的“做成游戏”后,切换到 GameAgent 并自动发送读取 `design_artifacts/` 文档的指令。

## 验证

- npm run typecheck
- git diff --check
- 提交钩子 ESLint / Prettier

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/383
2026-09-16 16:51:37 +08:00
kdletters c36a5170f8 AGC 新增本地自定义 LLM 配置与展示模型勾选
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m28s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / AI game creator shell Rust crates (push) Successful in 2m51s
Project CI / Frontend tests (push) Successful in 4m44s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Repository checks (push) Successful in 4m17s
Project CI / Backend tests (push) Successful in 7m48s
Project CI / AI game creator shell web tests (push) Successful in 3m43s
- 本地配置新增 llm.customEnabled 与 llm.visibleModels,显式开启后保留自定义连接和勾选列表,官方路由下仍清空凭据
- 配置迁移始终写出 customEnabled、visibleModels、apiKey、baseUrl、model、apiKind、reasoningEffort,便于手写自定义连接
- 新增 discover_game_creator_llm_models 命令,直连自定义端点 GET /models,限制超时与响应大小并脱敏错误
- codex_app_server 自定义模式改用配置里的地址与 Key 走本地凭据代理,不再经过平台 /api/llm,也不回退官方路由
- 模型目录按官方与自定义来源隔离缓存,自定义模式只展示勾选模型,选项失效时回退第一项
- 设置页在自定义模式展示 API 地址、API Key、协议与推理档,并提供模型读取、搜索、勾选与已勾选预览
- 补齐固定项排版与读取按钮样式,同步 AGC 模型选择规范、实施计划与共享概览
2026-09-16 16:39:15 +08:00
kdletters 9663bbf911 修复 AGC 客户端登录失败原因提示
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m7s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m38s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m38s
Project CI / AI game creator shell Rust crates (push) Successful in 2m29s
Project CI / Frontend tests (push) Successful in 4m42s
Project CI / Repository checks (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / Backend tests (push) Successful in 8m39s
Project CI / AI game creator shell web tests (push) Successful in 3m0s
- clientAuth 错误响应解析兼容未带 envelope 的旧形态错误体,密码错误时显示接口返回原因而不是固定“登录失败”
- 非 JSON 与结构无法识别的响应继续使用本地化兜底,不回显内部英文和原始 JSON
- appSurface 认证用例补充 envelope 与旧形态两条回归测试
2026-09-16 16:05:59 +08:00
82 changed files with 2999 additions and 1952 deletions
@@ -2,6 +2,8 @@
"schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server",
"llm": {
"customEnabled": false,
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
@@ -1,12 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
"description": "AI 游戏创作主窗口允许读系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
) -> Option<(&'a str, &'a str)> {
match self {
Self::PlatformSession { .. } => None,
#[cfg(test)]
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
#[cfg(test)]
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
.as_deref()
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
#[cfg(not(test))]
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
Self::AuthBridge { .. } => None,
}
}
}
@@ -2018,7 +2017,13 @@ impl CodexAppServerConnection {
let codex_cli_version = game_creator_codex_cli_version_identity()
.map_err(platform_llm::LlmError::InvalidConfig)?;
let mut effective_llm = llm.clone();
let credential = if game_creator_official_llm_route_locked() {
let credential = if llm.custom_enabled {
crate::config::validate_custom_llm_connection(llm)
.map_err(platform_llm::LlmError::InvalidConfig)?;
CodexAppServerCredential::AppDataKey {
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
}
} else if game_creator_official_llm_route_locked() {
let session = current_platform_session().ok_or_else(|| {
platform_llm::LlmError::InvalidConfig(
"authentication-required: 请先登录陶泥儿账号".to_string(),
@@ -2186,7 +2191,8 @@ impl CodexAppServerConnection {
true,
),
_ => (
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
(llm.custom_enabled
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.then(|| credential.direct_provider_route(llm))
.flatten()
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
@@ -4743,6 +4749,8 @@ mod tests {
fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(),
@@ -5528,6 +5536,59 @@ mod tests {
assert_ne!(command_token, provider_key);
}
#[tokio::test]
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
let mut llm = test_llm();
llm.custom_enabled = true;
llm.api_key = "custom-upstream-fixture-secret".into();
llm.base_url = "http://127.0.0.1:9/v1".into();
llm.model = "vendor/model.v1:latest".into();
llm.visible_models = vec![llm.model.clone()];
let credential = CodexAppServerCredential::AppDataKey {
fingerprint: "custom-fixture".into(),
};
let (base, key) = credential
.direct_provider_route(&llm)
.expect("custom route");
assert_eq!(base, llm.base_url);
assert_eq!(key, llm.api_key);
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
for mode in [
CodexAppServerWorkspaceMode::DirectProject,
CodexAppServerWorkspaceMode::ToolHost,
] {
let mut command = tokio::process::Command::new("fixture");
configure_game_creator_codex_app_server_command_for_mode(
&mut command,
&llm,
mode,
Some(&proxy),
None,
true,
)
.unwrap();
let arguments = command
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join("\n");
let params = codex_app_server_thread_start_params(
&llm.model,
std::path::Path::new("fixture-workspace"),
mode,
String::new(),
true,
);
assert_eq!(params["model"], "vendor/model.v1:latest");
assert!(!arguments.contains(&llm.api_key));
assert!(!arguments.contains("/api/llm"));
for (_, value) in command.as_std().get_envs() {
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
@@ -3053,14 +3053,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
)?;
let _platform_session_lease = access
.frozen_platform_session()
.map(|session| {
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
)
})
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
// The network phase deliberately runs without the project write lock. Capture rollback state
// only after acquiring the lock and revalidating the source identity, otherwise a failure can
@@ -5329,6 +5322,8 @@ mod tests {
fn direct_test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(),
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
root: PathBuf,
token: String,
session_user_id: String,
session_generation: u64,
session_identity_generation: u64,
}
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
@@ -1241,7 +1241,8 @@ fn external_mcp_session_id(root: &Path) -> String {
material.push('\0');
material.push_str(&session.user_id);
material.push('\0');
material.push_str(&session.generation.to_string());
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
material.push_str(&session.identity_generation.to_string());
}
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
}
@@ -1759,7 +1760,9 @@ async fn handle_external_mcp_http_request(
let Some(session) = current_platform_session() else {
return Err(StatusCode::UNAUTHORIZED);
};
if session.user_id != state.session_user_id || session.generation != state.session_generation {
if session.user_id != state.session_user_id
|| session.identity_generation != state.session_identity_generation
{
return Err(StatusCode::UNAUTHORIZED);
}
let response = EXTERNAL_MCP_BRIDGE_URL
@@ -1794,7 +1797,7 @@ pub(crate) async fn start_external_mcp_loopback(
root,
token: token.clone(),
session_user_id: session.user_id,
session_generation: session.generation,
session_identity_generation: session.identity_generation,
};
let app = Router::new()
.route(&route, post(handle_external_mcp_http_request))
@@ -11,6 +11,10 @@ use super::external_generation_state::{
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
};
use super::*;
use crate::platform_session::{
acquire_platform_session_identity_lease, validate_platform_session_identity,
PlatformSessionIdentity,
};
use reqwest::multipart::{Form, Part};
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
@@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice {
#[derive(Clone)]
struct PreparedPlatformSessionFence {
user_id: String,
api_base_url: String,
generation: u64,
access_token_sha256: String,
identity: PlatformSessionIdentity,
}
impl PreparedPlatformSessionFence {
@@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence {
access
.frozen_platform_session()
.map(|session| PreparedPlatformSessionFence {
user_id: session.user_id.clone(),
api_base_url: session.api_base_url.clone(),
generation: session.generation,
access_token_sha256: format!(
"{:x}",
Sha256::digest(session.access_token.as_bytes())
),
identity: session.identity(),
})
}
fn validate(&self) -> Result<(), String> {
let matches = current_platform_session().is_some_and(|session| {
session.user_id == self.user_id
&& session.api_base_url == self.api_base_url
&& session.generation == self.generation
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
== self.access_token_sha256
});
if matches {
Ok(())
} else {
Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
)
}
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
validate_platform_session_identity(&self.identity)
}
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
acquire_validated_platform_session_fingerprint(
&self.user_id,
&self.api_base_url,
self.generation,
&self.access_token_sha256,
)
acquire_platform_session_identity_lease(&self.identity)
}
}
@@ -10636,7 +10613,7 @@ mod canvas_generation_tests {
}
drop(owner_a_access);
drop(frozen_owner_a);
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
.expect("switch to owner B");
let error = match request_platform_art_asset_with_runtime_options_at(
@@ -10761,8 +10738,14 @@ mod canvas_generation_tests {
.recv_timeout(Duration::from_secs(3))
.expect("wait for accepted response");
std::thread::sleep(Duration::from_millis(50));
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
.expect("switch platform account after accepted response");
install_platform_session(
"post-202-user-b",
"post-202-token-b",
&switch_base_url,
2,
2,
)
.expect("switch platform account after accepted response");
});
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
@@ -1431,12 +1431,13 @@ mod external_generation_state_tests {
base_url,
);
let frozen_a = current_platform_session().expect("freeze owner A");
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
replace_platform_session_for_gui_owner(
"fingerprint-owner-b",
"fingerprint-token-b",
base_url,
2,
2,
)
.expect("switch global session to owner B");
let current_b = current_platform_session().expect("owner B is current after switch");
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
response_stream_fixture("finalization-tool-plan-repair-chain-run");
let root = project.path();
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "finalization-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "finalization-tool-plan-model".to_string(),
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-provider-model".to_string(),
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
LlmMessage::user("修复格式"),
]);
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-tool-plan-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-tool-plan-model".to_string(),
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
let root = project.path();
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-generic-retry-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-generic-retry-model".to_string(),
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
response_stream_fixture("tool-plan-capacity-preflight-run");
let root = project.path();
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-capacity-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-capacity-model".to_string(),
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
LlmMessage::user("修复格式"),
]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "durable-control-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "durable-control-tool-plan-model".to_string(),
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-cleanup-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-cleanup-model".to_string(),
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "terminal-handoff-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "terminal-handoff-model".to_string(),
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "provider-model".to_string(),
@@ -1939,8 +1939,9 @@ pub(crate) async fn polish_local_project_prompt(
}
#[tauri::command]
pub(crate) fn read_platform_account_session_generation() -> u64 {
current_platform_session_generation()
pub(crate) fn read_platform_account_session_state(
) -> crate::platform_session::PlatformSessionWriteState {
crate::platform_session::current_platform_session_write_state()
}
#[tauri::command]
@@ -1948,28 +1949,45 @@ pub(crate) async fn install_platform_account_session(
user_id: String,
access_token: String,
api_base_url: String,
generation: u64,
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
validate_platform_session_input(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)?;
install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
generation,
identity_generation,
revision,
)?;
install_platform_session(&user_id, &access_token, &api_base_url, generation)
install_platform_session(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)
})
.await
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
}
#[tauri::command]
pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
pub(crate) async fn clear_platform_account_session(
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(generation);
clear_external_agent_runner_platform_session(identity_generation, revision)?;
clear_platform_session(identity_generation, revision);
Ok(())
})
.await
@@ -1991,6 +2009,8 @@ pub(crate) fn write_game_creator_app_config(
.lock()
.map_err(|_| "配置写入锁不可用")?;
let (current, overlays) = load_game_creator_app_config_for_write()?;
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
config.llm.custom_enabled = current.llm.custom_enabled;
config.selected_model_id = current.selected_model_id;
config.selected_model_is_default = current.selected_model_is_default;
persist_game_creator_app_config(config, overlays, false)
@@ -2027,7 +2047,12 @@ pub(crate) fn select_game_creator_model(
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
if model_id.is_empty()
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
if config.llm.custom_enabled {
if !config.llm.visible_models.contains(&model_id) {
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
}
} else if model_id.is_empty()
|| model_id.len() > 64
|| !model_id
.bytes()
@@ -2035,12 +2060,21 @@ pub(crate) fn select_game_creator_model(
{
return Err("模型标识无效".into());
}
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
persist_game_creator_app_config(config, overlays, true)
}
#[tauri::command]
pub(crate) async fn discover_game_creator_llm_models(
llm: GameCreatorLlmConfig,
) -> Result<Vec<String>, String> {
if !load_game_creator_app_config()?.llm.custom_enabled {
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
}
fetch_custom_llm_models(&llm).await
}
fn persist_game_creator_app_config(
config: GameCreatorAppConfig,
overlays: Vec<(PathBuf, serde_json::Value)>,
@@ -2056,8 +2090,8 @@ fn persist_game_creator_app_config(
let previous = overlay.clone();
if let Some(fields) = overlay.as_object_mut() {
for (key, value) in fields.iter_mut() {
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
== model_only
if !model_only
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
{
if let Some(saved_value) = saved.get(key) {
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
@@ -4231,14 +4265,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
access.validate_frozen_session()?;
let _platform_session_lease = frozen_session
.as_ref()
.map(|session| {
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
)
})
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
access.validate_frozen_session()?;
File diff suppressed because it is too large Load Diff
@@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile {
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
custom_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
visible_models: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfig {
#[serde(default)]
custom_enabled: bool,
#[serde(default)]
visible_models: Vec<String>,
api_key: String,
base_url: String,
model: String,
@@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig {
impl Default for GameCreatorLlmConfig {
fn default() -> Self {
Self {
custom_enabled: false,
visible_models: Vec::new(),
api_key: String::new(),
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
@@ -2715,12 +2725,13 @@ 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,
read_platform_account_session_state,
install_platform_account_session,
clear_platform_account_session,
read_game_creator_app_config,
write_game_creator_app_config,
select_game_creator_model,
discover_game_creator_llm_models,
upload_local_asset,
register_local_asset,
create_ui_design_resource,
File diff suppressed because it is too large Load Diff
@@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> {
}
/// Call before and after every awaited remote action and immediately before installing a
/// binding. Developer-key mode has no process-global account generation to compare.
/// binding. 只比较身份:同一账号的 access token 轮换(长回合保活、401 续期)不得让
/// 在途的生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin 变化仍然
/// 失败关闭。Developer-key 模式没有进程级身份代次可比对。
pub(crate) fn validate_frozen_session(&self) -> Result<(), String> {
validate_external_editor_binding_access_shape(self)?;
if let Some(session) = self.frozen_platform_session {
validate_platform_session_snapshot(session)?;
validate_frozen_platform_session(session)?;
}
Ok(())
}
@@ -1152,7 +1154,8 @@ mod tests {
user_id: user_id.to_string(),
access_token: token.to_string(),
api_base_url: "https://dev.genarrative.world".to_string(),
generation,
identity_generation: generation,
revision: generation,
}
}
@@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session<T>(
let Some(platform_session) = platform_session else {
return action();
};
let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes());
with_validated_platform_session_fingerprint(
&platform_session.user_id,
&platform_session.api_base_url,
platform_session.generation,
&access_token_sha256,
action,
)
with_validated_platform_session_identity(&platform_session.identity(), action)
}
fn commit_resource_edit_asset_with_frozen_platform_session(
@@ -4774,14 +4767,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at(
let current_platform_session = current_platform_session();
let _platform_session_lease = current_platform_session
.as_ref()
.map(|session| {
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&sha256_hex(session.access_token.as_bytes()),
)
})
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?;
let entries = match fs::read_dir(&directory) {
@@ -5084,12 +5070,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at(
Ok(())
};
if let Some(session) = platform_session {
let access_token_sha256 = sha256_hex(session.access_token.as_bytes());
crate::platform_session::with_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&access_token_sha256,
crate::platform_session::with_validated_platform_session_identity(
&session.identity(),
archive,
)?;
} else {
@@ -5500,7 +5482,8 @@ mod tests {
user_id: "gui-owner".to_string(),
access_token: "gui-token".to_string(),
api_base_url: "https://dev.genarrative.world".to_string(),
generation: 7,
identity_generation: 7,
revision: 7,
};
let developer_credentials = (
"https://dev.genarrative.world".to_string(),
@@ -5911,6 +5894,7 @@ mod tests {
"source-binding-token-b",
api_base_url,
*generation,
*generation,
)
.expect("switch account after source registration");
}
@@ -6925,7 +6909,7 @@ mod tests {
listener,
upload_url,
false,
Some((base_url.clone(), frozen_session.generation + 1)),
Some((base_url.clone(), frozen_session.identity_generation + 1)),
done_receiver,
);
let client = reqwest::Client::new();
@@ -6952,7 +6936,8 @@ mod tests {
"source-binding-owner-a",
"source-binding-token-a",
&base_url,
frozen_session.generation + 2,
frozen_session.identity_generation + 2,
frozen_session.identity_generation + 2,
)
.expect("switch back to source binding owner A");
let resumed_session = current_platform_session().expect("resumed source binding owner A");
@@ -7018,7 +7003,7 @@ mod tests {
install_test_platform_session("submission-owner-a", "submission-token-a", &base_url);
let frozen_session = current_platform_session().expect("frozen owner A session");
let switch_base_url = base_url.clone();
let switch_generation = frozen_session.generation + 1;
let switch_generation = frozen_session.identity_generation + 1;
let server = std::thread::spawn(move || {
let mut stream =
accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0);
@@ -7032,6 +7017,7 @@ mod tests {
"submission-token-b",
&switch_base_url,
switch_generation,
switch_generation,
)
.expect("switch to owner B before returning accepted response");
write_json(
@@ -7447,8 +7433,14 @@ mod tests {
ledger.access_scheme = None;
initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a))
.expect("write resource ledger for owner A");
replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2)
.expect("switch global resource session to owner B");
replace_platform_session_for_gui_owner(
"resource-owner-b",
"resource-token-b",
base_url,
2,
2,
)
.expect("switch global resource session to owner B");
let error = prepare_resource_edit_service_identity(
root,
@@ -7509,7 +7501,8 @@ mod tests {
user_id: "resource-identity-owner-b".to_string(),
access_token: "resource-identity-token-b".to_string(),
api_base_url: owner_a.api_base_url.clone(),
generation: owner_a.generation + 1,
identity_generation: owner_a.identity_generation + 1,
revision: owner_a.revision + 1,
};
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared);
ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string());
@@ -7535,7 +7528,8 @@ mod tests {
&owner_b.user_id,
&owner_b.access_token,
&owner_b.api_base_url,
owner_b.generation,
owner_b.identity_generation,
owner_b.revision,
)
.expect("switch to resource non-owner B");
@@ -7639,7 +7633,8 @@ mod tests {
"resource-lease-owner-b",
"resource-lease-token-b",
api_base_url,
frozen_a.generation + 1,
frozen_a.identity_generation + 1,
frozen_a.revision + 1,
)
.expect("switch resource lease owner");
switched_sender.send(()).expect("signal resource switch");
@@ -8259,7 +8254,8 @@ mod tests {
"archive-owner-b",
"archive-token-b",
api_base_url,
owner_a.generation + 1,
owner_a.identity_generation + 1,
owner_a.revision + 1,
)
.expect("switch to owner B");
let error = archive_failed_local_project_resource_edit_at(
@@ -8388,7 +8384,8 @@ mod tests {
"pending-owner-b",
"pending-token-b",
api_base_url,
owner_a.generation + 1,
owner_a.identity_generation + 1,
owner_a.revision + 1,
)
.expect("switch to pending owner B");
let owner_b = current_platform_session().expect("pending owner B session");
@@ -9347,7 +9344,7 @@ mod tests {
let (attempted_sender, attempted_receiver) = mpsc::channel();
let (completed_sender, completed_receiver) = mpsc::channel();
let switch_api_base_url = api_base_url.to_string();
let switch_generation = frozen_session.generation + 1;
let switch_generation = frozen_session.identity_generation + 1;
let switch_thread = std::thread::spawn(move || {
begin_switch_receiver
.recv()
@@ -9360,6 +9357,7 @@ mod tests {
"commit-token-b",
&switch_api_base_url,
switch_generation,
switch_generation,
)
.expect("switch to commit owner B");
completed_sender
@@ -987,7 +987,10 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
platform_api_base_url: platform_session
.as_ref()
.map(|session| session.api_base_url.clone()),
platform_auth_generation: platform_session.map(|session| session.generation),
platform_auth_generation: platform_session
.as_ref()
.map(|session| session.identity_generation),
platform_auth_revision: platform_session.map(|session| session.revision),
..ExternalAgentRunnerRequestParams::default()
},
)?;
@@ -998,7 +1001,8 @@ pub(crate) fn install_external_agent_runner_platform_session(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?;
@@ -1008,7 +1012,8 @@ pub(crate) fn install_external_agent_runner_platform_session(
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
Some((user_id, access_token, api_base_url)),
generation,
identity_generation,
revision,
)
.and_then(|_| ensure_external_agent_runner(&config_dir))
.and_then(|endpoint| {
@@ -1017,7 +1022,8 @@ pub(crate) fn install_external_agent_runner_platform_session(
&config_dir,
&endpoint,
Some((user_id, access_token, api_base_url)),
generation,
identity_generation,
revision,
)
})
},
@@ -1025,7 +1031,10 @@ pub(crate) fn install_external_agent_runner_platform_session(
)
}
pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> {
pub(crate) fn clear_external_agent_runner_platform_session(
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
let Some(config_dir) = external_agent_runner_config_dir() else {
return Ok(());
};
@@ -1035,7 +1044,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
None,
generation,
identity_generation,
revision,
)
.and_then(|_| ensure_external_agent_runner(&config_dir))
.and_then(|endpoint| {
@@ -1044,7 +1054,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R
&config_dir,
&endpoint,
None,
generation,
identity_generation,
revision,
)
})
},
@@ -1057,7 +1068,8 @@ fn validate_external_agent_runner_platform_session_attachment(
config_dir: &Path,
endpoint: &ExternalAgentRunnerEndpoint,
session: Option<(&str, &str, &str)>,
generation: u64,
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
let state = lock_unpoisoned(state);
let registration = state.registration.as_ref().ok_or_else(|| {
@@ -1070,7 +1082,8 @@ fn validate_external_agent_runner_platform_session_attachment(
|| 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_auth_generation != Some(identity_generation)
|| registration.params.platform_auth_revision != Some(revision)
|| registration.params.platform_user_id.as_deref() != expected_user_id
|| registration.params.platform_access_token.as_deref() != expected_access_token
|| registration.params.platform_api_base_url.as_deref() != expected_api_base_url
@@ -1101,12 +1114,14 @@ pub(super) fn synchronize_external_agent_runner_platform_session_with(
pub(super) fn remember_external_agent_runner_platform_session(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>,
generation: u64,
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
remember_external_agent_runner_platform_session_with(
state,
session,
generation,
identity_generation,
revision,
write_external_agent_runner_gui_owner_claim_atomic,
)
}
@@ -1114,21 +1129,26 @@ pub(super) fn remember_external_agent_runner_platform_session(
pub(super) fn remember_external_agent_runner_platform_session_with(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>,
generation: u64,
identity_generation: u64,
revision: u64,
write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>,
) -> Result<(), String> {
let mut state = lock_unpoisoned(state);
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 {
// 写入顺序只认 revision;身份代次只表达主体归属,同一账号续期会推进 revision
// 但保持 identity generation 不变。
let current_revision = registration.params.platform_auth_revision.unwrap_or(0);
if revision < current_revision {
return Ok(());
}
if generation == current_generation {
if revision == current_revision {
match session {
Some((user_id, access_token, api_base_url))
if registration.params.platform_user_id.as_deref() == Some(user_id)
&& registration.params.platform_auth_generation
== Some(identity_generation)
&& registration.params.platform_access_token.as_deref()
== Some(access_token)
&& registration.params.platform_api_base_url.as_deref()
@@ -1168,7 +1188,8 @@ pub(super) fn remember_external_agent_runner_platform_session_with(
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.platform_auth_generation = Some(identity_generation);
registration.params.platform_auth_revision = Some(revision);
registration.params.gui_owner_session_revision = Some(registration_generation);
Ok(())
}
@@ -1472,6 +1493,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
platform_access_token: None,
platform_api_base_url: None,
platform_auth_generation: None,
platform_auth_revision: None,
};
match stable_identity {
Some(stable_identity) => {
@@ -133,37 +133,46 @@ fn apply_external_agent_runner_gui_owner_attachment(
params.platform_access_token.as_deref(),
params.platform_api_base_url.as_deref(),
params.platform_auth_generation,
params.platform_auth_revision,
) {
(Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => {
(
Some(user_id),
Some(access_token),
Some(api_base_url),
Some(identity_generation),
Some(revision),
) => {
if replace_claim {
crate::replace_platform_session_for_gui_owner(
user_id,
access_token,
api_base_url,
generation,
identity_generation,
revision,
)
} else {
crate::install_platform_session_checked(
user_id,
access_token,
api_base_url,
generation,
identity_generation,
revision,
)
}
}
(None, None, None, Some(generation)) => {
(None, None, None, Some(identity_generation), Some(revision)) => {
if replace_claim {
crate::clear_platform_session_for_gui_owner(generation);
crate::clear_platform_session_for_gui_owner(identity_generation, revision);
Ok(())
} else {
crate::clear_platform_session_checked(generation)
crate::clear_platform_session_checked(identity_generation, revision)
}
}
(None, None, None, None) if replace_claim => {
crate::clear_platform_session_for_gui_owner(0);
(None, None, None, None, None) if replace_claim => {
crate::clear_platform_session_for_gui_owner(0, 0);
Ok(())
}
(None, None, None, None) => Ok(()),
(None, None, None, None, None) => Ok(()),
_ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()),
};
result?;
@@ -171,7 +180,7 @@ fn apply_external_agent_runner_gui_owner_attachment(
Ok(claim) => claim,
Err(error) => {
*active_claim = None;
crate::clear_platform_session_for_gui_owner(0);
crate::clear_platform_session_for_gui_owner(0, 0);
return Err(format!(
"Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}"
));
@@ -181,7 +190,7 @@ fn apply_external_agent_runner_gui_owner_attachment(
|| committed_claim.session_revision != requested_revision
{
*active_claim = None;
crate::clear_platform_session_for_gui_owner(0);
crate::clear_platform_session_for_gui_owner(0, 0);
return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string());
}
if let Some(event_sink) = event_sink {
@@ -207,7 +216,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current(
return Ok(());
}
*active_claim = None;
crate::clear_platform_session_for_gui_owner(0);
crate::clear_platform_session_for_gui_owner(0, 0);
match durable_claim {
Ok(_) => Err(
"authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离"
@@ -280,6 +280,10 @@ pub(super) struct ExternalAgentRunnerRequestParams {
pub(super) platform_api_base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_auth_generation: Option<u64>,
/// 原生写入 revision:只用于 install / clear 的顺序判定。同一身份的凭据轮换会推进
/// revision,但不推进 `platform_auth_generation`(身份代次)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_auth_revision: Option<u64>,
}
#[derive(Deserialize, Serialize)]
@@ -668,14 +668,16 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() {
&state,
Some(("user-a", "token-a", "https://dev.genarrative.world")),
4,
4,
)
.expect("remember owner A session");
remember_external_agent_runner_platform_session(&state, None, 5)
remember_external_agent_runner_platform_session(&state, None, 5, 5)
.expect("remember logged-out session");
remember_external_agent_runner_platform_session(
&state,
Some(("user-a", "late-token-a", "https://dev.genarrative.world")),
4,
4,
)
.expect("ignore stale owner A session");
remember_external_agent_runner_platform_session(
@@ -686,12 +688,14 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() {
"https://dev.genarrative.world",
)),
5,
5,
)
.expect("ignore conflicting same-generation session");
remember_external_agent_runner_platform_session(
&state,
Some(("user-b", "token-b", "https://dev.genarrative.world")),
6,
6,
)
.expect("remember latest owner B session");
@@ -732,6 +736,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() {
platform_access_token: Some("token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -752,7 +757,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() {
)
.expect("attach owner A");
remember_external_agent_runner_platform_session(&state, None, 2)
remember_external_agent_runner_platform_session(&state, None, 2, 2)
.expect("remember logged-out session");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
@@ -781,6 +786,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() {
platform_access_token: Some("token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -800,6 +806,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() {
&state,
Some(("user-b", "token-b", "https://dev.genarrative.world")),
2,
2,
)
.expect("remember owner B while owner A attach is in flight");
Ok(())
@@ -844,6 +851,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() {
gui_owner_epoch: Some(owner.owner_epoch().to_string()),
gui_owner_session_revision: Some(0),
platform_auth_generation: Some(2),
platform_auth_revision: Some(2),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -875,6 +883,7 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() {
platform_user_id: Some("runner-owner-b".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(2),
platform_auth_revision: Some(2),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -908,6 +917,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(10),
platform_auth_revision: Some(10),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -925,12 +935,14 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old
platform_access_token: Some("runner-token-b".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
.expect("new GUI epoch replaces higher-generation old owner");
assert_eq!(
crate::current_platform_session().map(|session| (session.user_id, session.generation)),
crate::current_platform_session()
.map(|session| (session.user_id, session.identity_generation)),
Some(("runner-owner-b".to_string(), 1))
);
@@ -943,6 +955,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(11),
platform_auth_revision: Some(11),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -979,6 +992,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(8),
platform_auth_revision: Some(8),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -1002,6 +1016,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_
platform_access_token: Some("runner-token-b".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -1009,7 +1024,8 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_
validate_external_agent_runner_gui_owner_claim_current(&state)
.expect("reattached owner B claim is current");
assert_eq!(
crate::current_platform_session().map(|session| (session.user_id, session.generation)),
crate::current_platform_session()
.map(|session| (session.user_id, session.identity_generation)),
Some(("runner-owner-b".to_string(), 1))
);
}
@@ -1053,6 +1069,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() {
platform_access_token: Some("runner-token-a".to_string()),
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
platform_auth_generation: Some(1),
platform_auth_revision: Some(1),
..ExternalAgentRunnerRequestParams::default()
},
)
@@ -1069,6 +1086,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() {
"https://dev.genarrative.world",
)),
2,
2,
|_, _, _| Err("injected durable claim write failure".to_string()),
)
},
@@ -1,5 +1,73 @@
use super::*;
#[test]
fn legacy_official_config_gains_hand_editable_connection_keys_on_startup() {
// 用户现有配置文件(官方路由、连接字段已被清掉)在启动迁移路径上必须补齐
// 开关、模型列表与连接四要素,手写自定义连接时能看到完整字段。
let mut config: GameCreatorAppConfigFile = serde_json::from_str(
r#"{"schemaVersion":"game-creator-config.v2","agentMode":"codex_app_server","llm":{"reasoningEffort":"max","stream":true},"selectedModelId":"quality","selectedModelIsDefault":true}"#,
)
.unwrap();
assert!(ensure_game_creator_custom_llm_file_fields(&mut config));
assert!(scrub_locked_game_creator_config_file(&mut config));
let migrated: serde_json::Value = serde_json::to_value(&config).unwrap();
assert_eq!(migrated["llm"]["customEnabled"], false);
assert_eq!(migrated["llm"]["visibleModels"], serde_json::json!([]));
assert_eq!(migrated["llm"]["apiKey"], "");
assert_eq!(migrated["llm"]["baseUrl"], OFFICIAL_LLM_ROUTER_BASE_URL);
assert_eq!(migrated["llm"]["model"], "quality");
assert_eq!(
migrated["llm"]["apiKind"],
DEFAULT_GAME_CREATOR_LLM_API_KIND
);
assert_eq!(migrated["llm"]["reasoningEffort"], "max");
}
#[test]
fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials() {
let root = unique_project_path();
fs::create_dir_all(&root).unwrap();
let _guard = use_test_runtime_config_dir(root.clone());
let primary = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
write_game_creator_config_atomically(
&primary,
&serde_json::to_string(&GameCreatorAppConfig::default()).unwrap(),
)
.unwrap();
write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap();
let mut config = read_game_creator_app_config().unwrap().config;
assert_eq!(config.llm.model, "a/v1");
let selected = select_game_creator_model("b:v2".into(), false).unwrap();
assert_eq!(selected.config.llm.model, "b:v2");
assert!(select_game_creator_model("not-listed".into(), false).is_err());
config.llm.visible_models = vec!["b:v2".into()];
config.llm.api_key = "changed-fixture-key".into();
write_game_creator_app_config(config).unwrap();
let reloaded = read_game_creator_app_config().unwrap().config;
assert!(reloaded.llm.custom_enabled);
assert_eq!(reloaded.llm.visible_models, ["b:v2"]);
assert_eq!(reloaded.llm.model, "b:v2");
assert_eq!(reloaded.llm.api_key, "changed-fixture-key");
let persisted: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&primary).unwrap()).unwrap();
for key in [
"customEnabled",
"visibleModels",
"apiKey",
"baseUrl",
"model",
"apiKind",
"reasoningEffort",
] {
assert!(
persisted["llm"].get(key).is_some(),
"本地配置始终保留 {key},方便手写自定义连接:{persisted}"
);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn config_file_overrides_defaults_without_env() {
let root = unique_project_path();
@@ -313,10 +381,14 @@ fn locked_config_scrub_removes_all_legacy_provider_credentials() {
.llm
.as_ref()
.expect("global llm remains as non-sensitive tuning");
assert!(llm.api_key.is_none());
assert!(llm.base_url.is_none());
assert!(llm.model.is_none());
assert!(llm.api_kind.is_none());
// 连接字段保留在文件里(空 Key + 官方地址),便于手写自定义连接时对照。
assert_eq!(llm.api_key.as_deref(), Some(""));
assert_eq!(llm.base_url.as_deref(), Some(OFFICIAL_LLM_ROUTER_BASE_URL));
assert_eq!(
llm.api_kind.as_deref(),
Some(DEFAULT_GAME_CREATOR_LLM_API_KIND)
);
assert!(llm.model.is_some());
let serialized = serde_json::to_string(&config).expect("serialize scrubbed config");
assert!(!serialized.contains("legacy-global-key"));
assert!(!serialized.contains("legacy-agent-key"));
@@ -688,6 +760,8 @@ fn app_config_commands_write_runtime_config_file() {
agent_llm.insert(
" planner ".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some(" planner-key ".to_string()),
base_url: Some(" https://planner.example.test/v1 ".to_string()),
model: Some(" planner-model ".to_string()),
@@ -709,6 +783,8 @@ fn app_config_commands_write_runtime_config_file() {
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
llm: GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: " unit-test-key ".to_string(),
base_url: " https://runtime.example.test/v1 ".to_string(),
model: " runtime-model ".to_string(),
@@ -838,7 +914,7 @@ fn app_config_save_updates_conflicting_local_overlay() {
fs::create_dir_all(&root).expect("config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
fs::write(
write_game_creator_config_atomically(
&overlay_path,
r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#,
)
@@ -871,7 +947,7 @@ fn app_config_model_selection_only_updates_model_overlay() {
fs::create_dir_all(&root).expect("config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
fs::write(
write_game_creator_config_atomically(
&overlay_path,
r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#,
)
@@ -5885,6 +5885,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
agent_llm.insert(
"planner".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some("planner-key".to_string()),
base_url: Some(planner_base_url),
model: Some("planner-model".to_string()),
@@ -5903,6 +5905,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
agent_llm.insert(
"generator".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some("generator-key".to_string()),
base_url: Some(generator_base_url),
model: Some("generator-model".to_string()),
@@ -5921,6 +5925,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
agent_llm.insert(
"art-asset-plan".to_string(),
GameCreatorLlmConfigFile {
custom_enabled: None,
visible_models: None,
api_key: Some("art-key".to_string()),
base_url: Some(art_base_url),
model: Some("art-model".to_string()),
@@ -1494,7 +1494,7 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion()
assert!(prompt_input.contains("只删除项目内普通文件"));
let verification_request = receiver
.recv_timeout(Duration::from_secs(2))
.recv_timeout(Duration::from_secs(10))
.expect("verification plan request");
assert!(verification_request.contains("file.delete"));
assert!(verification_request.contains("已删除 game/obsolete-runtime-file.txt"));
@@ -172,7 +172,6 @@ mod materialize {
use crate::ui_editor::layout::node::{
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
@@ -303,7 +302,6 @@ mod materialize {
component: None,
children_display_mode: ChildrenDisplayMode::Exclusive,
children: members.into_iter().map(|member| member.node).collect(),
offset: NodeOffset::default(),
},
priority,
src_ui_design,
@@ -9,7 +9,6 @@ use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::dimension::UIRect;
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::{State, UITree};
@@ -323,7 +322,6 @@ fn convert_node(
component: source.component.clone().into_option(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
})
}
@@ -860,7 +858,6 @@ pub(crate) async fn recognize_ui_impl_with_provider(
component: recognition_root.component.into_option(),
children_display_mode: ChildrenDisplayMode::Stack,
children,
offset: NodeOffset::default(),
};
// Tree identity and root identity are assigned by Rust, never chosen by the model.
ui_trees.push(UITree {
@@ -2,5 +2,4 @@ pub mod children_display_mode;
pub mod control_layout;
pub mod dimension;
pub mod node;
pub mod offset;
pub mod transform;
@@ -1,7 +1,6 @@
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::offset::NodeOffset;
use crate::ui_editor::utils::NodeId;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -15,7 +14,6 @@ pub struct Node {
pub component: Option<Component>,
pub children_display_mode: ChildrenDisplayMode,
pub children: Vec<Node>,
pub offset: NodeOffset,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]

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