修复切换账号后画布资源不可编辑
Project CI / Repository checks (pull_request) Successful in 3m42s
Project CI / Frontend tests (pull_request) Successful in 3m23s
Project CI / Native shell tests (pull_request) Failing after 10m22s
Project CI / Backend tests (pull_request) Successful in 4m34s

新增账号隔离的 External Editor 项目与资源绑定
切换账号后从本地资源重新上传并登记
保留原账号在途生成与资源编辑归属
补充跨账号回归测试和项目文档
This commit is contained in:
2026-08-23 19:19:40 +08:00
parent b1ee6d816d
commit 29894de43d
11 changed files with 3004 additions and 478 deletions
@@ -565,21 +565,70 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
root: &Path,
art_spec: &DirectTaonierArtAssetIdentity,
) -> Result<bool, String> {
let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?;
let (api_base_url, api_key, frozen_platform_session) =
resolve_canvas_sync_api_credentials(None, None)?;
let access = ExternalEditorBindingAccess::new(
&api_base_url,
&api_key,
frozen_platform_session.as_ref(),
)?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
let source_asset = manifest
.assets
.iter()
.find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH)
.ok_or_else(|| "陶泥儿规范图 manifest 记录缺失".to_string())?;
let source_path = resolve_local_project_path(root, &source_asset.local_path)?;
let source_bytes =
fs::read(source_path).map_err(|error| format!("读取陶泥儿规范图失败:{error}"))?;
let source_identity = new_external_editor_source_identity(
&source_asset.id,
&format!("{:x}", Sha256::digest(&source_bytes)),
&source_asset.media_type,
&source_asset.kind,
)?;
let principal = external_editor_binding_principal(&access)?;
let Some(project_binding) =
read_external_editor_project_binding_at(root, &manifest.project_id, &principal)?
else {
// 当前账号从未建立远端项目。后续正式生成路径会从本地规范图重新登记;这里不得
// 用当前 token 查询 manifest 中历史账号的 canvasProjectId。
return Ok(false);
};
let Some(spec_binding) = read_external_editor_resource_binding_at(
root,
&manifest.project_id,
&principal,
&project_binding.remote_project_id,
&source_identity,
)?
else {
return Ok(false);
};
let Some(remote_spec_resource_id) = spec_binding.remote_resource_id.clone() else {
return Ok(false);
};
let remote_art_spec = DirectTaonierArtAssetIdentity {
resource_id: remote_spec_resource_id,
asset_object_id: spec_binding.asset_object_id,
canvas_project_id: project_binding.remote_project_id,
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?;
let route = resolve_platform_editor_api_route(&format!(
let route = access.api_route(&format!(
"/api/external/v1/editor/projects/{}",
percent_encode_query_component(&art_spec.canvas_project_id)
percent_encode_query_component(&remote_art_spec.canvas_project_id)
));
access.validate_frozen_session()?;
let response = client
.get(format!("{api_base_url}{route}"))
.bearer_auth(&api_key)
.send()
.await
.map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?;
access.validate_frozen_session()?;
let status = response.status();
if !status.is_success() {
return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16()));
@@ -596,7 +645,8 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
.get("resources")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "陶泥儿画布资源响应缺少 resources".to_string())?;
let Some(resource) = direct_recoverable_spritesheet_resource(resources, art_spec)? else {
let Some(resource) = direct_recoverable_spritesheet_resource(resources, &remote_art_spec)?
else {
return Ok(false);
};
let resource_id = json_string_field(resource, "resourceId")
@@ -608,6 +658,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource)
.await?
.ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?;
access.validate_frozen_session()?;
let validated = validate_platform_art_png_bytes_with_limits(
&download.bytes,
"恢复的陶泥儿 art-spritesheet",
@@ -626,7 +677,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
"/api/external/v1/editor/images/generations",
"spec",
&[],
Some(&art_spec.canvas_project_id),
None,
)
.ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?;
if current_art_spec != *art_spec {
@@ -661,9 +712,9 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
"platform-art-recovery",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(art_spec.canvas_project_id.clone()),
canvas_project_id: Some(remote_art_spec.canvas_project_id.clone()),
resource_id: Some(resource_id),
asset_object_id: Some(asset_object_id),
asset_object_id: Some(asset_object_id.clone()),
task_id: Some(task_id),
prompt: json_string_field(resource, "actualPrompt")
.or_else(|| json_string_field(resource, "prompt")),
@@ -672,12 +723,36 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
"/api/external/v1/editor/icon-spritesheets/generations".to_string(),
),
generation_kind: Some("icon-spritesheet".to_string()),
reference_resource_ids: vec![art_spec.resource_id.clone()],
reference_resource_ids: vec![remote_art_spec.resource_id.clone()],
},
);
if let Err(error) = register {
let _ = std::fs::remove_file(&output);
return Err(error);
let registered_asset = match register {
Ok(registered_asset) => registered_asset,
Err(error) => {
let _ = std::fs::remove_file(&output);
return Err(error);
}
};
if let Some(object_key) = json_string_field(resource, "objectKey") {
let recovered_source = new_external_editor_source_identity(
&registered_asset.id,
&validated.content_sha256,
"image/png",
"art-spritesheet",
)?;
let recovered_binding = new_external_editor_resource_binding(
&manifest.project_id,
&principal,
&remote_art_spec.canvas_project_id,
&recovered_source,
json_string_field(resource, "resourceId").as_deref(),
&object_key,
&asset_object_id,
Some(validated.width),
Some(validated.height),
unix_timestamp(),
)?;
write_external_editor_resource_binding_at(root, &recovered_binding)?;
}
emit_direct_game_creator_progress(
root,
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -237,6 +237,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::*;
@@ -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::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -14355,3 +14355,12 @@
- 素材生成确认、离开确认和旧服务身份确认沿用现有独立 modal,不在当前面板下方追加内容。modal 打开后焦点必须进入对话框,并同时隔离工具栏、画布视口、缩放/小地图、状态区和并存操作层,使背景从键盘焦点顺序与 accessibility tree 中退出。
- `Tab` / `Shift+Tab` 必须在当前 modal 内双向循环;非异步 pending 状态允许 `Escape` 安全关闭。关闭后优先恢复到原触发器,自动弹出的 modal 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。
## 2026-08-23 AGC 本地资源与 External Editor 账号绑定分离
- 权威边界:本地项目 ID、manifest asset、正式本地文件和内容摘要属于设备上的本地项目;`canvasProjectId / resourceId / assetObjectId / objectKey` 属于具体 External Editor 服务 principal。manifest 中现有远端字段继续保留生成来源,不再承担“当前账号可编辑句柄”,本轮不修改共享 manifest schema。
- 私有投影:统一在 `.agent/runtime/external-editor-bindings/` 持久化 active binding。项目 binding 按服务 origin、平台 userId 或 Developer Key 摘要以及本地 `projectId` 分区;资源 binding 再绑定远端项目、本地 asset ID、源 SHA-256、媒体类型和 canonical asset kind。sidecar 不保存 Token、Key、Cookie、Authorization、签名上传表单、媒体正文或绝对路径。
- 切号语义:账号 B 打开账号 A 曾生成资源的同一本地项目时,不访问、迁移或覆盖 A 的私有画布;B 从本地正式文件在自己的远端项目重新上传、confirm、登记并形成独立 binding。切回 A 时复用 A binding。项目标题只用于远端展示,不能作为同名项目的权威关联;项目改名不换 binding。
- 在途边界:资源编辑、素材画布生成和 Agent 美术生成的 `prepared / accepted / running` 账本继续锁定发起 principal。账号切换后只能停止请求并保留原 operation 供切回或对账,不得把在途副作用迁移到新账号,也不得重建正文或重复 POST。只有尚未提交远端生成的新操作可以为当前账号建立新的项目和资源 binding。
- 集成边界:`resource_editor`、素材画布参考准备、`canvas.asset_generate` 的 art-spec 派生和直连只读恢复统一消费当前 principal bindingPR 176 rebase 后必须删除或整合其局部 canonical cache,不能保留第二套账号身份系统。External v1 路由和公开 DTO 不变,无需修改 OpenAPI。
- 验收:至少覆盖 A 生成到本地后切 B 重登记、B 请求零 A 远端 ID、重启后复用 B、切回 A 复用 A、两个同名本地项目隔离、项目改名不漂移、源 SHA 或 kind 变化重登记、A 在途任务切 B 零网络,以及 sidecar 零凭据和身份篡改失败关闭。
@@ -4839,3 +4839,11 @@
- 原因:当前完整模块 init 的 RSS 会超过基础 Compose 旧 `896m` cgroup 上限;内核 OOM kill SpacetimeDB,客户端只看到传输错误,容易被误判为网络竞态。
- 处理:先查 kernel journal 的 `Memory cgroup out of memory` 和目标容器 ID,再把本地/预发完整容器 SpacetimeDB 上限统一为 `2g`;保留 page pool 限制。不要只增加 publish 重试,也不要把 `/healthz` 或首页改成数据库就绪探针。
- 验证:用新空卷完成模块 publish、五服务启动和 Web/API smoke,并确认容器未 OOM、SpacetimeDB 与 API/Nginx 最终 healthy。
## 同一本地项目切换账号后不能继续信任 manifest 远端 ID2026-08-23
- 现象:本地资源文件仍存在,但账号 A 生成后切到账号 B,快速编辑、GIF/视频派生、art-spec 下游生成或直连恢复提示画布不存在、无权限或资源不属于当前项目;原请求重试仍失败。
- 原因:本地 manifest 只有一组 `source.canvasProjectId / resourceId / assetObjectId`,旧实现把它同时当作历史来源和当前账号可编辑引用。新的 operation 虽已绑定 B,却会把 A 的 project/resource/object 身份配合 B Token 发出;另一部分链路又把 A 的画布 ID 与 B 按标题选出的项目严格比较。
- 处理:manifest 远端字段只保留历史 provenance。所有远端编辑和派生先解析 `.agent/runtime/external-editor-bindings/` 中当前 principal 的项目/资源 binding;不存在时从本地正式文件按 asset ID、SHA-256、媒体类型和 canonical kind 在当前账号重登记。禁止按标题、manifest ID 或其他账号 committed ledger 自动采纳远端对象。A 的在途 operation 继续留在 A,B 只能开始自己的新操作。
- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Developer Key fixture 不能替代平台账号隔离证据。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs``project/resource_editor.rs``project/asset_canvas/generation.rs``agent/generation/canvas_generation.rs``agent/direct_runtime.rs`
@@ -395,7 +395,9 @@ Agent Runtime 负责:
- 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpointRuntime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。
- 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls``lastResponse`,让多轮任务不丢失结构化行动证据。
- 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。
- 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*``/api/assets/*``/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png``design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png``art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec``ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。
- 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*``/api/assets/*``/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按当前服务 principal 与本地 `projectId` 的私有 binding 创建或复用远端画布项目和素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png``design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png``art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec``ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。
- External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json``source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。
- 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。
- 2026-08-03 durable 恢复补充,2026-08-04 扩展 `prepared``prepared / accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源;`prepared` 只原样恢复同一逻辑 POST`accepted` 只查询既有 operation。不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住恢复。显式“继续/恢复”复用原 action、pending、provider batch 与生成账本身份,不创建 successor 请求或新幂等键。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。
- 生成账本必须绑定调用模式与服务身份:普通模式为固定官方 origin + ownerUserId,高级 External v1 模式为显式 service originAccess Token 或 Developer Key 只负责当前请求授权,轮换不得改变已有 operation 的归属。退出或换号提升单调账号 generation,中止并脱离旧请求;旧 owner 账本在新 owner 下禁止 POST、GET、下载、安装和归档,只有重新登录同一 owner 后才能恢复。绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。首次请求结果已经未知后,鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。
- 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。