feat(platform): 插件协议系统 - 支持声明式渠道、工作流与参考媒体映射
This commit is contained in:
76
backend/internal/protocol/autodl_plugin_artifact_test.go
Normal file
76
backend/internal/protocol/autodl_plugin_artifact_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAutoDLPluginArtifact(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", "autodl-comfyui.yingce-plugin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkg, err := ParsePluginPackage(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapters, err := LoadInstalledProviders(pkg.ManifestRaw, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(adapters) != 1 {
|
||||
t.Fatalf("loaded adapters = %d", len(adapters))
|
||||
}
|
||||
adapter := adapters[0]
|
||||
if adapter.Metadata().ID != "autodl-comfyui" || adapter.Metadata().Execution != "declarative" {
|
||||
t.Fatalf("metadata = %#v", adapter.Metadata())
|
||||
}
|
||||
if !adapter.Metadata().RequiresPublicMediaURLs {
|
||||
t.Fatal("AutoDL provider must request public reference media URLs")
|
||||
}
|
||||
|
||||
create, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{
|
||||
Model: "minimax_h3_lightx2v_no_pic", Prompt: "a cinematic test", Duration: 3, Resolution: "768P", Images: []MediaReference{{URL: "https://cdn.example/reference.png"}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if create.Method != "POST" || create.Path != "/api/v1/comfyui/comfyui_workflow/minimax_h3_lightx2v_no_pic" || create.ContentType != "application/json" {
|
||||
t.Fatalf("create spec = %#v", create)
|
||||
}
|
||||
body, ok := create.Body.(map[string]any)
|
||||
if !ok || body["prompt"] != "a cinematic test" || body["duration"] != 3 || body["resolution"] != "768p" || body["ref_image_0"] != "https://cdn.example/reference.png" {
|
||||
t.Fatalf("create body = %#v", create.Body)
|
||||
}
|
||||
if _, exists := body["ref_image_1"]; exists {
|
||||
t.Fatalf("missing reference image was not omitted: %#v", body)
|
||||
}
|
||||
|
||||
failed, err := adapter.ParseCreate(context.Background(), []byte(`{"code":"RequestParameterIsWrong","data":null,"msg":"参数: resolution 的值: 768P 不在 options 列表中"}`))
|
||||
if err != nil || failed.Status != StatusFailed || failed.Message == "" {
|
||||
t.Fatalf("failed = %#v, err = %v", failed, err)
|
||||
}
|
||||
|
||||
created, err := adapter.ParseCreate(context.Background(), []byte(`{"code":0,"data":{"task_id":"task-auto-1","status":"QUEUED"}}`))
|
||||
if err != nil || created.TaskID != "task-auto-1" || created.Status != StatusPending {
|
||||
t.Fatalf("created = %#v, err = %v", created, err)
|
||||
}
|
||||
poll, err := adapter.BuildPoll(context.Background(), PollContext{BaseURL: "https://autodl.art", Model: "minimax_h3_lightx2v_no_pic", TaskID: created.TaskID})
|
||||
if err != nil || poll.Method != "GET" || poll.Path != "/api/v1/comfyui/comfyui_workflow/result/task-auto-1" {
|
||||
t.Fatalf("poll spec = %#v, err = %v", poll, err)
|
||||
}
|
||||
completed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: created.TaskID}, []byte(`{"code":"Success","data":{"task_id":"task-auto-1","status":"completed","results":[{"url":"https://cdn.example/video.mp4","type":"video","file_type":"mp4"}]}}`))
|
||||
if err != nil || completed.Status != StatusSucceeded || completed.Result == nil || len(completed.Result.Videos) != 1 {
|
||||
t.Fatalf("completed = %#v, err = %v", completed, err)
|
||||
}
|
||||
video := completed.Result.Videos[0]
|
||||
if video.URL != "https://cdn.example/video.mp4" || video.Kind != "video" || !video.Ephemeral {
|
||||
t.Fatalf("video = %#v", video)
|
||||
}
|
||||
if _, err := json.Marshal(completed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,21 @@ func Builtins() *Registry {
|
||||
return registry
|
||||
}
|
||||
|
||||
// BundledManifest returns the canonical unified manifest for a declarative
|
||||
// bundled contribution. Host-backed builtins are generated by the installer
|
||||
// because their execution engine lives in the application binary.
|
||||
func BundledManifest(id string) (Manifest, bool) {
|
||||
adapter, ok := Builtins().Get(id)
|
||||
if !ok {
|
||||
return Manifest{}, false
|
||||
}
|
||||
manifest, ok := adapter.(manifestAdapter)
|
||||
if !ok {
|
||||
return Manifest{}, false
|
||||
}
|
||||
return manifest.manifest, true
|
||||
}
|
||||
|
||||
func allSurfaces() []Surface {
|
||||
return []Surface{SurfaceAdminSystemChannel, SurfaceUserCustomChannel, SurfaceCanvas, SurfaceCreation, SurfaceAgent}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ func TestBuiltinCatalogContainsRequestedProtocols(t *testing.T) {
|
||||
if !found {
|
||||
t.Fatal("Agnes metadata was omitted from the administrator catalog")
|
||||
}
|
||||
if _, ok := registry.Get("autodl-comfyui"); ok {
|
||||
t.Fatal("AutoDL must be provided by the uploaded plugin package, not the host registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextAdaptersMapRequestsAndResponses(t *testing.T) {
|
||||
@@ -216,11 +219,9 @@ func TestAgnesIsExplicitlyUnavailable(t *testing.T) {
|
||||
|
||||
func TestDeclarativeManifestMapsFieldsAndResponses(t *testing.T) {
|
||||
manifest := []byte(`{
|
||||
"apiVersion":"v1",
|
||||
"metadata":{"id":"example-video","version":"1.0.0","name":"Example Video","vendor":"Example","categories":["video"],"scopes":["admin.system-channel","user.custom-channel"],"documentation":"# Example Video"},
|
||||
"create":{"method":"POST","path":"/v1/tasks","fields":{"model":"request.model","input.prompt":"request.prompt","input.seconds":"request.duration"}},
|
||||
"poll":{"method":"GET","path":"/v1/tasks/{{taskId}}"},
|
||||
"response":{"taskIdPaths":["id","data.id"],"statusPaths":["status","data.status"],"resultUrlPaths":["result.video_url","data.result.video_url"],"resultKind":"video"}
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
"id":"example-video","version":"1.0.0","name":"Example Video","author":"Example","documentation":"# Example Video",
|
||||
"contributes":{"providers":[{"id":"example-video","label":"Example Video","capabilities":["video"],"scopes":["admin.system-channel","user.custom-channel"],"create":{"method":"POST","path":"/v1/tasks","fields":{"model":"request.model","input.prompt":"request.prompt","input.seconds":"request.duration"}},"poll":{"method":"GET","path":"/v1/tasks/{{taskId}}"},"response":{"taskIdPaths":["id","data.id"],"statusPaths":["status","data.status"],"resultPaths":["result.video_url","data.result.video_url"],"resultKind":"video"}}]}
|
||||
}`)
|
||||
adapter, err := LoadManifest(manifest)
|
||||
if err != nil {
|
||||
@@ -258,6 +259,33 @@ func TestDeclarativeManifestMapsFieldsAndResponses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarativeManifestSupportsMediaPathsTransformsAndErrors(t *testing.T) {
|
||||
manifest := []byte(`{
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
"id":"declarative-expression-test","version":"1.0.0","name":"Declarative Expression Test","author":"Test","documentation":"# Declarative Expression Test",
|
||||
"contributes":{"providers":[{"id":"declarative-expression-test","label":"Declarative Expression Test","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/tasks","fields":{"resolution":"request.resolution|lower","ref_image_0":"request.images.0.url","ref_image_1":"request.images.1.url"}},"response":{"errorPaths":["code"],"messagePaths":["msg"]}}]}
|
||||
}`)
|
||||
adapter, err := LoadManifest(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Resolution: "768P", Images: []MediaReference{{URL: "https://cdn.example/one.png"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := spec.Body.(map[string]any)
|
||||
if body["resolution"] != "768p" || body["ref_image_0"] != "https://cdn.example/one.png" {
|
||||
t.Fatalf("mapped body = %#v", body)
|
||||
}
|
||||
if _, exists := body["ref_image_1"]; exists {
|
||||
t.Fatalf("empty media path was not omitted: %#v", body)
|
||||
}
|
||||
failed, err := adapter.ParseCreate(context.Background(), []byte(`{"code":"RequestParameterIsWrong","msg":"invalid resolution"}`))
|
||||
if err != nil || failed.Status != StatusFailed || failed.Message != "invalid resolution" {
|
||||
t.Fatalf("failed response = %#v, err = %v", failed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarativeManifestRequiresDocumentation(t *testing.T) {
|
||||
manifest := Manifest{
|
||||
APIVersion: "v1",
|
||||
|
||||
28
backend/internal/protocol/docs/autodl-comfyui.md
Normal file
28
backend/internal/protocol/docs/autodl-comfyui.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# {{NAME}}
|
||||
|
||||
## 接口
|
||||
|
||||
AutoDL.Art 将 ComfyUI 工作流包装为异步任务接口。创建任务使用 `POST /api/v1/comfyui/comfyui_workflow/{workflow_id}`,影策运行时模板为 `/api/v1/comfyui/comfyui_workflow/{model}`;查询结果使用 `GET /api/v1/comfyui/comfyui_workflow/result/{task_id}`,影策运行时模板为 `/api/v1/comfyui/comfyui_workflow/result/{task_id}`。`workflow_id` 由模型字段传入,不能把它写死在渠道地址中。创建接口只返回任务标识,宿主负责按照统一任务生命周期轮询。
|
||||
|
||||
## 模型
|
||||
|
||||
AutoDL 的模型选择对应 ComfyUI 工作流 ID。不同工作流可以声明不同参数,插件清单中的工作流贡献负责描述参数、默认值和画布能力。当前内置示例使用 `minimax_h3_lightx2v_no_pic`,表示 MiniMax H3 文生视频工作流。后续新增工作流只需增加工作流贡献,不需要修改生成服务代码。
|
||||
|
||||
## 参数
|
||||
|
||||
请求体为 JSON。常用字段包括 `prompt`、`duration` 和 `resolution`;实际工作流可以扩展自己的参数 schema。状态返回值位于 `data.status`,任务 ID 位于 `data.task_id`,成功结果位于 `data.results`。结果数组中的地址是短期 URL,成功后必须立即由宿主下载到影策资源存储,不能把临时地址直接写入画布长期数据。
|
||||
|
||||
## 鉴权
|
||||
|
||||
使用 AutoDL 令牌作为 `Authorization` 请求头。令牌应配置在渠道密钥存储中,只由后端出站请求读取,不进入插件 Manifest、浏览器 URL、日志或持久任务正文。Token 分组应选择 AutoDL 的 ComfyUI 分组。
|
||||
|
||||
## 状态与结果
|
||||
|
||||
`QUEUED` 映射为排队,`RUNNING` 映射为处理中,`SUCCESS` 映射为成功,`FAILED` 映射为失败。成功但没有 `data.results` 时,宿主应返回真实错误而不是生成空节点。视频结果由宿主读取 MIME、下载并保存,用户界面只消费已保存资源。
|
||||
|
||||
## 官方
|
||||
|
||||
- [AutoDL ComfyUI API](https://autodl.art/docs/comfyui_api/)
|
||||
- [AutoDL Token 管理](https://autodl.art/large-model/tokens)
|
||||
|
||||
{{CONTRACT}}
|
||||
@@ -10,20 +10,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manifest is the declarative format used by future protocol packages. It is
|
||||
// intentionally limited to relative HTTP paths and JSON field mappings; a
|
||||
// plugin cannot inject code or bypass the host outbound policy.
|
||||
type Manifest struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Create ManifestOperation `json:"create"`
|
||||
Agent *ManifestOperation `json:"agent,omitempty"`
|
||||
Poll *ManifestOperation `json:"poll,omitempty"`
|
||||
Cancel *ManifestOperation `json:"cancel,omitempty"`
|
||||
Response ManifestResponse `json:"response"`
|
||||
AgentResponse *ManifestAgentResponse `json:"agentResponse,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestOperation struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
@@ -32,13 +18,16 @@ type ManifestOperation struct {
|
||||
}
|
||||
|
||||
type ManifestResponse struct {
|
||||
TaskIDPaths []string `json:"taskIdPaths,omitempty"`
|
||||
StatusPaths []string `json:"statusPaths,omitempty"`
|
||||
MessagePaths []string `json:"messagePaths,omitempty"`
|
||||
TextPaths []string `json:"textPaths,omitempty"`
|
||||
ReasoningPaths []string `json:"reasoningPaths,omitempty"`
|
||||
ResultURLPaths []string `json:"resultUrlPaths,omitempty"`
|
||||
ResultKind string `json:"resultKind,omitempty"`
|
||||
TaskIDPaths []string `json:"taskIdPaths,omitempty"`
|
||||
StatusPaths []string `json:"statusPaths,omitempty"`
|
||||
ErrorPaths []string `json:"errorPaths,omitempty"`
|
||||
MessagePaths []string `json:"messagePaths,omitempty"`
|
||||
TextPaths []string `json:"textPaths,omitempty"`
|
||||
ReasoningPaths []string `json:"reasoningPaths,omitempty"`
|
||||
ResultURLPaths []string `json:"resultUrlPaths,omitempty"`
|
||||
ResultPaths []string `json:"resultPaths,omitempty"`
|
||||
ResultKind string `json:"resultKind,omitempty"`
|
||||
ResultEphemeral bool `json:"resultEphemeral,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestAgentResponse describes the provider response shape for a
|
||||
@@ -59,25 +48,60 @@ type ManifestAgentResponse struct {
|
||||
type AdapterResolver func(string) (Adapter, bool)
|
||||
|
||||
func LoadInstalledPlugin(data []byte, resolve AdapterResolver) (Adapter, error) {
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode protocol plugin: %w", err)
|
||||
adapters, err := LoadInstalledProviders(data, resolve)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(manifest.Metadata.Execution), "host:") {
|
||||
if resolve == nil {
|
||||
return nil, fmt.Errorf("protocol plugin %q requires a host execution engine", manifest.Metadata.ID)
|
||||
if len(adapters) == 0 {
|
||||
return nil, fmt.Errorf("plugin does not provide an executable provider")
|
||||
}
|
||||
return adapters[0], nil
|
||||
}
|
||||
|
||||
// LoadInstalledProviders loads every provider contribution from one plugin
|
||||
// package. The package remains the lifecycle and permission unit; providers
|
||||
// are separate execution entries in the runtime index.
|
||||
func LoadInstalledProviders(data []byte, resolve AdapterResolver) ([]Adapter, error) {
|
||||
manifest, err := decodeManifest(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") {
|
||||
if len(manifest.Contributes.Providers) != 1 {
|
||||
return nil, fmt.Errorf("host-backed plugin must declare exactly one provider")
|
||||
}
|
||||
name := strings.TrimPrefix(strings.TrimSpace(manifest.Metadata.Execution), "host:")
|
||||
if resolve == nil {
|
||||
return nil, fmt.Errorf("plugin %q requires a host execution engine", manifest.Metadata.ID)
|
||||
}
|
||||
name := strings.TrimPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:")
|
||||
adapter, ok := resolve(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("protocol plugin host execution %q is unavailable", name)
|
||||
return nil, fmt.Errorf("plugin host execution %q is unavailable", name)
|
||||
}
|
||||
if err := validatePluginMetadata(manifest.Metadata); err != nil {
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metadataAdapter{metadata: manifest.Metadata, delegate: adapter}, nil
|
||||
if err := normalizeManifest(&manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Adapter{metadataAdapter{metadata: manifest.Metadata, delegate: adapter}}, nil
|
||||
}
|
||||
return LoadManifest(data)
|
||||
if len(manifest.Contributes.Providers) == 0 {
|
||||
adapter, err := loadDeclarativeManifest(manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Adapter{adapter}, nil
|
||||
}
|
||||
result := make([]Adapter, 0, len(manifest.Contributes.Providers))
|
||||
for index := range manifest.Contributes.Providers {
|
||||
adapter, err := loadDeclarativeManifestProvider(manifest, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, adapter)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validatePluginMetadata(metadata Metadata) error {
|
||||
@@ -139,21 +163,42 @@ func (a metadataAdapter) ParseAgent(ctx context.Context, body []byte) (AgentResu
|
||||
}
|
||||
|
||||
func LoadManifest(data []byte) (Adapter, error) {
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode protocol manifest: %w", err)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
manifest, err := decodeManifest(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(manifest.Metadata.Execution) == "" {
|
||||
manifest.Metadata.Execution = "declarative"
|
||||
return loadDeclarativeManifest(manifest)
|
||||
}
|
||||
|
||||
func decodeManifest(data []byte) (Manifest, error) {
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return Manifest{}, fmt.Errorf("decode plugin manifest: %w", err)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func loadDeclarativeManifest(manifest Manifest) (Adapter, error) {
|
||||
manifest.Runtime.Backend = "declarative"
|
||||
if err := normalizeManifest(&manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return manifestAdapter{manifest: manifest}, nil
|
||||
}
|
||||
|
||||
func loadDeclarativeManifestProvider(manifest Manifest, index int) (Adapter, error) {
|
||||
manifest.Runtime.Backend = "declarative"
|
||||
if err := normalizeManifestForProvider(&manifest, index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return manifestAdapter{manifest: manifest}, nil
|
||||
}
|
||||
|
||||
func ValidateManifest(manifest Manifest) error {
|
||||
if strings.TrimSpace(manifest.APIVersion) != "v1" {
|
||||
if strings.TrimSpace(manifest.APIVersion) != "yingce.plugin/v1" {
|
||||
return fmt.Errorf("unsupported protocol manifest apiVersion %q", manifest.APIVersion)
|
||||
}
|
||||
if strings.TrimSpace(manifest.Metadata.ID) == "" || strings.TrimSpace(manifest.Metadata.Version) == "" {
|
||||
@@ -162,54 +207,113 @@ func ValidateManifest(manifest Manifest) error {
|
||||
if strings.TrimSpace(manifest.Metadata.Name) == "" {
|
||||
return fmt.Errorf("protocol manifest metadata requires name")
|
||||
}
|
||||
if strings.TrimSpace(manifest.Metadata.Documentation) == "" {
|
||||
return fmt.Errorf("protocol manifest metadata requires Markdown documentation")
|
||||
}
|
||||
if !validManifestIdentifier(manifest.Metadata.ID) {
|
||||
return fmt.Errorf("protocol manifest metadata id is invalid")
|
||||
}
|
||||
if len(manifest.Metadata.Name) > 160 || len(manifest.Metadata.Vendor) > 120 {
|
||||
return fmt.Errorf("protocol manifest metadata name or vendor is too long")
|
||||
}
|
||||
if len(manifest.Metadata.Categories) == 0 || len(manifest.Metadata.Scopes) == 0 {
|
||||
return fmt.Errorf("protocol manifest metadata requires categories and scopes")
|
||||
if len(manifest.Contributes.Providers) == 0 && !hasNonProviderContribution(manifest.Contributes) {
|
||||
return fmt.Errorf("plugin must declare at least one contribution")
|
||||
}
|
||||
for _, capability := range manifest.Metadata.Categories {
|
||||
if len(manifest.Contributes.Providers) == 0 {
|
||||
return nil
|
||||
}
|
||||
provider := manifest.Contributes.Providers[0]
|
||||
if strings.TrimSpace(provider.ID) == "" || strings.TrimSpace(provider.Label) == "" {
|
||||
return fmt.Errorf("provider contribution requires id and label")
|
||||
}
|
||||
if len(provider.Capabilities) == 0 || len(provider.Scopes) == 0 {
|
||||
return fmt.Errorf("provider contribution requires capabilities and scopes")
|
||||
}
|
||||
for _, capability := range provider.Capabilities {
|
||||
if capability != CapabilityText && capability != CapabilityImage && capability != CapabilityVideo && capability != CapabilityAudio {
|
||||
return fmt.Errorf("unsupported protocol capability %q", capability)
|
||||
}
|
||||
}
|
||||
for _, scope := range manifest.Metadata.Scopes {
|
||||
for _, scope := range provider.Scopes {
|
||||
switch scope {
|
||||
case SurfaceAdminSystemChannel, SurfaceUserCustomChannel, SurfaceCanvas, SurfaceCreation, SurfaceAgent:
|
||||
default:
|
||||
return fmt.Errorf("unsupported protocol scope %q", scope)
|
||||
}
|
||||
}
|
||||
if err := validateManifestOperation(manifest.Create); err != nil {
|
||||
if err := validateManifestOperation(provider.Create); err != nil {
|
||||
return fmt.Errorf("create operation: %w", err)
|
||||
}
|
||||
if manifest.Agent != nil {
|
||||
if err := validateManifestOperation(*manifest.Agent); err != nil {
|
||||
if provider.Agent != nil {
|
||||
if err := validateManifestOperation(*provider.Agent); err != nil {
|
||||
return fmt.Errorf("agent operation: %w", err)
|
||||
}
|
||||
if manifest.AgentResponse == nil {
|
||||
if provider.AgentResponse == nil {
|
||||
return fmt.Errorf("agent response mapping is required when agent operation is declared")
|
||||
}
|
||||
}
|
||||
if manifest.Poll != nil {
|
||||
if err := validateManifestOperation(*manifest.Poll); err != nil {
|
||||
if provider.Poll != nil {
|
||||
if err := validateManifestOperation(*provider.Poll); err != nil {
|
||||
return fmt.Errorf("poll operation: %w", err)
|
||||
}
|
||||
}
|
||||
if manifest.Cancel != nil {
|
||||
if err := validateManifestOperation(*manifest.Cancel); err != nil {
|
||||
if provider.Cancel != nil {
|
||||
if err := validateManifestOperation(*provider.Cancel); err != nil {
|
||||
return fmt.Errorf("cancel operation: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeManifest(manifest *Manifest) error {
|
||||
if manifest == nil {
|
||||
return fmt.Errorf("plugin manifest is missing")
|
||||
}
|
||||
if len(manifest.Contributes.Providers) == 0 {
|
||||
manifest.Metadata.Execution = manifest.Runtime.Backend
|
||||
return nil
|
||||
}
|
||||
return normalizeManifestForProvider(manifest, 0)
|
||||
}
|
||||
|
||||
func normalizeManifestForProvider(manifest *Manifest, index int) error {
|
||||
if manifest == nil || index < 0 || index >= len(manifest.Contributes.Providers) {
|
||||
return fmt.Errorf("plugin provider contribution is missing")
|
||||
}
|
||||
provider := manifest.Contributes.Providers[index]
|
||||
manifest.Metadata.ID = provider.ID
|
||||
manifest.Metadata.Categories = provider.Capabilities
|
||||
manifest.Metadata.Scopes = provider.Scopes
|
||||
manifest.Metadata.Parameters = provider.Parameters
|
||||
manifest.Metadata.Create = operationSummary(provider.Create)
|
||||
manifest.Metadata.Poll = operationSummaryPtr(provider.Poll)
|
||||
manifest.Metadata.Cancel = operationSummaryPtr(provider.Cancel)
|
||||
manifest.Metadata.ContentType = provider.Create.ContentType
|
||||
manifest.Metadata.RequiresPublicMediaURLs = provider.RequiresPublicMediaURLs
|
||||
manifest.Metadata.Execution = manifest.Runtime.Backend
|
||||
manifest.Create = provider.Create
|
||||
manifest.Agent = provider.Agent
|
||||
manifest.Poll = provider.Poll
|
||||
manifest.Cancel = provider.Cancel
|
||||
manifest.Response = provider.Response
|
||||
manifest.AgentResponse = provider.AgentResponse
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasNonProviderContribution(contributes ManifestContributions) bool {
|
||||
return len(contributes.Workflows) > 0 || len(contributes.CanvasNodes) > 0 || len(contributes.Transforms) > 0 || len(contributes.Commands) > 0 || len(contributes.AssetSources) > 0 || len(contributes.UsageObservers) > 0 || len(contributes.AICapabilities) > 0 || len(contributes.Agents) > 0 || len(contributes.ImportExport) > 0
|
||||
}
|
||||
|
||||
func operationSummary(operation ManifestOperation) string {
|
||||
path := strings.ReplaceAll(operation.Path, "{{model}}", "{model}")
|
||||
path = strings.ReplaceAll(path, "{{taskId}}", "{task_id}")
|
||||
return strings.ToUpper(operation.Method) + " " + path
|
||||
}
|
||||
|
||||
func operationSummaryPtr(operation *ManifestOperation) string {
|
||||
if operation == nil {
|
||||
return ""
|
||||
}
|
||||
return operationSummary(*operation)
|
||||
}
|
||||
|
||||
func validateManifestOperation(operation ManifestOperation) error {
|
||||
method := strings.ToUpper(strings.TrimSpace(operation.Method))
|
||||
if method != http.MethodGet && method != http.MethodPost && method != http.MethodDelete && method != http.MethodPut {
|
||||
@@ -225,6 +329,9 @@ type manifestAdapter struct{ manifest Manifest }
|
||||
|
||||
func (a manifestAdapter) Metadata() Metadata { return a.manifest.Metadata }
|
||||
func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) {
|
||||
if len(a.manifest.Contributes.Providers) == 0 {
|
||||
return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID)
|
||||
}
|
||||
return buildManifestOperation(a.manifest.Create, c.Request, ""), nil
|
||||
}
|
||||
func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) {
|
||||
@@ -302,13 +409,18 @@ func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResu
|
||||
status = StatusPending
|
||||
}
|
||||
message := firstPathValue(payload, a.manifest.Response.MessagePaths...)
|
||||
if manifestError(payload, a.manifest.Response.ErrorPaths...) {
|
||||
status = StatusFailed
|
||||
}
|
||||
result := &Result{
|
||||
Text: firstPathValue(payload, a.manifest.Response.TextPaths...),
|
||||
Reasoning: firstPathValue(payload, a.manifest.Response.ReasoningPaths...),
|
||||
}
|
||||
for _, path := range a.manifest.Response.ResultURLPaths {
|
||||
if value := firstPathValue(payload, path); value != "" {
|
||||
item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind}
|
||||
paths := append([]string{}, a.manifest.Response.ResultURLPaths...)
|
||||
paths = append(paths, a.manifest.Response.ResultPaths...)
|
||||
for _, path := range paths {
|
||||
for _, value := range mediaPathValues(payload, path) {
|
||||
item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind, Ephemeral: a.manifest.Response.ResultEphemeral}
|
||||
switch a.manifest.Response.ResultKind {
|
||||
case "image":
|
||||
result.Images = append(result.Images, item)
|
||||
@@ -328,13 +440,42 @@ func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResu
|
||||
return CreateResult{TaskID: id, Status: status, Result: result, Message: message}
|
||||
}
|
||||
|
||||
func mediaPathValues(payload map[string]any, path string) []string {
|
||||
value := pathValue(payload, path)
|
||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||
return []string{strings.TrimSpace(text)}
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
switch typed := item.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
values = append(values, strings.TrimSpace(typed))
|
||||
}
|
||||
case map[string]any:
|
||||
if url := firstString(typed, "url", "file_url", "fileUrl", "video_url", "videoUrl", "image_url", "imageUrl"); url != "" {
|
||||
values = append(values, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func buildManifestOperation(operation ManifestOperation, request GenerationRequest, taskID string) RequestSpec {
|
||||
var body map[string]any
|
||||
if len(operation.Fields) > 0 {
|
||||
body = make(map[string]any, len(operation.Fields))
|
||||
}
|
||||
for key, expression := range operation.Fields {
|
||||
setMapPath(body, key, manifestExpressionValue(expression, request, taskID))
|
||||
value := manifestExpressionValue(expression, request, taskID)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
setMapPath(body, key, value)
|
||||
}
|
||||
path := strings.ReplaceAll(operation.Path, "{{taskId}}", url.PathEscape(taskID))
|
||||
path = strings.ReplaceAll(path, "{{model}}", url.PathEscape(request.Model))
|
||||
@@ -343,40 +484,79 @@ func buildManifestOperation(operation ManifestOperation, request GenerationReque
|
||||
|
||||
func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any {
|
||||
expression = strings.TrimSpace(expression)
|
||||
switch expression {
|
||||
case "request.model":
|
||||
return request.Model
|
||||
case "request.prompt":
|
||||
return request.Prompt
|
||||
case "request.images":
|
||||
return request.Images
|
||||
case "request.videos":
|
||||
return request.Videos
|
||||
case "request.audios":
|
||||
return request.Audios
|
||||
case "request.imageCount":
|
||||
return request.ImageCount
|
||||
case "request.duration":
|
||||
return request.Duration
|
||||
case "request.aspectRatio":
|
||||
return request.AspectRatio
|
||||
case "request.resolution":
|
||||
return request.Resolution
|
||||
case "request.quality":
|
||||
return request.Quality
|
||||
case "request.generateAudio":
|
||||
return request.GenerateAudio
|
||||
case "request.watermark":
|
||||
return request.Watermark
|
||||
case "request.operation":
|
||||
return request.Operation
|
||||
case "taskId":
|
||||
return taskID
|
||||
source := expression
|
||||
transforms := []string{}
|
||||
if separator := strings.IndexByte(expression, '|'); separator >= 0 {
|
||||
source = strings.TrimSpace(expression[:separator])
|
||||
transforms = strings.Split(expression[separator+1:], "|")
|
||||
}
|
||||
if strings.HasPrefix(expression, "request.extra.") {
|
||||
return pathValue(request.Extra, strings.TrimPrefix(expression, "request.extra."))
|
||||
|
||||
var value any
|
||||
switch {
|
||||
case source == "taskId":
|
||||
value = taskID
|
||||
case strings.HasPrefix(source, "request."):
|
||||
value = pathValue(manifestRequestValues(request), strings.TrimPrefix(source, "request."))
|
||||
default:
|
||||
value = source
|
||||
}
|
||||
for _, transform := range transforms {
|
||||
value = applyManifestTransform(value, transform)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// manifestRequestValues is deliberately a small, JSON-shaped view of the
|
||||
// platform request. It lets uploaded manifests address media items by index
|
||||
// without exposing Go structs or adding provider-specific host code.
|
||||
func manifestRequestValues(request GenerationRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"model": request.Model,
|
||||
"prompt": request.Prompt,
|
||||
"images": manifestMediaValues(request.Images),
|
||||
"videos": manifestMediaValues(request.Videos),
|
||||
"audios": manifestMediaValues(request.Audios),
|
||||
"imageCount": request.ImageCount,
|
||||
"duration": request.Duration,
|
||||
"aspectRatio": request.AspectRatio,
|
||||
"resolution": request.Resolution,
|
||||
"quality": request.Quality,
|
||||
"generateAudio": request.GenerateAudio,
|
||||
"watermark": request.Watermark,
|
||||
"operation": request.Operation,
|
||||
"extra": request.Extra,
|
||||
}
|
||||
}
|
||||
|
||||
func manifestMediaValues(values []MediaReference) []any {
|
||||
result := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, map[string]any{
|
||||
"url": value.URL,
|
||||
"dataUrl": value.DataURL,
|
||||
"kind": value.Kind,
|
||||
"ephemeral": value.Ephemeral,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyManifestTransform(value any, transform string) any {
|
||||
transform = strings.ToLower(strings.TrimSpace(transform))
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
switch transform {
|
||||
case "trim":
|
||||
return strings.TrimSpace(text)
|
||||
case "lower", "lowercase":
|
||||
return strings.ToLower(text)
|
||||
case "upper", "uppercase":
|
||||
return strings.ToUpper(text)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
return expression
|
||||
}
|
||||
|
||||
func pathValue(payload map[string]any, path string) any {
|
||||
@@ -413,6 +593,73 @@ func firstPathValue(payload map[string]any, paths ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func manifestError(payload map[string]any, paths ...string) bool {
|
||||
for _, path := range paths {
|
||||
value := pathValue(payload, path)
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
continue
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(typed)) {
|
||||
case "", "0", "ok", "success", "succeeded", "true":
|
||||
continue
|
||||
default:
|
||||
return true
|
||||
}
|
||||
case float64:
|
||||
if typed == 0 {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
case float32:
|
||||
if typed == 0 {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
case int, int8, int16, int32, int64:
|
||||
if reflectValueIsZero(typed) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
if reflectValueIsZero(typed) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func reflectValueIsZero(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed == 0
|
||||
case int8:
|
||||
return typed == 0
|
||||
case int16:
|
||||
return typed == 0
|
||||
case int32:
|
||||
return typed == 0
|
||||
case int64:
|
||||
return typed == 0
|
||||
case uint:
|
||||
return typed == 0
|
||||
case uint8:
|
||||
return typed == 0
|
||||
case uint16:
|
||||
return typed == 0
|
||||
case uint32:
|
||||
return typed == 0
|
||||
case uint64:
|
||||
return typed == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// firstPathJSONValue keeps scalar response fields string-like while allowing
|
||||
// providers to return tool arguments as an object instead of a JSON string.
|
||||
// The platform tool contract always stores arguments as JSON text.
|
||||
|
||||
@@ -8,12 +8,9 @@ import (
|
||||
|
||||
func TestManifestAgentMapsRequestAndResponse(t *testing.T) {
|
||||
manifest := []byte(`{
|
||||
"apiVersion":"v1",
|
||||
"metadata":{"id":"agent-test","version":"1.0.0","name":"Agent Test","vendor":"Test","categories":["text"],"scopes":["agent"],"documentation":"# Agent Test"},
|
||||
"create":{"method":"POST","path":"/create"},
|
||||
"agent":{"method":"POST","path":"/messages","fields":{"model":"request.model","messages":"request.extra.agent.chatCompletion.messages","tools":"request.extra.agent.chatCompletion.tools","tool_choice":"request.extra.agent.chatCompletion.tool_choice"}},
|
||||
"agentResponse":{"textPaths":["choices.0.message.content"],"toolCallsPath":"choices.0.message.tool_calls","toolCallIdPaths":["id"],"toolCallNamePaths":["function.name"],"toolCallArgumentsPaths":["function.arguments"]},
|
||||
"response":{}
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
"id":"agent-test","version":"1.0.0","name":"Agent Test","author":"Test","documentation":"# Agent Test",
|
||||
"contributes":{"providers":[{"id":"agent-test","label":"Agent Test","capabilities":["text"],"scopes":["agent"],"create":{"method":"POST","path":"/create"},"agent":{"method":"POST","path":"/messages","fields":{"model":"request.model","messages":"request.extra.agent.chatCompletion.messages","tools":"request.extra.agent.chatCompletion.tools","tool_choice":"request.extra.agent.chatCompletion.tool_choice"}},"agentResponse":{"textPaths":["choices.0.message.content"],"toolCallsPath":"choices.0.message.tool_calls","toolCallIdPaths":["id"],"toolCallNamePaths":["function.name"],"toolCallArgumentsPaths":["function.arguments"]},"response":{}}]}
|
||||
}`)
|
||||
adapter, err := LoadManifest(manifest)
|
||||
if err != nil {
|
||||
@@ -64,10 +61,9 @@ func TestManifestAgentMapsRequestAndResponse(t *testing.T) {
|
||||
|
||||
func TestManifestMapsSynchronousTextResponse(t *testing.T) {
|
||||
manifest := []byte(`{
|
||||
"apiVersion":"v1",
|
||||
"metadata":{"id":"text-test","version":"1.0.0","name":"Text Test","vendor":"Test","categories":["text"],"scopes":["canvas"],"documentation":"# Text Test"},
|
||||
"create":{"method":"POST","path":"/chat","fields":{"prompt":"request.prompt"}},
|
||||
"response":{"textPaths":["choices.0.message.content"],"reasoningPaths":["choices.0.message.reasoning_content"]}
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
"id":"text-test","version":"1.0.0","name":"Text Test","author":"Test","documentation":"# Text Test",
|
||||
"contributes":{"providers":[{"id":"text-test","label":"Text Test","capabilities":["text"],"scopes":["canvas"],"create":{"method":"POST","path":"/chat","fields":{"prompt":"request.prompt"}},"response":{"textPaths":["choices.0.message.content"],"reasoningPaths":["choices.0.message.reasoning_content"]}}]}
|
||||
}`)
|
||||
adapter, err := LoadManifest(manifest)
|
||||
if err != nil {
|
||||
@@ -85,3 +81,19 @@ func TestManifestMapsSynchronousTextResponse(t *testing.T) {
|
||||
t.Fatalf("text result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestUsesOnlyUnifiedTopLevelContract(t *testing.T) {
|
||||
legacy := []byte(`{"apiVersion":"v1","metadata":{"id":"legacy","version":"1","name":"Legacy"},"create":{"method":"POST","path":"/tasks"},"response":{}}`)
|
||||
if _, err := LoadManifest(legacy); err == nil {
|
||||
t.Fatal("legacy protocol manifest was accepted")
|
||||
}
|
||||
canvasOnly := []byte(`{"apiVersion":"yingce.plugin/v1","id":"canvas-extension","name":"Canvas Extension","version":"1.0.0","contributes":{"canvasNodes":[{"id":"canvas-extension/node","label":"Node","defaultTitle":"Node","defaultSize":{"width":320,"height":200},"schema":{},"renderer":"declarative"}]}}`)
|
||||
if _, err := LoadManifest(canvasOnly); err != nil {
|
||||
t.Fatalf("providerless unified plugin was rejected: %v", err)
|
||||
}
|
||||
multi := []byte(`{"apiVersion":"yingce.plugin/v1","id":"multi-provider","name":"Multi Provider","version":"1.0.0","contributes":{"providers":[{"id":"multi-text","label":"Text","capabilities":["text"],"scopes":["canvas"],"create":{"method":"POST","path":"/text"},"response":{}},{"id":"multi-image","label":"Image","capabilities":["image"],"scopes":["canvas"],"create":{"method":"POST","path":"/image"},"response":{}}]}}`)
|
||||
adapters, err := LoadInstalledProviders(multi, nil)
|
||||
if err != nil || len(adapters) != 2 || adapters[0].Metadata().ID != "multi-text" || adapters[1].Metadata().ID != "multi-image" {
|
||||
t.Fatalf("multi-provider load = %#v, %v", adapters, err)
|
||||
}
|
||||
}
|
||||
|
||||
136
backend/internal/protocol/package.go
Normal file
136
backend/internal/protocol/package.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
PluginPackageFormat = "yingce.plugin/package-v1"
|
||||
PluginPackageMaxBytes = 16 << 20
|
||||
PluginManifestMaxBytes = 512 << 10
|
||||
pluginPackageMaxFiles = 256
|
||||
pluginPackageMaxEntry = 8 << 20
|
||||
)
|
||||
|
||||
// PluginPackage is the transport envelope for every uploaded plugin. The
|
||||
// manifest remains the single capability contract; files are optional runtime
|
||||
// artifacts addressed by manifest.entry.
|
||||
type PluginPackage struct {
|
||||
Manifest Manifest
|
||||
ManifestRaw []byte
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// ParsePluginPackage validates the package container and returns its manifest
|
||||
// and files. Uploaded code is never executed by this function.
|
||||
func ParsePluginPackage(data []byte) (PluginPackage, error) {
|
||||
if len(data) == 0 || len(data) > PluginPackageMaxBytes {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package must be between 1 and %d bytes", PluginPackageMaxBytes)
|
||||
}
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return PluginPackage{}, fmt.Errorf("decode plugin package: %w", err)
|
||||
}
|
||||
if len(reader.File) == 0 || len(reader.File) > pluginPackageMaxFiles {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package must contain between 1 and %d files", pluginPackageMaxFiles)
|
||||
}
|
||||
files := make(map[string][]byte, len(reader.File))
|
||||
var manifestRaw []byte
|
||||
for _, file := range reader.File {
|
||||
name, err := validatePluginPackagePath(file.Name)
|
||||
if err != nil {
|
||||
return PluginPackage{}, err
|
||||
}
|
||||
if _, exists := files[name]; exists {
|
||||
return PluginPackage{}, fmt.Errorf("duplicate plugin package file %q", name)
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if file.Mode()&os.ModeSymlink != 0 {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package cannot contain symlink %q", name)
|
||||
}
|
||||
if file.UncompressedSize64 > pluginPackageMaxEntry {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package file %q exceeds %d bytes", name, pluginPackageMaxEntry)
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return PluginPackage{}, fmt.Errorf("open plugin package file %q: %w", name, err)
|
||||
}
|
||||
content, readErr := io.ReadAll(io.LimitReader(stream, pluginPackageMaxEntry+1))
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil {
|
||||
return PluginPackage{}, fmt.Errorf("read plugin package file %q: %w", name, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return PluginPackage{}, fmt.Errorf("close plugin package file %q: %w", name, closeErr)
|
||||
}
|
||||
if len(content) > pluginPackageMaxEntry {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package file %q exceeds %d bytes", name, pluginPackageMaxEntry)
|
||||
}
|
||||
files[name] = content
|
||||
if name == "manifest.json" {
|
||||
manifestRaw = content
|
||||
}
|
||||
}
|
||||
if len(manifestRaw) == 0 {
|
||||
return PluginPackage{}, fmt.Errorf("plugin package must contain manifest.json")
|
||||
}
|
||||
if len(manifestRaw) > PluginManifestMaxBytes {
|
||||
return PluginPackage{}, fmt.Errorf("manifest.json exceeds %d bytes", PluginManifestMaxBytes)
|
||||
}
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
return PluginPackage{}, fmt.Errorf("decode plugin manifest: %w", err)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return PluginPackage{}, err
|
||||
}
|
||||
if err := validatePluginPackageRuntime(manifest, files); err != nil {
|
||||
return PluginPackage{}, err
|
||||
}
|
||||
return PluginPackage{Manifest: manifest, ManifestRaw: manifestRaw, Files: files}, nil
|
||||
}
|
||||
|
||||
func validatePluginPackagePath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.ContainsRune(name, '\\') || strings.HasPrefix(name, "/") || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." {
|
||||
return "", fmt.Errorf("invalid plugin package path %q", name)
|
||||
}
|
||||
if name == "manifest.json" || strings.HasPrefix(name, "web/") || strings.HasPrefix(name, "assets/") || strings.HasPrefix(name, "docs/") || name == "README.md" || name == "LICENSE" {
|
||||
return name, nil
|
||||
}
|
||||
return "", fmt.Errorf("plugin package path %q is outside the allowed package roots", name)
|
||||
}
|
||||
|
||||
func validatePluginPackageRuntime(manifest Manifest, files map[string][]byte) error {
|
||||
if strings.TrimSpace(manifest.Entry) == "" {
|
||||
if strings.TrimSpace(manifest.Runtime.Web) != "" {
|
||||
return fmt.Errorf("manifest.runtime.web requires manifest.entry")
|
||||
}
|
||||
for name := range files {
|
||||
if strings.HasPrefix(name, "web/") {
|
||||
return fmt.Errorf("plugin package web files require manifest.entry")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
entry := strings.TrimSpace(manifest.Entry)
|
||||
if !strings.HasPrefix(entry, "web/") || path.Clean(entry) != entry {
|
||||
return fmt.Errorf("plugin manifest entry must point inside web/")
|
||||
}
|
||||
if _, ok := files[entry]; !ok {
|
||||
return fmt.Errorf("plugin manifest entry %q is missing from package", entry)
|
||||
}
|
||||
if manifest.Runtime.Web != "sandbox" && manifest.Runtime.Web != "worker" {
|
||||
return fmt.Errorf("uploaded web plugins require runtime.web sandbox or worker")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
62
backend/internal/protocol/package_test.go
Normal file
62
backend/internal/protocol/package_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePluginPackageRejectsBareManifest(t *testing.T) {
|
||||
if _, err := ParsePluginPackage([]byte(`{"apiVersion":"yingce.plugin/v1"}`)); err == nil {
|
||||
t.Fatal("bare JSON manifest was accepted as a plugin package")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginPackageValidatesWebEntry(t *testing.T) {
|
||||
manifest := []byte(`{
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
"id":"ui-extension",
|
||||
"name":"UI Extension",
|
||||
"version":"1.0.0",
|
||||
"entry":"web/entry.js",
|
||||
"surfaces":["fullscreen"],
|
||||
"runtime":{"web":"sandbox"},
|
||||
"contributes":{"commands":[{"id":"ui-extension/open","label":"Open UI"}]}
|
||||
}`)
|
||||
pkg, err := ParsePluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "web/entry.js": []byte("self.postMessage({type:'ready'})")}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkg.Manifest.Entry != "web/entry.js" || len(pkg.Files["web/entry.js"]) == 0 {
|
||||
t.Fatalf("package = %#v", pkg)
|
||||
}
|
||||
|
||||
missingEntry := zipPluginPackage(t, map[string][]byte{"manifest.json": manifest})
|
||||
if _, err := ParsePluginPackage(missingEntry); err == nil {
|
||||
t.Fatal("missing web entry was accepted")
|
||||
}
|
||||
|
||||
forbidden := zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "web/entry.js": []byte("ready"), "server/entry.js": []byte("unsafe")})
|
||||
if _, err := ParsePluginPackage(forbidden); err == nil {
|
||||
t.Fatal("forbidden package path was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func zipPluginPackage(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, data := range files {
|
||||
file, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package protocol
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Capability is the kind of model operation handled by a protocol adapter.
|
||||
type Capability string
|
||||
@@ -34,9 +37,10 @@ const (
|
||||
)
|
||||
|
||||
type MediaReference struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
DataURL string `json:"dataUrl,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
DataURL string `json:"dataUrl,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Ephemeral bool `json:"ephemeral,omitempty"`
|
||||
}
|
||||
|
||||
// GenerationRequest is the platform contract shared by canvas, creation and agent.
|
||||
@@ -112,24 +116,188 @@ type Parameter struct {
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Vendor string `json:"vendor"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Categories []Capability `json:"categories"`
|
||||
Scopes []Surface `json:"scopes"`
|
||||
Parameters []Parameter `json:"parameters,omitempty"`
|
||||
Create string `json:"create,omitempty"`
|
||||
Poll string `json:"poll,omitempty"`
|
||||
Cancel string `json:"cancel,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
LegacyAliases []string `json:"legacyAliases,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Installable bool `json:"installable"`
|
||||
Execution string `json:"execution,omitempty"`
|
||||
UnavailableReason string `json:"unavailableReason,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Vendor string `json:"vendor"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Categories []Capability `json:"categories"`
|
||||
Scopes []Surface `json:"scopes"`
|
||||
Parameters []Parameter `json:"parameters,omitempty"`
|
||||
Create string `json:"-"`
|
||||
Poll string `json:"-"`
|
||||
Cancel string `json:"-"`
|
||||
ContentType string `json:"-"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
LegacyAliases []string `json:"-"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Installable bool `json:"installable"`
|
||||
Execution string `json:"-"`
|
||||
RequiresPublicMediaURLs bool `json:"-"`
|
||||
UnavailableReason string `json:"-"`
|
||||
}
|
||||
|
||||
// Manifest is the single plugin contract. A plugin may contribute providers,
|
||||
// workflows, canvas nodes and other capabilities from the same package; the
|
||||
// runtime chooses an execution engine per contribution without changing the
|
||||
// public manifest shape.
|
||||
type Manifest struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Metadata Metadata `json:"-"`
|
||||
Entry string `json:"entry,omitempty"`
|
||||
Surfaces []string `json:"surfaces,omitempty"`
|
||||
Runtime ManifestRuntime `json:"runtime,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
Configuration ManifestConfiguration `json:"configuration,omitempty"`
|
||||
Contributes ManifestContributions `json:"contributes"`
|
||||
// These fields are the normalized provider projection used by the runtime.
|
||||
// They are never serialized; provider execution is described only under
|
||||
// contributes.providers.
|
||||
Create ManifestOperation `json:"-"`
|
||||
Agent *ManifestOperation `json:"-"`
|
||||
Poll *ManifestOperation `json:"-"`
|
||||
Cancel *ManifestOperation `json:"-"`
|
||||
Response ManifestResponse `json:"-"`
|
||||
AgentResponse *ManifestAgentResponse `json:"-"`
|
||||
}
|
||||
|
||||
// Manifest JSON is the public plugin contract. Metadata is an internal
|
||||
// execution projection and is deliberately not a second wire format.
|
||||
func (m Manifest) MarshalJSON() ([]byte, error) {
|
||||
type wire struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
Entry string `json:"entry,omitempty"`
|
||||
Surfaces []string `json:"surfaces,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Installable bool `json:"installable,omitempty"`
|
||||
Runtime ManifestRuntime `json:"runtime,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
Configuration ManifestConfiguration `json:"configuration,omitempty"`
|
||||
Contributes ManifestContributions `json:"contributes"`
|
||||
}
|
||||
return json.Marshal(wire{APIVersion: m.APIVersion, ID: m.Metadata.ID, Name: m.Metadata.Name, Version: m.Metadata.Version, Author: m.Metadata.Vendor, Description: m.Metadata.Description, Documentation: m.Metadata.Documentation, Entry: m.Entry, Surfaces: m.Surfaces, Enabled: m.Metadata.Enabled, Installable: m.Metadata.Installable, Runtime: m.Runtime, Permissions: m.Permissions, Configuration: m.Configuration, Contributes: m.Contributes})
|
||||
}
|
||||
|
||||
func (m *Manifest) UnmarshalJSON(data []byte) error {
|
||||
type wire struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Documentation string `json:"documentation"`
|
||||
Entry string `json:"entry"`
|
||||
Surfaces []string `json:"surfaces"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Installable bool `json:"installable"`
|
||||
Runtime ManifestRuntime `json:"runtime"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Configuration ManifestConfiguration `json:"configuration"`
|
||||
Contributes ManifestContributions `json:"contributes"`
|
||||
}
|
||||
var value wire
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
enabled := true
|
||||
if value.Enabled != nil {
|
||||
enabled = *value.Enabled
|
||||
}
|
||||
m.APIVersion, m.Entry, m.Surfaces, m.Runtime, m.Permissions, m.Configuration, m.Contributes = value.APIVersion, value.Entry, value.Surfaces, value.Runtime, value.Permissions, value.Configuration, value.Contributes
|
||||
m.Metadata = Metadata{ID: value.ID, Name: value.Name, Version: value.Version, Vendor: value.Author, Description: value.Description, Documentation: value.Documentation, Enabled: enabled, Installable: value.Installable}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ManifestRuntime struct {
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Web string `json:"web,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestConfiguration struct {
|
||||
Fields []ManifestField `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestField struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestContributions struct {
|
||||
Providers []ManifestProvider `json:"providers,omitempty"`
|
||||
Workflows []ManifestWorkflow `json:"workflows,omitempty"`
|
||||
CanvasNodes []ManifestCanvasNode `json:"canvasNodes,omitempty"`
|
||||
Transforms []ManifestTransform `json:"transforms,omitempty"`
|
||||
Commands []ManifestCommand `json:"commands,omitempty"`
|
||||
AssetSources []string `json:"assetSources,omitempty"`
|
||||
UsageObservers []string `json:"usageObservers,omitempty"`
|
||||
AICapabilities []string `json:"aiCapabilities,omitempty"`
|
||||
Agents []string `json:"agents,omitempty"`
|
||||
ImportExport []string `json:"importExport,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestProvider struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
Scopes []Surface `json:"scopes"`
|
||||
BaseURL string `json:"baseUrl,omitempty"`
|
||||
RequiresPublicMediaURLs bool `json:"requiresPublicMediaUrls,omitempty"`
|
||||
Auth ManifestAuth `json:"auth,omitempty"`
|
||||
Parameters []Parameter `json:"parameters,omitempty"`
|
||||
Create ManifestOperation `json:"create"`
|
||||
Agent *ManifestOperation `json:"agent,omitempty"`
|
||||
Poll *ManifestOperation `json:"poll,omitempty"`
|
||||
Cancel *ManifestOperation `json:"cancel,omitempty"`
|
||||
Response ManifestResponse `json:"response"`
|
||||
AgentResponse *ManifestAgentResponse `json:"agentResponse,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestAuth struct {
|
||||
Type string `json:"type"`
|
||||
Field string `json:"field"`
|
||||
Header string `json:"header,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestWorkflow struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Capability Capability `json:"capability"`
|
||||
Parameters []Parameter `json:"parameters"`
|
||||
Defaults map[string]any `json:"defaults,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestCanvasNode struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
DefaultTitle string `json:"defaultTitle"`
|
||||
DefaultSize map[string]int `json:"defaultSize"`
|
||||
Schema map[string]any `json:"schema"`
|
||||
Renderer string `json:"renderer"`
|
||||
}
|
||||
|
||||
type ManifestTransform struct {
|
||||
ID string `json:"id"`
|
||||
Input string `json:"input"`
|
||||
Output string `json:"output"`
|
||||
Runtime string `json:"runtime"`
|
||||
}
|
||||
|
||||
type ManifestCommand struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func (r RequestSpec) Validate() error {
|
||||
|
||||
Reference in New Issue
Block a user