撤销画布 Agent 图片编辑门禁改动

撤销基于调用来源全面绕过图片编辑素材白名单的实现。

为改用普通图片空素材类型及有限历史兼容恢复基线。
This commit is contained in:
2026-08-06 15:53:45 +08:00
parent 0ab46d8c8b
commit e35429bb88
5 changed files with 24 additions and 114 deletions
@@ -6499,7 +6499,6 @@
- 背景:画布生成结果统一显示快速编辑,但部分角色动作 / 序列帧和音频结果会进入不受支持的图片编辑链路;不同入口各自判断时也容易继续漂移。
- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频。单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁,视频快速编辑只走视频生成接口。
- 边界:角色动作继续通过对应的动作生成链路处理,不再把当前帧当作可快速编辑图片。
- 2026-08-06 修订:画布 Agent 的 `edit_image` 是服务端确认后生成的独立内部工具调用,不属于画布快速编辑入口。worker 以服务端生成的 `editor-agent:` 队列 dedupe namespace 恢复可信调用来源并允许其无 `targetLayerId` 执行;普通画布与 External API 调用继续按真实目标图层失败关闭,不能通过伪造 `assetKind="editor_agent_edit_image"``generationInputs.source` 绕过白名单。
- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标和角色动作绕过入口时仍拒绝;后端表驱动测试覆盖全部现役素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``ImageCanvasSelectedLayerToolbarView.tsx``ImageCanvasContextMenusView.tsx``useImageCanvasGenerationWorkflow.ts``useImageCanvasGenerationSubmissionWorkflow.ts`
File diff suppressed because one or more lines are too long
@@ -449,18 +449,11 @@ pub struct EditorUiDesignAssetExtractionRequest {
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EditorGenerationCallerKind {
Api,
EditorAgent,
}
pub(crate) struct EditorGenerationCaller {
pub(crate) owner_user_id: String,
pub(crate) audit_subject_user_id: Option<String>,
pub(crate) audit_project_id: Option<String>,
pub(crate) phase_reporter: Option<EditorGenerationPhaseReporter>,
pub(crate) kind: EditorGenerationCallerKind,
}
impl EditorGenerationCaller {
@@ -471,7 +464,6 @@ impl EditorGenerationCaller {
owner_user_id,
audit_project_id: None,
phase_reporter: None,
kind: EditorGenerationCallerKind::Api,
}
}
@@ -2878,17 +2870,6 @@ fn ensure_editor_image_edit_source_kind_allowed(
)
}
pub(crate) fn ensure_editor_image_edit_source_kind_allowed_for_caller(
caller_kind: EditorGenerationCallerKind,
asset_kind: Option<&str>,
media_type: Option<&str>,
) -> Result<(), AppError> {
if caller_kind == EditorGenerationCallerKind::EditorAgent {
return Ok(());
}
ensure_editor_image_edit_source_kind_allowed(asset_kind, media_type)
}
fn resolve_editor_image_edit_target_layer_source_kind<'a>(
layers: &'a Value,
resources: &'a [EditorProjectResourcePayload],
@@ -2949,7 +2930,6 @@ fn resolve_editor_image_edit_target_layer_source_kind<'a>(
async fn ensure_editor_image_edit_source_allowed(
state: &AppState,
owner_user_id: &str,
caller_kind: EditorGenerationCallerKind,
payload: &EditorImageEditRequest,
) -> Result<(), AppError> {
let Some(project_id) = payload
@@ -2958,11 +2938,7 @@ async fn ensure_editor_image_edit_source_allowed(
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_source_kind_allowed_for_caller(
caller_kind,
payload.asset_kind.as_deref(),
None,
);
return ensure_editor_image_edit_source_kind_allowed(payload.asset_kind.as_deref(), None);
};
let Some(target_layer_id) = payload
.target_layer_id
@@ -2970,11 +2946,7 @@ async fn ensure_editor_image_edit_source_allowed(
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_source_kind_allowed_for_caller(
caller_kind,
payload.asset_kind.as_deref(),
None,
);
return ensure_editor_image_edit_source_kind_allowed(payload.asset_kind.as_deref(), None);
};
let project = state
.spacetime_client()
@@ -2990,8 +2962,7 @@ async fn ensure_editor_image_edit_source_allowed(
&project.resources,
target_layer_id,
)?;
ensure_editor_image_edit_source_kind_allowed_for_caller(
caller_kind,
ensure_editor_image_edit_source_kind_allowed(
asset_kind.or(payload.asset_kind.as_deref()),
media_type,
)
@@ -4171,13 +4142,7 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner(
"referenceImageSrcs",
"修改参考图",
)?;
ensure_editor_image_edit_source_allowed(
state,
caller.owner_user_id.as_str(),
caller.kind,
&payload,
)
.await?;
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
@@ -4239,13 +4204,7 @@ pub(crate) async fn edit_editor_image_for_owner(
"referenceImageSrcs",
"修改参考图",
)?;
ensure_editor_image_edit_source_allowed(
state,
caller.owner_user_id.as_str(),
caller.kind,
&payload,
)
.await?;
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
return Err(
@@ -31,9 +31,9 @@ use crate::{
EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE,
EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload,
EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload,
EditorGenerationCaller, EditorGenerationCallerKind, EditorIconSpritesheetGenerationRequest,
EditorImageEditRequest, EditorImageGenerationRequest, EditorProjectPayload,
EditorProjectResourcePayload, EditorUiDesignAssetExtractionRequest, current_utc_micros,
EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest,
EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload,
EditorUiDesignAssetExtractionRequest, current_utc_micros,
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
editor_asset_payload_from_record, editor_project_payload_from_record,
editor_project_resource_payload_from_record,
@@ -955,7 +955,6 @@ fn editor_generation_caller(
audit_subject_user_id: Some(principal.owner_user_id().to_string()),
audit_project_id: normalize_optional_string(project_id),
phase_reporter: None,
kind: EditorGenerationCallerKind::Api,
}
}
@@ -41,7 +41,7 @@ use crate::{
},
editor_project::{
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest,
EditorGenerationCaller, EditorGenerationCallerKind, EditorGenerationPhaseReporter,
EditorGenerationCaller, EditorGenerationPhaseReporter,
EditorIconSpritesheetGenerationRequest, EditorImageEditRequest,
EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest,
edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner,
@@ -794,9 +794,12 @@ async fn process_external_generation_job_once(
}
}
EDITOR_IMAGE_EDIT_JOB_KIND => {
let payload = match parse_editor_image_edit_worker_payload(&job) {
let payload = match serde_json::from_str::<EditorImageEditRequest>(
job.request_payload_json.as_str(),
) {
Ok(payload) => payload,
Err(message) => {
Err(error) => {
let message = format!("图片画布改图任务参数解析失败:{error}");
fail_job(&state, &worker_id, &job, message.clone()).await?;
return Err(message);
}
@@ -1138,13 +1141,6 @@ fn worker_request_context(
.with_external_call_deadline(provider_deadline)
}
fn parse_editor_image_edit_worker_payload(
job: &ExternalGenerationJobRecord,
) -> Result<EditorImageEditRequest, String> {
serde_json::from_str(job.request_payload_json.as_str())
.map_err(|error| format!("图片画布改图任务参数解析失败:{error}"))
}
fn editor_generation_worker_caller(
worker_id: &str,
job: &ExternalGenerationJobRecord,
@@ -1154,11 +1150,6 @@ fn editor_generation_worker_caller(
audit_subject_user_id: Some(job.owner_user_id.clone()),
audit_project_id: Some(job.source_entity_id.clone()),
phase_reporter: Some(editor_generation_phase_reporter(worker_id, job)?),
kind: if is_editor_agent_generation_job(job) {
EditorGenerationCallerKind::EditorAgent
} else {
EditorGenerationCallerKind::Api
},
})
}
@@ -1245,7 +1236,15 @@ fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool {
}
fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool {
job.dedupe_key.trim().starts_with("editor-agent:")
serde_json::from_str::<Value>(job.request_payload_json.as_str())
.ok()
.is_some_and(|payload| {
payload
.pointer("/generationInputs/source")
.and_then(Value::as_str)
// TODO extract const
.is_some_and(|source| source.trim() == "editor-agent")
})
}
fn compact_editor_generation_result(mut result: Value) -> Value {
@@ -1751,50 +1750,6 @@ mod tests {
assert!(caller.phase_reporter.is_some());
}
#[test]
fn editor_agent_image_edit_payload_reaches_worker_gate_as_trusted_caller() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string();
job.dedupe_key = "editor-agent:conversation-1:7:edit-image".to_string();
job.request_payload_json = json!({
"prompt": "改成蓝色",
"sourceImageSrc": "users/user-1/source.png",
"projectId": "project-1",
"assetKind": "editor_agent_edit_image",
"generationInputs": { "source": "editor-agent" }
})
.to_string();
let payload = parse_editor_image_edit_worker_payload(&job)
.expect("Agent 图片编辑 payload 应由 worker 解析");
let caller = editor_generation_worker_caller("worker-a", &job)
.expect("Agent 图片编辑任务应恢复可信调用来源");
assert_eq!(caller.kind, EditorGenerationCallerKind::EditorAgent);
assert!(
crate::editor_project::ensure_editor_image_edit_source_kind_allowed_for_caller(
caller.kind,
payload.asset_kind.as_deref(),
None,
)
.is_ok()
);
job.dedupe_key = "editor:image-edit:project-1:request-1".to_string();
let untrusted_caller = editor_generation_worker_caller("worker-a", &job)
.expect("普通画布任务应恢复 API 调用来源");
assert_eq!(untrusted_caller.kind, EditorGenerationCallerKind::Api);
assert!(
crate::editor_project::ensure_editor_image_edit_source_kind_allowed_for_caller(
untrusted_caller.kind,
payload.asset_kind.as_deref(),
None,
)
.is_err(),
"相同 payload 不得仅凭 assetKind 或 generationInputs 冒充 Agent"
);
}
#[cfg(any())]
#[test]
fn worker_write_guard_requires_claimed_job_lease_token() {
@@ -2016,7 +1971,6 @@ mod tests {
#[test]
fn editor_agent_result_payload_keeps_compact_response() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "editor-agent:conversation-1:7:generate-image".to_string();
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})
@@ -2118,7 +2072,6 @@ mod tests {
#[test]
fn editor_agent_spritesheet_result_keeps_all_persisted_slices() {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "editor-agent:conversation-1:7:generate-icon-spritesheet".to_string();
job.request_payload_json = json!({
"generationInputs": { "source": "editor-agent" },
})