feat(upload): 支持 Agent 本地图片安全上传
This commit is contained in:
@@ -16,8 +16,9 @@ Use a two-phase plan/apply workflow. Optimize for correct placement, not speed.
|
||||
- Never create a new round until the operator confirms the resolved work and current round.
|
||||
- Never retry a timed-out create request. First inspect current server state; otherwise a retry can create a duplicate round.
|
||||
- Require `externalId` for every agent-created work. Reuse the same value for safe retries.
|
||||
- Accept 1-30 public `http`/`https` image URLs. Preserve their order; the first image is the cover.
|
||||
- Accept either 1-30 public `http`/`https` image URLs or 1-30 local image files. Never mix both modes in one plan. Preserve order; the first image is the cover.
|
||||
- Require an active Tencent COS configuration. URLs already using its public or CDN origin are reused; other public images are downloaded and stored in that COS by the API.
|
||||
- Upload local files as `multipart/form-data` from the bundled script. Never read image bytes into the conversation, print Base64, or put binary data in the plan.
|
||||
- Put API keys only in `DELIVERY_DESK_API_KEY`. Do not paste keys into chat, plans, source files, or command history.
|
||||
- Prefer a project-scoped API key. A platform key has a wider blast radius and always requires explicit group verification.
|
||||
- Stop on any mismatch, ambiguity, changed project/work state, or missing input. Ask the operator instead of guessing.
|
||||
@@ -40,8 +41,9 @@ For both operations, require:
|
||||
- Delivery Desk base URL. Default to `DELIVERY_DESK_BASE_URL` or `http://127.0.0.1:3010` only for local development.
|
||||
- A valid API key in `DELIVERY_DESK_API_KEY`.
|
||||
- Exact target project, resolved to group ID/name and project ID/name/slug.
|
||||
- Ordered public image URLs.
|
||||
- Confirmation that the source URLs are reachable until the API finishes importing them. After a cross-origin import succeeds, Delivery Desk uses the resulting COS URL.
|
||||
- Ordered public image URLs or ordered local image paths.
|
||||
- For URL mode, confirmation that source URLs remain reachable until import finishes.
|
||||
- For file mode, confirmation that files remain unchanged until apply. The plan records absolute path, byte size, SHA-256, and content type; it never records image bytes.
|
||||
|
||||
For `create_work`, also require:
|
||||
|
||||
@@ -102,6 +104,20 @@ python $skillScript plan-round `
|
||||
--output tmp/delivery-plan.json
|
||||
```
|
||||
|
||||
Use local files instead of URLs:
|
||||
|
||||
```powershell
|
||||
python $skillScript plan-work `
|
||||
--project-id 12 `
|
||||
--external-id client-2026-002 `
|
||||
--title "本地生成作品" `
|
||||
--image-file "D:\generated\01.png" `
|
||||
--image-file "D:\generated\02.png" `
|
||||
--output tmp/delivery-plan.json
|
||||
```
|
||||
|
||||
Use repeated `--image-url` or repeated `--image-file`, never both. Local files must be JPEG, PNG, GIF, WebP, or AVIF, each no larger than 20 MB.
|
||||
|
||||
Optional `plan-round` content flags:
|
||||
|
||||
- `--title`, `--description`, and repeated `--tag` replace current values.
|
||||
@@ -120,7 +136,7 @@ Show the plan summary exactly, including:
|
||||
- Work ID and current/next round for `create_round`.
|
||||
- `externalId` for `create_work`.
|
||||
- Title, complete description, complete tag list.
|
||||
- Ordered image URLs with `1` marked as the cover.
|
||||
- Ordered image URLs, or local file path/name/size/SHA-256, with `1` marked as the cover.
|
||||
- Confirmation code.
|
||||
|
||||
Ask: `确认按以上目标和内容执行吗?请回复“确认 <confirmation_code>”。`
|
||||
@@ -137,7 +153,7 @@ python $skillScript apply `
|
||||
--confirm ABCD1234EF56
|
||||
```
|
||||
|
||||
The script re-fetches the project/work, checks for state drift, performs one POST, and reads the created resource back. Treat only a successful verification result as complete.
|
||||
The script re-fetches the project/work, checks for state drift, revalidates local file hashes when applicable, performs one POST, and reads the created resource back. Local files are streamed by the script as multipart and never enter model context. Treat only a successful verification result as complete.
|
||||
|
||||
Report group, project, work ID, `externalId`, created round number, title, image count, and whether the server returned an existing idempotent work.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Returns current work content, project identity, images, and rounds. Use `?round=
|
||||
|
||||
### `POST /api/projects/:projectId/works`
|
||||
|
||||
JSON body:
|
||||
URL mode uses a JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -72,6 +72,15 @@ Constraints:
|
||||
- Cross-origin images must be supported image responses no larger than 20 MB. Local, private, reserved, and non-standard-port targets are rejected.
|
||||
- Repeating the same `projectId + externalId` returns the existing work with `idempotent: true`.
|
||||
|
||||
Local-file mode uses `multipart/form-data` with text fields `externalId`, `title`, `description`, `tags` and repeated file field `images`.
|
||||
|
||||
- Send `tags` as a JSON array string.
|
||||
- Send 1-30 JPEG, PNG, GIF, WebP, or AVIF files, each no larger than 20 MB.
|
||||
- File order is display order; file 1 is the cover.
|
||||
- The server validates actual image content instead of trusting only the filename or declared MIME.
|
||||
- With active Tencent COS configuration, accepted files are streamed from server temporary storage into COS.
|
||||
- Do not send Base64 in JSON. The agent script reads local files directly into the multipart request; binary bytes never belong in the plan or conversation.
|
||||
|
||||
### `POST /api/works/:workId/rounds`
|
||||
|
||||
JSON body:
|
||||
@@ -87,6 +96,7 @@ JSON body:
|
||||
|
||||
This endpoint is not idempotent. One successful call creates exactly one new round. Never blindly retry after a timeout.
|
||||
It applies the same COS reuse/import rules as work creation.
|
||||
It also accepts the same local-file multipart fields, except `externalId` is omitted.
|
||||
|
||||
## State guards
|
||||
|
||||
|
||||
@@ -5,16 +5,30 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
MAX_LOCAL_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
LOCAL_IMAGE_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jfif": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".avif": "image/avif",
|
||||
}
|
||||
|
||||
|
||||
class UploadError(RuntimeError):
|
||||
def __init__(self, message: str, *, action: str = "ask_operator", next_step: str = "停止操作,将错误和目标信息告知调用者并等待处理") -> None:
|
||||
@@ -37,7 +51,7 @@ def retry(message: str, next_step: str) -> UploadError:
|
||||
def http_failure(method: str, path: str, status: int, detail: str) -> UploadError:
|
||||
message = f"{method} {path} 返回 {status}: {detail}"
|
||||
if status in {400, 413, 422}:
|
||||
return revise(message, "根据错误修改输入或图片 URL,重新生成计划,并取得新的确认码后再执行")
|
||||
return revise(message, "根据错误修改输入、图片 URL 或本地文件,重新生成计划,并取得新的确认码后再执行")
|
||||
if status == 401:
|
||||
return UploadError(message, next_step="停止操作,请调用者配置或更换有效的 DELIVERY_DESK_API_KEY")
|
||||
if status == 403:
|
||||
@@ -91,6 +105,75 @@ def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, A
|
||||
raise retry(f"无法连接 {base}: {error.reason}", "确认服务地址正确且服务可达后,原样重试当前只读命令") from error
|
||||
|
||||
|
||||
def request_multipart(base: str, path: str, *, fields: dict[str, str], files: list[dict[str, Any]]) -> tuple[int, Any]:
|
||||
boundary = f"delivery-desk-{uuid.uuid4().hex}"
|
||||
parsed = urlsplit(base)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.query or parsed.fragment:
|
||||
raise revise("Delivery Desk base URL 格式无效", "改用有效的 HTTP/HTTPS 服务地址后重新生成计划")
|
||||
target = f"{parsed.path.rstrip('/')}{path}" or "/"
|
||||
|
||||
def write_text(stream: Any, value: str) -> None:
|
||||
stream.write(value.encode("utf-8"))
|
||||
|
||||
with tempfile.TemporaryFile() as body:
|
||||
for name, value in fields.items():
|
||||
write_text(body, f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n")
|
||||
for item in files:
|
||||
filename = str(item["name"]).replace("\\", "_").replace('"', "_")
|
||||
write_text(
|
||||
body,
|
||||
f"--{boundary}\r\nContent-Disposition: form-data; name=\"images\"; filename=\"{filename}\"\r\n"
|
||||
f"Content-Type: {item['content_type']}\r\n\r\n",
|
||||
)
|
||||
digest = hashlib.sha256()
|
||||
copied = 0
|
||||
with Path(str(item["path"])).open("rb") as image:
|
||||
while chunk := image.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
copied += len(chunk)
|
||||
body.write(chunk)
|
||||
if copied != int(item["size"]) or digest.hexdigest() != item["sha256"]:
|
||||
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
|
||||
write_text(body, "\r\n")
|
||||
write_text(body, f"--{boundary}--\r\n")
|
||||
length = body.tell()
|
||||
body.seek(0)
|
||||
|
||||
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
||||
connection = connection_type(parsed.hostname, parsed.port, timeout=120)
|
||||
try:
|
||||
connection.request(
|
||||
"POST",
|
||||
target,
|
||||
body=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key()}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(length),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
payload = json.loads(raw) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
payload = raw
|
||||
if response.status >= 400:
|
||||
detail = payload.get("error", raw) if isinstance(payload, dict) else raw
|
||||
raise http_failure("POST", path, response.status, str(detail))
|
||||
return response.status, payload
|
||||
except UploadError:
|
||||
raise
|
||||
except (OSError, TimeoutError, http.client.HTTPException) as error:
|
||||
raise UploadError(
|
||||
f"state_unknown: POST {path} 的结果未知,禁止自动重试:{error}",
|
||||
next_step="使用 works --external-id 或 inspect-work 只读核对服务器状态;若仍无法确认,询问调用者后再决定",
|
||||
) from error
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def print_json(value: Any) -> None:
|
||||
print(json.dumps(value, ensure_ascii=False, indent=2))
|
||||
|
||||
@@ -131,6 +214,55 @@ def validate_images(values: list[str]) -> list[str]:
|
||||
return cleaned
|
||||
|
||||
|
||||
def describe_image_files(values: list[str]) -> list[dict[str, Any]]:
|
||||
if not 1 <= len(values) <= 30:
|
||||
raise revise("必须提供 1-30 个本地图片文件", "调整图片数量和顺序,重新生成计划并取得新的确认码")
|
||||
result: list[dict[str, Any]] = []
|
||||
for value in values:
|
||||
try:
|
||||
file_path = Path(value).expanduser().resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise revise(f"本地图片不存在或不可读取: {value}", "修正文件路径后重新生成计划") from error
|
||||
if not file_path.is_file():
|
||||
raise revise(f"本地图片不是普通文件: {file_path}", "改用有效图片文件后重新生成计划")
|
||||
size = file_path.stat().st_size
|
||||
if size < 1 or size > MAX_LOCAL_IMAGE_BYTES:
|
||||
raise revise(f"本地图片大小必须在 1 字节到 20 MB 之间: {file_path}", "压缩或替换图片后重新生成计划")
|
||||
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
|
||||
if not content_type:
|
||||
raise revise(f"不支持的本地图片类型: {file_path.name}", "改用 JPEG、PNG、GIF、WebP 或 AVIF 图片后重新生成计划")
|
||||
digest = hashlib.sha256()
|
||||
with file_path.open("rb") as source:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
result.append({
|
||||
"path": str(file_path),
|
||||
"name": file_path.name,
|
||||
"size": size,
|
||||
"sha256": digest.hexdigest(),
|
||||
"content_type": content_type,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def verify_planned_files(images: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for item in images:
|
||||
try:
|
||||
file_path = Path(str(item.get("path", ""))).resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise revise("本地图片在确认后不存在或不可读取,旧计划已失效", "修正路径并重新生成计划") from error
|
||||
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
|
||||
if (
|
||||
not file_path.is_file()
|
||||
or str(file_path) != item.get("path")
|
||||
or file_path.name != item.get("name")
|
||||
or file_path.stat().st_size != item.get("size")
|
||||
or content_type != item.get("content_type")
|
||||
):
|
||||
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
|
||||
return images
|
||||
|
||||
|
||||
def confirmation_code(plan: dict[str, Any]) -> str:
|
||||
material = {key: value for key, value in plan.items() if key != "confirmation_code"}
|
||||
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
@@ -169,11 +301,18 @@ def content_from_args(args: argparse.Namespace, current: dict[str, Any] | None =
|
||||
tags = [] if args.clear_tags else args.tags if args.tags is not None else current.get("tags", [])
|
||||
if not str(title or "").strip():
|
||||
raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码")
|
||||
if args.image_urls:
|
||||
image_source = "url"
|
||||
images: list[Any] = validate_images(args.image_urls)
|
||||
else:
|
||||
image_source = "file"
|
||||
images = describe_image_files(args.image_files or [])
|
||||
return {
|
||||
"title": str(title).strip(),
|
||||
"description": str(description or ""),
|
||||
"tags": [str(tag).strip() for tag in tags if str(tag).strip()],
|
||||
"images": validate_images(args.image_urls),
|
||||
"image_source": image_source,
|
||||
"images": images,
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +345,7 @@ def cmd_plan_work(args: argparse.Namespace) -> None:
|
||||
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id):
|
||||
raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划")
|
||||
plan = {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"operation": "create_work",
|
||||
"base_url": base,
|
||||
"target": project_identity(project),
|
||||
@@ -226,7 +365,7 @@ def cmd_plan_round(args: argparse.Namespace) -> None:
|
||||
rounds = work.get("rounds") or []
|
||||
current_round = max((int(item.get("round_number", 0)) for item in rounds), default=0)
|
||||
plan = {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"operation": "create_round",
|
||||
"base_url": base,
|
||||
"target": project_identity(project),
|
||||
@@ -256,12 +395,33 @@ def same_content(detail: dict[str, Any], expected: dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def upload_content(base: str, path: str, content: dict[str, Any], extra_fields: dict[str, str] | None = None) -> tuple[int, Any]:
|
||||
image_source = str(content.get("image_source") or "url")
|
||||
fields = {
|
||||
"title": str(content["title"]),
|
||||
"description": str(content.get("description", "")),
|
||||
"tags": json.dumps(content.get("tags", []), ensure_ascii=False),
|
||||
**(extra_fields or {}),
|
||||
}
|
||||
if image_source == "file":
|
||||
files = verify_planned_files(content["images"])
|
||||
return request_multipart(base, path, fields=fields, files=files)
|
||||
body = {
|
||||
"title": content["title"],
|
||||
"description": content.get("description", ""),
|
||||
"tags": content.get("tags", []),
|
||||
"images": content["images"],
|
||||
**(extra_fields or {}),
|
||||
}
|
||||
return request_json(base, path, method="POST", body=body)
|
||||
|
||||
|
||||
def cmd_apply(args: argparse.Namespace) -> None:
|
||||
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
||||
expected_code = confirmation_code(plan)
|
||||
if args.confirm != expected_code or plan.get("confirmation_code") != expected_code:
|
||||
raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码")
|
||||
if plan.get("schema_version") != 1 or plan.get("operation") not in {"create_work", "create_round"}:
|
||||
if plan.get("schema_version") not in {1, 2} or plan.get("operation") not in {"create_work", "create_round"}:
|
||||
raise UploadError("不支持的计划格式")
|
||||
base = base_url(plan.get("base_url"))
|
||||
target = plan["target"]
|
||||
@@ -277,7 +437,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
||||
if existing:
|
||||
print_json({"success": True, "idempotent": True, "message": "externalId 已存在,未发送创建请求", "target": target, "work": existing[0]})
|
||||
return
|
||||
_, created = request_json(base, f"/api/projects/{target['project_id']}/works", method="POST", body={**content, "externalId": external_id})
|
||||
_, created = upload_content(base, f"/api/projects/{target['project_id']}/works", content, {"externalId": external_id})
|
||||
work_id = int(created["id"])
|
||||
verified = get_work(base, work_id)
|
||||
assert_work_project(verified, int(target["project_id"]))
|
||||
@@ -291,7 +451,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
||||
assert_work_project(before, int(target["project_id"]))
|
||||
if int(before.get("version_number", 0)) != int(work_plan["expected_version_number"]):
|
||||
raise UploadError("作品当前版本已变化,旧计划失效;请重新生成计划并确认")
|
||||
_, created = request_json(base, f"/api/works/{work_plan['work_id']}/rounds", method="POST", body=content)
|
||||
_, created = upload_content(base, f"/api/works/{work_plan['work_id']}/rounds", content)
|
||||
verified = get_work(base, int(work_plan["work_id"]))
|
||||
if int(verified.get("version_number", 0)) != int(work_plan["expected_version_number"]) + 1 or not same_content(verified, content):
|
||||
raise UploadError("新轮次请求返回成功,但回读轮次或内容不一致")
|
||||
@@ -310,7 +470,9 @@ def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> No
|
||||
command.add_argument("--clear-description", action="store_true")
|
||||
command.add_argument("--tag", action="append", dest="tags", default=None)
|
||||
command.add_argument("--clear-tags", action="store_true")
|
||||
command.add_argument("--image-url", action="append", dest="image_urls", required=True)
|
||||
image_input = command.add_mutually_exclusive_group(required=True)
|
||||
image_input.add_argument("--image-url", action="append", dest="image_urls")
|
||||
image_input.add_argument("--image-file", action="append", dest="image_files")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
Reference in New Issue
Block a user