feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
@@ -729,7 +729,8 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
if encodedQuery := query.Encode(); encodedQuery != "" {
|
||||
target += "?" + encodedQuery
|
||||
}
|
||||
if _, err := service.ValidateOutboundURL(target); err != nil {
|
||||
validatedTarget, err := svc.ValidateChannelOutboundURL(target, channel.AllowLocalChannel, false)
|
||||
if err != nil {
|
||||
_ = svc.RefundBilling(billingOrderID, "系统渠道地址校验失败")
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -764,7 +765,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
}
|
||||
}
|
||||
}
|
||||
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target, bytes.NewReader(body))
|
||||
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, validatedTarget.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
_ = svc.RefundBilling(billingOrderID, "系统渠道请求构造失败")
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
@@ -787,7 +788,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
status := model.ApiCallStatusSucceeded
|
||||
statusCode := 0
|
||||
errorText := ""
|
||||
resp, err := service.OutboundHTTPClient(35 * time.Minute).Do(upstreamReq)
|
||||
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget, channel.AllowLocalChannel).Do(upstreamReq)
|
||||
if err != nil {
|
||||
status = model.ApiCallStatusFailed
|
||||
errorText = err.Error()
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
const maxCustomRelayErrorResponseBytes int64 = 64 << 10
|
||||
|
||||
var customRelayClient = service.CustomRelayHTTPClient
|
||||
var customRelayClient = service.CustomRelayHTTPClientForChannel
|
||||
|
||||
func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.Any("/ai/custom", func(c *gin.Context) {
|
||||
@@ -42,12 +42,17 @@ func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
proxyCustomRelayRequest(c, policy.Request)
|
||||
proxyCustomRelayRequestWithCapabilities(c, policy.Request, svc.DesktopLocalChannelsEnabled())
|
||||
})
|
||||
}
|
||||
|
||||
func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy) {
|
||||
target, err := service.ValidateCustomRelayURL(c.GetHeader("X-Canvas-Upstream-URL"))
|
||||
proxyCustomRelayRequestWithCapabilities(c, policy, false)
|
||||
}
|
||||
|
||||
func proxyCustomRelayRequestWithCapabilities(c *gin.Context, policy service.RuntimeRequestPolicy, desktopLocalChannelsEnabled bool) {
|
||||
requestedAllowLocal := strings.TrimSpace(c.GetHeader(service.LocalChannelRequestHeader)) == "1"
|
||||
target, err := service.ValidateCustomRelayChannelURL(c.GetHeader("X-Canvas-Upstream-URL"), c.GetHeader(service.LocalChannelBaseURLHeader), requestedAllowLocal, desktopLocalChannelsEnabled)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -111,7 +116,7 @@ func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes) * time.Minute).Do(upstreamReq)
|
||||
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes)*time.Minute, target, requestedAllowLocal, desktopLocalChannelsEnabled).Do(upstreamReq)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游连接失败"))
|
||||
return
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -47,6 +48,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/models")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
request.Header.Set(service.CustomRelayHeadersHeader, base64.StdEncoding.EncodeToString([]byte(`[{"name":"User-Agent","value":"Custom Relay Agent"},{"name":"X-Gateway-Tenant","value":"tenant-a"}]`)))
|
||||
request.Header.Set("Cookie", "browser=session")
|
||||
request.Header.Set("Origin", "https://canvas.example.com")
|
||||
@@ -54,7 +56,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -80,11 +82,12 @@ func TestCustomRelayReturnsVideoContent(t *testing.T) {
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/videos/task-1/content")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "video/mp4" || response.Body.String() != "video-content" {
|
||||
t.Fatalf("status = %d, content-type = %q, body = %q", response.Code, response.Header().Get("Content-Type"), response.Body.String())
|
||||
}
|
||||
@@ -112,11 +115,12 @@ func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1beta/models/gemini-test:generateContent")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "gemini")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -140,7 +144,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
context, _ := gin.CreateTestContext(w)
|
||||
context.Request = r
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
request, _ := http.NewRequest(http.MethodPost, proxy.URL, strings.NewReader(`{"model":"test"}`))
|
||||
@@ -149,6 +153,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
|
||||
request.Header.Set("Accept", "text/event-stream")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+"/v1/responses")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, upstream.URL)
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
@@ -184,7 +189,7 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
connected := false
|
||||
previous := customRelayClient
|
||||
customRelayClient = func(time.Duration) *http.Client {
|
||||
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client {
|
||||
connected = true
|
||||
return http.DefaultClient
|
||||
}
|
||||
@@ -196,11 +201,12 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", "https://127.0.0.1/v1/responses")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
markDesktopLocalRelayTestRequest(request, "https://127.0.0.1")
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -209,10 +215,131 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLocalCustomRelayRequiresServerCapabilityAcrossSupportedPaths(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
requests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if strings.HasSuffix(r.URL.Path, "/content") {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video-content"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
}{
|
||||
{name: "models", method: http.MethodGet, path: "/v1/models"},
|
||||
{name: "text", method: http.MethodPost, path: "/v1/responses", contentType: "application/json"},
|
||||
{name: "image", method: http.MethodPost, path: "/v1/images/generations", contentType: "application/json"},
|
||||
{name: "video submit", method: http.MethodPost, path: "/v1/videos", contentType: "application/json"},
|
||||
{name: "audio", method: http.MethodPost, path: "/v1/audio/speech", contentType: "application/json"},
|
||||
{name: "video poll", method: http.MethodGet, path: "/v1/videos/task-1"},
|
||||
{name: "video download", method: http.MethodGet, path: "/v1/videos/task-1/content"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := io.Reader(nil)
|
||||
if test.method == http.MethodPost {
|
||||
body = strings.NewReader(`{}`)
|
||||
}
|
||||
request := httptest.NewRequest(test.method, "/api/ai/custom", body)
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
|
||||
if test.contentType != "" {
|
||||
request.Header.Set("Content-Type", test.contentType)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
before := requests
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), false)
|
||||
if response.Code == http.StatusOK || requests != before {
|
||||
t.Fatalf("capability=false status=%d requests=%d->%d", response.Code, before, requests)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(test.method, "/api/ai/custom", bodyForRelayMethod(test.method))
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", upstream.URL+test.path)
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, upstream.URL)
|
||||
if test.contentType != "" {
|
||||
request.Header.Set("Content-Type", test.contentType)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
context, _ = gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code != http.StatusOK || requests != before+1 {
|
||||
t.Fatalf("capability=true status=%d body=%s requests=%d->%d", response.Code, response.Body.String(), before, requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLocalCustomRelayDoesNotFollowRedirects(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reachedTarget := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
reachedTarget = true
|
||||
}))
|
||||
defer target.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", target.URL+"/v1/models")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer source.Close()
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/ai/custom", nil)
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", source.URL+"/v1/models")
|
||||
request.Header.Set("X-Canvas-Upstream-Format", "openai")
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, source.URL)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
proxyCustomRelayRequestWithCapabilities(context, defaultCustomRelayTestPolicy(), true)
|
||||
if response.Code == http.StatusOK {
|
||||
t.Fatalf("redirect response status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if reachedTarget {
|
||||
t.Fatal("desktop local relay redirect target must not be reached")
|
||||
}
|
||||
}
|
||||
|
||||
func bodyForRelayMethod(method string) io.Reader {
|
||||
if method == http.MethodPost {
|
||||
return strings.NewReader(`{}`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markDesktopLocalRelayTestRequest(request *http.Request, baseURL string) {
|
||||
request.Header.Set(service.LocalChannelRequestHeader, "1")
|
||||
request.Header.Set(service.LocalChannelBaseURLHeader, baseURL)
|
||||
}
|
||||
|
||||
func useCustomRelayTestClient(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
previous := customRelayClient
|
||||
customRelayClient = func(time.Duration) *http.Client { return client }
|
||||
customRelayClient = func(time.Duration, *url.URL, bool, bool) *http.Client { return client }
|
||||
t.Cleanup(func() { customRelayClient = previous })
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,22 @@ import (
|
||||
)
|
||||
|
||||
type ModelChannel struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36"`
|
||||
Scope ChannelScope `json:"scope" gorm:"index;size:24"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
Name string `json:"name" gorm:"size:80"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"-"`
|
||||
SecretKey string `json:"-"`
|
||||
APIFormat string `json:"apiFormat" gorm:"size:24"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
|
||||
HeadersJSON string `json:"-" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36"`
|
||||
Scope ChannelScope `json:"scope" gorm:"index;size:24"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
Name string `json:"name" gorm:"size:80"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AllowLocalChannel bool `json:"allowLocalChannel" gorm:"default:false"`
|
||||
APIKey string `json:"-"`
|
||||
SecretKey string `json:"-"`
|
||||
APIFormat string `json:"apiFormat" gorm:"size:24"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
|
||||
HeadersJSON string `json:"-" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
type ChannelModel struct {
|
||||
|
||||
@@ -85,6 +85,7 @@ type AdminReferenceData struct {
|
||||
type ChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AllowLocalChannel *bool `json:"allowLocalChannel"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
ConcurrencyLimit *int `json:"concurrencyLimit"`
|
||||
@@ -95,22 +96,23 @@ type ChannelRequest struct {
|
||||
}
|
||||
|
||||
type PublicModelChannel struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Scope model.ChannelScope `json:"scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
Models []string `json:"models"`
|
||||
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
|
||||
Headers []OutboundHeader `json:"headers,omitempty"`
|
||||
HasAPIKey bool `json:"hasApiKey"`
|
||||
HasSecretKey bool `json:"hasSecretKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Scope model.ChannelScope `json:"scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AllowLocalChannel bool `json:"allowLocalChannel,omitempty"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
Models []string `json:"models"`
|
||||
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
|
||||
Headers []OutboundHeader `json:"headers,omitempty"`
|
||||
HasAPIKey bool `json:"hasApiKey"`
|
||||
HasSecretKey bool `json:"hasSecretKey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PublicChannelModelPrice struct {
|
||||
@@ -502,7 +504,7 @@ func (s *Service) CreateSystemChannel(actor *model.User, req ChannelRequest) (*P
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := channelFromRequest(req, model.ModelChannel{ID: newID(), UserID: actor.ID, Scope: model.ChannelScopeSystem, Enabled: true})
|
||||
channel, err := s.channelFromRequest(req, model.ModelChannel{ID: newID(), UserID: actor.ID, Scope: model.ChannelScopeSystem, Enabled: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -535,7 +537,7 @@ func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelR
|
||||
return nil, err
|
||||
}
|
||||
req = mergeChannelRequest(req, *channel)
|
||||
next, err := channelFromRequest(req, *channel)
|
||||
next, err := s.channelFromRequest(req, *channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -726,6 +728,10 @@ func (s *Service) APICallLogs(actor *model.User, limit int) ([]model.ApiCallLog,
|
||||
}
|
||||
|
||||
func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) {
|
||||
return (&Service{}).channelFromRequest(req, channel)
|
||||
}
|
||||
|
||||
func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
baseURL := strings.TrimSpace(req.BaseURL)
|
||||
if name == "" {
|
||||
@@ -734,7 +740,14 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
|
||||
if baseURL == "" {
|
||||
return channel, BadAuthRequest("请填写 Base URL")
|
||||
}
|
||||
if _, err := ValidateOutboundURL(baseURL); err != nil {
|
||||
requestedAllowLocal := channel.AllowLocalChannel
|
||||
if req.AllowLocalChannel != nil {
|
||||
requestedAllowLocal = *req.AllowLocalChannel
|
||||
}
|
||||
if requestedAllowLocal && !s.DesktopLocalChannelsEnabled() {
|
||||
return channel, BadAuthRequest("当前后端未启用本机渠道")
|
||||
}
|
||||
if _, err := s.validateChannelOutboundURL(baseURL, requestedAllowLocal, false); err != nil {
|
||||
return channel, err
|
||||
}
|
||||
models := uniqueNonEmpty(req.Models)
|
||||
@@ -745,6 +758,7 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
|
||||
}
|
||||
channel.Name = name
|
||||
channel.BaseURL = strings.TrimRight(baseURL, "/")
|
||||
channel.AllowLocalChannel = requestedAllowLocal
|
||||
if req.APIKey != "" {
|
||||
channel.APIKey = req.APIKey
|
||||
}
|
||||
@@ -784,6 +798,10 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
|
||||
if req.Headers == nil {
|
||||
req.Headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
}
|
||||
if req.AllowLocalChannel == nil {
|
||||
value := channel.AllowLocalChannel
|
||||
req.AllowLocalChannel = &value
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -827,22 +845,23 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
apiKey = channel.APIKey
|
||||
}
|
||||
return PublicModelChannel{
|
||||
ID: channel.ID,
|
||||
UserID: channel.UserID,
|
||||
Scope: channel.Scope,
|
||||
Enabled: channel.Enabled,
|
||||
Name: channel.Name,
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
APIFormat: channel.APIFormat,
|
||||
ConcurrencyLimit: channel.ConcurrencyLimit,
|
||||
Models: models,
|
||||
ModelCosts: modelCosts,
|
||||
Headers: headers,
|
||||
HasAPIKey: strings.TrimSpace(channel.APIKey) != "",
|
||||
HasSecretKey: strings.TrimSpace(channel.SecretKey) != "",
|
||||
CreatedAt: channel.CreatedAt,
|
||||
UpdatedAt: channel.UpdatedAt,
|
||||
ID: channel.ID,
|
||||
UserID: channel.UserID,
|
||||
Scope: channel.Scope,
|
||||
Enabled: channel.Enabled,
|
||||
Name: channel.Name,
|
||||
BaseURL: baseURL,
|
||||
AllowLocalChannel: admin && channel.AllowLocalChannel,
|
||||
APIKey: apiKey,
|
||||
APIFormat: channel.APIFormat,
|
||||
ConcurrencyLimit: channel.ConcurrencyLimit,
|
||||
Models: models,
|
||||
ModelCosts: modelCosts,
|
||||
Headers: headers,
|
||||
HasAPIKey: strings.TrimSpace(channel.APIKey) != "",
|
||||
HasSecretKey: strings.TrimSpace(channel.SecretKey) != "",
|
||||
CreatedAt: channel.CreatedAt,
|
||||
UpdatedAt: channel.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,9 @@ import (
|
||||
)
|
||||
|
||||
func TestChannelFromRequestStoresConnectionWithoutDefaultProtocol(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
|
||||
channel, err := channelFromRequest(ChannelRequest{
|
||||
Name: "混合模型渠道",
|
||||
BaseURL: server.URL + "/v1",
|
||||
BaseURL: "https://8.8.8.8/v1",
|
||||
APIKey: "access-key",
|
||||
SecretKey: "secret-key",
|
||||
ConcurrencyLimit: intPtr(6),
|
||||
@@ -112,7 +108,6 @@ func TestRuntimeConcurrencyUsesEnvironmentFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`))
|
||||
@@ -120,8 +115,9 @@ func TestFetchAdminChannelModelsReaddsDeletedModel(t *testing.T) {
|
||||
defer upstream.Close()
|
||||
|
||||
svc, db := newChannelModelTestService(t)
|
||||
svc.runtimeCapabilities = RuntimeCapabilities{desktopLocalChannels: true}
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
|
||||
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`}
|
||||
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: upstream.URL + "/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`, AllowLocalChannel: true}
|
||||
deleted := model.ChannelModel{ID: "deleted-model", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "model-a", BillingMode: "fixed_request", PriceVersion: 1}
|
||||
if err := db.Create(&channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
)
|
||||
|
||||
type ChannelModelsRequest struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AllowLocalChannel bool `json:"allowLocalChannel"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
}
|
||||
|
||||
type channelModelsPayload struct {
|
||||
@@ -90,11 +91,11 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
|
||||
}
|
||||
target = baseURL + "/models"
|
||||
}
|
||||
if _, err := ValidateOutboundURL(target); err != nil {
|
||||
if _, err := s.validateChannelOutboundURL(target, input.AllowLocalChannel, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
requestContext := withProviderOutboundPolicy(ctx, providerConfig{BaseURL: baseURL, AllowLocalChannel: s.effectiveAllowLocalChannel(input.AllowLocalChannel)})
|
||||
request, err := http.NewRequestWithContext(requestContext, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, BadAuthRequest("模型服务地址无效")
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User
|
||||
return nil, err
|
||||
}
|
||||
// 使用服务端保存的渠道密钥和请求头访问上游,避免敏感配置再次经过浏览器。
|
||||
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
|
||||
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, AllowLocalChannel: channel.AllowLocalChannel, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -242,6 +242,9 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" {
|
||||
return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key")
|
||||
}
|
||||
if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -277,6 +280,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
APIFormat: channel.APIFormat,
|
||||
InterfaceType: string(protocol),
|
||||
BaseURL: channel.BaseURL,
|
||||
AllowLocalChannel: s.effectiveAllowLocalChannel(channel.AllowLocalChannel),
|
||||
APIKey: channel.APIKey,
|
||||
SecretKey: channel.SecretKey,
|
||||
Headers: headers,
|
||||
@@ -305,6 +309,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
Service: s, UserID: actor.ID, ChannelID: channel.ID, Capability: capability,
|
||||
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue,
|
||||
})
|
||||
testCtx = withProviderOutboundPolicy(testCtx, input.Config)
|
||||
startedAt := time.Now()
|
||||
switch capability {
|
||||
case "text":
|
||||
|
||||
246
backend/internal/service/desktop_local_channel.go
Normal file
246
backend/internal/service/desktop_local_channel.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const LocalChannelRequestHeader = "X-Canvas-Allow-Local-Channel"
|
||||
const LocalChannelBaseURLHeader = "X-Canvas-Upstream-Base-URL"
|
||||
|
||||
// RuntimeCapabilities 只来自后端启动配置,HTTP 请求不得修改这些能力。
|
||||
type RuntimeCapabilities struct {
|
||||
desktopLocalChannels bool
|
||||
}
|
||||
|
||||
func RuntimeCapabilitiesForDeployment(bindAddr string, desktopLocalChannelsSetting string) RuntimeCapabilities {
|
||||
if !truthyRuntimeCapability(desktopLocalChannelsSetting) || !isExplicitDesktopLoopbackBind(bindAddr) {
|
||||
return RuntimeCapabilities{}
|
||||
}
|
||||
return RuntimeCapabilities{desktopLocalChannels: true}
|
||||
}
|
||||
|
||||
func truthyRuntimeCapability(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isExplicitDesktopLoopbackBind(bindAddr string) bool {
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(bindAddr))
|
||||
if err != nil || host != "127.0.0.1" {
|
||||
return false
|
||||
}
|
||||
value, err := strconv.Atoi(port)
|
||||
return err == nil && value >= 1 && value <= 65535
|
||||
}
|
||||
|
||||
func (s *Service) DesktopLocalChannelsEnabled() bool {
|
||||
return s != nil && s.runtimeCapabilities.desktopLocalChannels
|
||||
}
|
||||
|
||||
func (s *Service) effectiveAllowLocalChannel(requested bool) bool {
|
||||
return requested && s.DesktopLocalChannelsEnabled()
|
||||
}
|
||||
|
||||
type outboundLookup func(context.Context, string) ([]net.IP, error)
|
||||
|
||||
// OutboundPolicy 是独立的桌面 loopback 出口策略。它不读取任何 legacy private-upstream 环境变量。
|
||||
type OutboundPolicy struct {
|
||||
lookup outboundLookup
|
||||
loopbackOnly bool
|
||||
}
|
||||
|
||||
func desktopLoopbackOutboundPolicy(lookup outboundLookup) OutboundPolicy {
|
||||
if lookup == nil {
|
||||
lookup = func(ctx context.Context, host string) ([]net.IP, error) {
|
||||
return net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
}
|
||||
}
|
||||
return OutboundPolicy{lookup: lookup, loopbackOnly: true}
|
||||
}
|
||||
|
||||
func validateOutboundURLWithPolicy(rawURL string, policy OutboundPolicy) (*url.URL, error) {
|
||||
if len(strings.TrimSpace(rawURL)) > 4096 {
|
||||
return nil, BadAuthRequest("外部服务地址过长")
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" || !parsed.IsAbs() {
|
||||
return nil, BadAuthRequest("外部服务地址无效")
|
||||
}
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return nil, BadAuthRequest("外部服务地址只支持 http/https")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, BadAuthRequest("外部服务地址不允许包含认证信息")
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
return nil, BadAuthRequest("外部服务地址不允许包含片段")
|
||||
}
|
||||
if policy.loopbackOnly {
|
||||
if err := validateDesktopLoopbackPort(parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolveDesktopLoopbackHost(context.Background(), parsed.Hostname(), policy.lookup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateDesktopLoopbackPort(parsed *url.URL) error {
|
||||
if parsed == nil || strings.HasSuffix(parsed.Host, ":") {
|
||||
return BadAuthRequest("外部服务地址端口无效")
|
||||
}
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
for _, char := range port {
|
||||
if char < '0' || char > '9' {
|
||||
return BadAuthRequest("外部服务地址端口无效")
|
||||
}
|
||||
}
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return BadAuthRequest("外部服务地址端口无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDesktopLoopbackHost(ctx context.Context, host string, lookup outboundLookup) ([]net.IP, error) {
|
||||
if host == "127.0.0.1" {
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
}
|
||||
if !strings.EqualFold(host, "localhost") {
|
||||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||||
}
|
||||
addresses, err := lookup(ctx, host)
|
||||
if err != nil {
|
||||
return nil, BadAuthRequest("外部服务域名解析失败")
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return nil, BadAuthRequest("外部服务域名没有可用地址")
|
||||
}
|
||||
for _, ip := range addresses {
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||||
}
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func outboundHTTPClientWithPolicy(timeout time.Duration, policy OutboundPolicy) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: newOutboundTransportWithPolicy(policy),
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return errors.New("本机渠道不允许重定向")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newOutboundTransportWithPolicy(policy OutboundPolicy) *http.Transport {
|
||||
dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return &http.Transport{
|
||||
DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addresses, err := resolveDesktopLoopbackHost(ctx, host, policy.lookup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
for _, ip := range addresses {
|
||||
connection, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func isExactDesktopLoopbackHost(host string) bool {
|
||||
return host == "127.0.0.1" || strings.EqualFold(host, "localhost")
|
||||
}
|
||||
|
||||
func ValidateCustomRelayChannelURL(rawURL string, configuredBaseURL string, requestedAllowLocal bool, desktopLocalChannelsEnabled bool) (*url.URL, error) {
|
||||
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: desktopLocalChannelsEnabled}}
|
||||
target, err := svc.validateChannelOutboundURL(rawURL, requestedAllowLocal, true)
|
||||
if err != nil || !requestedAllowLocal || !desktopLocalChannelsEnabled || target == nil || !isExactDesktopLoopbackHost(target.Hostname()) {
|
||||
return target, err
|
||||
}
|
||||
base, err := svc.validateChannelOutboundURL(configuredBaseURL, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sameOutboundOrigin(base, target) {
|
||||
return nil, BadAuthRequest("本机渠道请求必须与配置的 Base URL 同源")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func sameOutboundOrigin(left *url.URL, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host)
|
||||
}
|
||||
|
||||
func CustomRelayHTTPClientForChannel(timeout time.Duration, target *url.URL, requestedAllowLocal bool, desktopLocalChannelsEnabled bool) *http.Client {
|
||||
if requestedAllowLocal && desktopLocalChannelsEnabled && target != nil && isExactDesktopLoopbackHost(target.Hostname()) {
|
||||
return outboundHTTPClientWithPolicy(timeout, desktopLoopbackOutboundPolicy(nil))
|
||||
}
|
||||
return CustomRelayHTTPClient(timeout)
|
||||
}
|
||||
|
||||
func (s *Service) ValidateChannelOutboundURL(rawURL string, requestedAllowLocal bool, customRelay bool) (*url.URL, error) {
|
||||
return s.validateChannelOutboundURL(rawURL, requestedAllowLocal, customRelay)
|
||||
}
|
||||
|
||||
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, target *url.URL, requestedAllowLocal bool) *http.Client {
|
||||
if s.effectiveAllowLocalChannel(requestedAllowLocal) && target != nil && isExactDesktopLoopbackHost(target.Hostname()) {
|
||||
return outboundHTTPClientWithPolicy(timeout, desktopLoopbackOutboundPolicy(nil))
|
||||
}
|
||||
return OutboundHTTPClient(timeout)
|
||||
}
|
||||
|
||||
func (s *Service) validateChannelOutboundURL(rawURL string, requestedAllowLocal bool, customRelay bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if requestedAllowLocal {
|
||||
if !s.effectiveAllowLocalChannel(true) {
|
||||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||||
}
|
||||
return validateOutboundURLWithPolicy(rawURL, desktopLoopbackOutboundPolicy(nil))
|
||||
}
|
||||
if err == nil && isExactDesktopLoopbackHost(parsed.Hostname()) {
|
||||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||||
}
|
||||
if customRelay {
|
||||
return ValidateCustomRelayURL(rawURL)
|
||||
}
|
||||
return ValidateOutboundURL(rawURL)
|
||||
}
|
||||
133
backend/internal/service/desktop_local_channel_dispatch_test.go
Normal file
133
backend/internal/service/desktop_local_channel_dispatch_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestDesktopLocalChannelModeRejectsPublicAcrossProductionDispatch(t *testing.T) {
|
||||
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
requested := true
|
||||
const publicBaseURL = "https://8.8.8.8"
|
||||
|
||||
if _, err := open.channelFromRequest(ChannelRequest{Name: "Public", BaseURL: publicBaseURL, AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem}); err == nil {
|
||||
t.Fatal("saving allowLocalChannel=true with a public Base URL must fail")
|
||||
}
|
||||
if _, err := ValidateCustomRelayChannelURL(publicBaseURL+"/v1/models", publicBaseURL, true, true); err == nil {
|
||||
t.Fatal("custom relay allowLocalChannel=true must not fall back to the public relay policy")
|
||||
}
|
||||
if _, err := open.resolveProviderConfig(providerConfig{BaseURL: publicBaseURL, AllowLocalChannel: true}); err == nil {
|
||||
t.Fatal("generation provider allowLocalChannel=true must reject a public Base URL before dispatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLocalChannelModeOwnsOutboundPolicy(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1,localhost,192.168.1.20,169.254.169.254")
|
||||
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
closed := &Service{}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://8.8.8.8/v1/models",
|
||||
"http://192.168.1.20:8000/v1/models",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://127.0.0.2:8000/v1/models",
|
||||
"http://localhost.:8000/v1/models",
|
||||
"http://[::1]:8000/v1/models",
|
||||
} {
|
||||
if _, err := open.validateChannelOutboundURL(rawURL, true, false); err == nil {
|
||||
t.Fatalf("allowLocalChannel=true must reject non-exact-loopback URL %q", rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://127.0.0.1:8000/v1/models",
|
||||
"http://localhost:8000/v1/models",
|
||||
} {
|
||||
for _, customRelay := range []bool{false, true} {
|
||||
_, err := open.validateChannelOutboundURL(rawURL, false, customRelay)
|
||||
if err == nil || !strings.Contains(err.Error(), "不允许访问本机、内网或链路本地地址") {
|
||||
t.Fatalf("allowLocalChannel=false must reject exact loopback %q with canonical error, customRelay=%v: %v", rawURL, customRelay, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, customRelay := range []bool{false, true} {
|
||||
if _, err := open.validateChannelOutboundURL("https://8.8.8.8/v1/models", false, customRelay); err != nil {
|
||||
t.Fatalf("ordinary public channel must keep the existing outbound policy, customRelay=%v: %v", customRelay, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://127.0.0.1:8000/v1/models",
|
||||
"https://8.8.8.8/v1/models",
|
||||
} {
|
||||
if _, err := closed.validateChannelOutboundURL(rawURL, true, false); err == nil {
|
||||
t.Fatalf("server capability=false must reject requested local mode for %q", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoURLParserDesktopLoopbackPortBoundary(t *testing.T) {
|
||||
tests := []struct {
|
||||
rawURL string
|
||||
wantErr bool
|
||||
wantHost string
|
||||
wantPort string
|
||||
}{
|
||||
{rawURL: "http://localhost:", wantHost: "localhost", wantPort: ""},
|
||||
{rawURL: "http://localhost:0", wantHost: "localhost", wantPort: "0"},
|
||||
{rawURL: "http://localhost:65536", wantHost: "localhost", wantPort: "65536"},
|
||||
{rawURL: "http://localhost:abc", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
parsed, err := url.Parse(test.rawURL)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("url.Parse(%q) error=nil, want parser error", test.rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse(%q) error=%v", test.rawURL, err)
|
||||
}
|
||||
if parsed.Hostname() != test.wantHost || parsed.Port() != test.wantPort {
|
||||
t.Fatalf("url.Parse(%q) hostname=%q port=%q", test.rawURL, parsed.Hostname(), parsed.Port())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLoopbackPolicyValidatesPortSyntaxAndRange(t *testing.T) {
|
||||
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
})
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://localhost",
|
||||
"http://localhost:1/v1/models",
|
||||
"http://127.0.0.1:65535/v1/models",
|
||||
} {
|
||||
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err != nil {
|
||||
t.Fatalf("strict local URL %q must be accepted: %v", rawURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://localhost:/v1/models",
|
||||
"http://localhost:0/v1/models",
|
||||
"http://localhost:65536/v1/models",
|
||||
"http://localhost:abc/v1/models",
|
||||
"http://localhost:+1/v1/models",
|
||||
"http://localhost:-1/v1/models",
|
||||
"http://localhost:0x50/v1/models",
|
||||
} {
|
||||
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err == nil {
|
||||
t.Fatalf("strict local URL %q must reject invalid port syntax/range", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestLocalExecutionPathsApplyLoopbackPolicyBeyondConfigResolution(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
requests := map[string]int{}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests[r.URL.Path]++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/contents/generations/tasks/cancel-task":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/video/generations/recovery-task":
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"IN_PROGRESS"}}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/chat/completions":
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"storyboard-ok"}}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
closed := &Service{}
|
||||
tests := []struct {
|
||||
name string
|
||||
config providerConfig
|
||||
path string
|
||||
runRequest func(context.Context, providerConfig) error
|
||||
}{
|
||||
{
|
||||
name: "cancel",
|
||||
config: providerConfig{BaseURL: upstream.URL + "/api/v3", APIKey: "key", Model: "video-model", InterfaceType: string(model.ChannelInterfaceVolcengineArkVideo), AllowLocalChannel: true},
|
||||
path: "/api/v3/contents/generations/tasks/cancel-task",
|
||||
runRequest: func(ctx context.Context, config providerConfig) error {
|
||||
return cancelProviderTask(ctx, config, "cancel-task")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recovery",
|
||||
config: providerConfig{BaseURL: upstream.URL + "/v1", APIKey: "key", Model: "video-model", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2), AllowLocalChannel: true},
|
||||
path: "/v1/video/generations/recovery-task",
|
||||
runRequest: func(ctx context.Context, config providerConfig) error {
|
||||
_, _, err := queryNewAPIChannel2VideoTask(ctx, canvasGenerationInput{Mode: "video", Config: config}, "recovery-task")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storyboard",
|
||||
config: providerConfig{BaseURL: upstream.URL + "/v1", APIKey: "key", Model: "text-model", InterfaceType: string(model.ChannelInterfaceChatCompletion), AllowLocalChannel: true},
|
||||
path: "/v1/chat/completions",
|
||||
runRequest: func(ctx context.Context, config providerConfig) error {
|
||||
result, err := runTextTask(ctx, canvasGenerationInput{Mode: "text", Prompt: "storyboard", Config: config})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result["text"] != "storyboard-ok" {
|
||||
return fmt.Errorf("storyboard text = %v", result["text"])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := closed.resolveProviderConfig(test.config); err == nil {
|
||||
t.Fatal("capability=false must reject local execution before any outbound request")
|
||||
}
|
||||
before := requests[test.path]
|
||||
resolved, err := open.resolveProviderConfig(test.config)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveProviderConfig() error = %v", err)
|
||||
}
|
||||
if err := test.runRequest(context.Background(), resolved); err == nil {
|
||||
t.Fatal("local request without loopback policy context must still fail SSRF validation")
|
||||
}
|
||||
if requests[test.path] != before {
|
||||
t.Fatal("request without loopback policy must not reach upstream")
|
||||
}
|
||||
ctx := withProviderOutboundPolicy(context.Background(), resolved)
|
||||
if err := test.runRequest(ctx, resolved); err != nil {
|
||||
t.Fatalf("local request with loopback policy error = %v", err)
|
||||
}
|
||||
if requests[test.path] != before+1 {
|
||||
t.Fatalf("upstream requests = %d, want %d", requests[test.path], before+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
203
backend/internal/service/desktop_local_channel_flow_test.go
Normal file
203
backend/internal/service/desktop_local_channel_flow_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestModelChannelAllowLocalDefaultsFalseAfterAutoMigrate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ModelChannel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel := model.ModelChannel{ID: "legacy-channel", Name: "Legacy", BaseURL: "https://example.com", Scope: model.ChannelScopeSystem, Enabled: true}
|
||||
if err := db.Omit("AllowLocalChannel").Create(&channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored model.ModelChannel
|
||||
if err := db.First(&stored, "id = ?", channel.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.AllowLocalChannel {
|
||||
t.Fatal("legacy row must migrate with allowLocalChannel=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemChannelAllowLocalSaveRequiresCapabilityAndPatchPreservesOmission(t *testing.T) {
|
||||
requested := true
|
||||
closed := &Service{}
|
||||
_, err := closed.channelFromRequest(ChannelRequest{Name: "Local", BaseURL: "http://127.0.0.1:8000", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem})
|
||||
if err == nil {
|
||||
t.Fatal("server capability=false must reject saving allowLocalChannel=true")
|
||||
}
|
||||
if _, err := closed.channelFromRequest(ChannelRequest{Name: "Forged", BaseURL: "https://8.8.8.8", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem}); err == nil {
|
||||
t.Fatal("server capability=false must reject persisting a forged allowLocalChannel=true flag even for a public Base URL")
|
||||
}
|
||||
|
||||
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
channel, err := open.channelFromRequest(ChannelRequest{Name: "Local", BaseURL: "http://127.0.0.1:8000", AllowLocalChannel: &requested}, model.ModelChannel{Scope: model.ChannelScopeSystem})
|
||||
if err != nil {
|
||||
t.Fatalf("channelFromRequest() error = %v", err)
|
||||
}
|
||||
if !channel.AllowLocalChannel || channel.BaseURL != "http://127.0.0.1:8000" {
|
||||
t.Fatalf("stored channel = %#v", channel)
|
||||
}
|
||||
|
||||
merged := mergeChannelRequest(ChannelRequest{Enabled: boolPtr(false)}, channel)
|
||||
if merged.AllowLocalChannel == nil || !*merged.AllowLocalChannel {
|
||||
t.Fatal("PATCH omitting allowLocalChannel must preserve stored true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicSystemChannelHidesLocalAddressAndLocalFlagFromOrdinaryUsers(t *testing.T) {
|
||||
channel := model.ModelChannel{
|
||||
ID: "system-local", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Desktop Local",
|
||||
BaseURL: "http://127.0.0.1:8000", APIKey: "secret", AllowLocalChannel: true,
|
||||
}
|
||||
admin := publicChannel(channel, true, nil)
|
||||
if admin.BaseURL != channel.BaseURL || !admin.AllowLocalChannel {
|
||||
t.Fatalf("admin channel = %#v", admin)
|
||||
}
|
||||
user := publicChannel(channel, false, nil)
|
||||
if user.BaseURL != "/api/ai/system/system-local" || user.APIKey != "system" || user.AllowLocalChannel {
|
||||
t.Fatalf("ordinary user channel leaked local configuration: %#v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"local-model"}]}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
actor := &model.User{ID: "user-1"}
|
||||
input := ChannelModelsRequest{BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true}
|
||||
|
||||
closed := &Service{}
|
||||
if _, err := closed.FetchChannelModelCatalog(context.Background(), actor, input); err == nil {
|
||||
t.Fatal("model catalog must reject forged allowLocalChannel=true when server capability=false")
|
||||
}
|
||||
|
||||
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
catalog, err := open.FetchChannelModelCatalog(context.Background(), actor, input)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchChannelModelCatalog() error = %v", err)
|
||||
}
|
||||
if len(catalog) != 1 || catalog[0].ID != "local-model" {
|
||||
t.Fatalf("catalog = %#v", catalog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel := model.ModelChannel{
|
||||
ID: "channel-local", UserID: "admin", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Local",
|
||||
BaseURL: "http://127.0.0.1:8000", APIKey: "key", APIFormat: "openai", ModelsJSON: `["text-model"]`, AllowLocalChannel: true,
|
||||
}
|
||||
channelModel := model.ChannelModel{ID: "model-local", ChannelID: channel.ID, ModelKey: "text-model", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, BillingMode: "fixed_request", Enabled: true, PriceConfigured: true}
|
||||
if err := db.Create(&channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&channelModel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := repository.New(db)
|
||||
open := &Service{repo: repo, runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
resolved, err := open.resolveProviderConfig(providerConfig{ChannelID: channel.ID, Model: "text-model"})
|
||||
if err != nil {
|
||||
t.Fatalf("desktop resolveProviderConfig() error = %v", err)
|
||||
}
|
||||
if !resolved.AllowLocalChannel {
|
||||
t.Fatal("desktop worker must retain the effective local policy in resolved provider config")
|
||||
}
|
||||
encoded, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var recovered providerConfig
|
||||
if err := json.Unmarshal(encoded, &recovered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closedAfterRestart := &Service{repo: repo, runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: false}}
|
||||
if _, err := closedAfterRestart.resolveProviderConfig(recovered); err == nil {
|
||||
t.Fatal("restart with server capability=false must reject a DB/task payload that still carries allowLocalChannel=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomTaskInputRecoveryReevaluatesLocalFlagAgainstServerCapability(t *testing.T) {
|
||||
input, err := normalizeTaskInput(map[string]any{
|
||||
"mode": "text",
|
||||
"config": providerConfig{BaseURL: "http://127.0.0.1:8000", APIKey: "key", Model: "text-model", AllowLocalChannel: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var recovered canvasGenerationInput
|
||||
if err := json.Unmarshal(encoded, &recovered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !recovered.Config.AllowLocalChannel {
|
||||
t.Fatal("task InputJSON recovery lost allowLocalChannel")
|
||||
}
|
||||
closed := &Service{}
|
||||
if _, err := closed.resolveProviderConfig(recovered.Config); err == nil {
|
||||
t.Fatal("recovered custom task must fail closed when server capability=false")
|
||||
}
|
||||
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
resolved, err := open.resolveProviderConfig(recovered.Config)
|
||||
if err != nil || !resolved.AllowLocalChannel {
|
||||
t.Fatalf("desktop recovered config = %#v, error = %v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemChannelOutboundClientUsesStoredLocalFlagAndCapability(t *testing.T) {
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer source.Close()
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
|
||||
closed := &Service{}
|
||||
if _, err := closed.ValidateChannelOutboundURL(source.URL, true, false); err == nil {
|
||||
t.Fatal("stored allowLocalChannel=true must remain closed when server capability=false")
|
||||
}
|
||||
|
||||
open := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
target, err := open.ValidateChannelOutboundURL(source.URL, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateChannelOutboundURL() error = %v", err)
|
||||
}
|
||||
request, _ := http.NewRequest(http.MethodGet, target.String(), nil)
|
||||
response, err := open.OutboundHTTPClientForChannel(time.Second, target, true).Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("OutboundHTTPClientForChannel() error = %v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool { return &value }
|
||||
226
backend/internal/service/desktop_local_channel_test.go
Normal file
226
backend/internal/service/desktop_local_channel_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestRuntimeCapabilitiesDoNotExposeWritableDesktopFlag(t *testing.T) {
|
||||
typeInfo := reflect.TypeOf(RuntimeCapabilities{})
|
||||
if field, ok := typeInfo.FieldByName("DesktopLocalChannels"); ok && field.IsExported() {
|
||||
t.Fatal("RuntimeCapabilities must not expose a writable DesktopLocalChannels field outside the service package")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCapabilitiesRequireExplicitDesktopFlagAndLoopbackBind(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bind string
|
||||
setting string
|
||||
want bool
|
||||
}{
|
||||
{name: "default disabled", bind: "127.0.0.1:8080", setting: "", want: false},
|
||||
{name: "explicit loopback desktop", bind: "127.0.0.1:8080", setting: "true", want: true},
|
||||
{name: "wildcard bind stays closed", bind: ":8080", setting: "true", want: false},
|
||||
{name: "all interfaces stays closed", bind: "0.0.0.0:8080", setting: "true", want: false},
|
||||
{name: "lan bind stays closed", bind: "192.168.1.5:8080", setting: "true", want: false},
|
||||
{name: "hostname bind is not a trusted bind identity", bind: "localhost:8080", setting: "true", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
capabilities := RuntimeCapabilitiesForDeployment(test.bind, test.setting)
|
||||
if capabilities.desktopLocalChannels != test.want {
|
||||
t.Fatalf("desktopLocalChannels = %v, want %v", capabilities.desktopLocalChannels, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelChannelLocalFlagHasFalseMigrationDefault(t *testing.T) {
|
||||
parsed, err := schema.Parse(&model.ModelChannel{}, &sync.Map{}, schema.NamingStrategy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
field := parsed.LookUpField("AllowLocalChannel")
|
||||
if field == nil || !field.HasDefaultValue || strings.ToLower(strings.TrimSpace(field.DefaultValue)) != "false" {
|
||||
t.Fatalf("AllowLocalChannel migration field = %#v", field)
|
||||
}
|
||||
if (model.ModelChannel{}).AllowLocalChannel {
|
||||
t.Fatal("zero/legacy ModelChannel must default allowLocalChannel=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeFeatureAvailabilityExposesDesktopCapabilityReadOnly(t *testing.T) {
|
||||
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
features := svc.withRuntimeCapabilities(publicFeatureAvailability(nil, defaultFeatureAvailability()))
|
||||
if !features.DesktopLocalChannelsEnabled {
|
||||
t.Fatal("public feature payload must expose the server-held desktop capability")
|
||||
}
|
||||
if writable := defaultFeatureAvailability(); writable.ShortDramaEnabled != features.ShortDramaEnabled || writable.TaskCenterEnabled != features.TaskCenterEnabled || writable.CreditsEnabled != features.CreditsEnabled {
|
||||
t.Fatal("runtime capability must not alter writable feature availability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLocalChannelRequiresRequestedFlagAndServerCapability(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1,localhost")
|
||||
|
||||
closed := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: false}}
|
||||
if _, err := closed.validateChannelOutboundURL("http://127.0.0.1:8000/v1/models", true, false); err == nil {
|
||||
t.Fatal("server capability=false must reject a forged requested allowLocalChannel=true even when legacy private overrides are enabled")
|
||||
}
|
||||
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")
|
||||
desktop := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
if _, err := desktop.validateChannelOutboundURL("http://127.0.0.1:8000/v1/models", false, false); err == nil {
|
||||
t.Fatal("requested allowLocalChannel=false must reject loopback")
|
||||
}
|
||||
for _, rawURL := range []string{
|
||||
"http://127.0.0.1:8000/v1/models",
|
||||
"http://localhost:8000/v1/models",
|
||||
} {
|
||||
if _, err := desktop.validateChannelOutboundURL(rawURL, true, false); err != nil {
|
||||
t.Fatalf("validateChannelOutboundURL(%q) error = %v", rawURL, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLoopbackPolicyRejectsNonExactHostForms(t *testing.T) {
|
||||
policy := desktopLoopbackOutboundPolicy(nil)
|
||||
for _, rawURL := range []string{
|
||||
"http://[::1]:8000/v1/models",
|
||||
"http://[::ffff:127.0.0.1]:8000/v1/models",
|
||||
"http://0.0.0.0:8000/v1/models",
|
||||
"http://127.0.0.2:8000/v1/models",
|
||||
"http://127.1:8000/v1/models",
|
||||
"http://2130706433:8000/v1/models",
|
||||
"http://0x7f000001:8000/v1/models",
|
||||
"http://0177.0.0.1:8000/v1/models",
|
||||
"http://localhost.:8000/v1/models",
|
||||
"http://x.localhost:8000/v1/models",
|
||||
"http://127.0.0.1.evil.test:8000/v1/models",
|
||||
"http://user:pass@127.0.0.1:8000/v1/models",
|
||||
"http://127.0.0.1:8000/v1/models#fragment",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://10.0.0.1:8000/v1/models",
|
||||
"http://192.168.1.20:8000/v1/models",
|
||||
} {
|
||||
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err == nil {
|
||||
t.Fatalf("validateOutboundURLWithPolicy(%q) should fail", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLocalRelayRequiresConfiguredBaseURLOrigin(t *testing.T) {
|
||||
if _, err := ValidateCustomRelayChannelURL("http://127.0.0.1:8000/v1/models", "http://127.0.0.1:8000", true, true); err != nil {
|
||||
t.Fatalf("same-origin local relay error = %v", err)
|
||||
}
|
||||
for _, baseURL := range []string{"", "http://127.0.0.1:8001", "http://localhost:8000"} {
|
||||
if _, err := ValidateCustomRelayChannelURL("http://127.0.0.1:8000/v1/models", baseURL, true, true); err == nil {
|
||||
t.Fatalf("configured Base URL %q must not authorize a different local origin", baseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLoopbackPolicyRejectsMixedLocalhostResolution(t *testing.T) {
|
||||
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("10.0.0.8")}, nil
|
||||
})
|
||||
if _, err := validateOutboundURLWithPolicy("http://localhost:8000/v1/models", policy); err == nil {
|
||||
t.Fatal("localhost must fail when any resolved address is not loopback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLoopbackPolicyFallsBackAcrossValidatedLocalhostAddresses(t *testing.T) {
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
defer server.Close()
|
||||
|
||||
_, port, err := net.SplitHostPort(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1")}, nil
|
||||
})
|
||||
request, _ := http.NewRequest(http.MethodGet, "http://localhost:"+port+"/health", nil)
|
||||
response, err := outboundHTTPClientWithPolicy(time.Second, policy).Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("dual-stack localhost request error = %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLoopbackPolicyRechecksDNSAtDialTime(t *testing.T) {
|
||||
calls := 0
|
||||
policy := desktopLoopbackOutboundPolicy(func(context.Context, string) ([]net.IP, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
}
|
||||
return []net.IP{net.ParseIP("10.0.0.9")}, nil
|
||||
})
|
||||
const rawURL = "http://localhost:18080/v1/models"
|
||||
if _, err := validateOutboundURLWithPolicy(rawURL, policy); err != nil {
|
||||
t.Fatalf("initial validation error = %v", err)
|
||||
}
|
||||
request, _ := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
_, err := outboundHTTPClientWithPolicy(500*time.Millisecond, policy).Do(request)
|
||||
if err == nil {
|
||||
t.Fatal("dial-time DNS rebinding to a non-loopback address must fail")
|
||||
}
|
||||
if calls < 2 {
|
||||
t.Fatalf("resolver calls = %d, want at least 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLoopbackPolicyNeverFollowsRedirects(t *testing.T) {
|
||||
reachedTarget := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
reachedTarget = true
|
||||
}))
|
||||
defer target.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", target.URL+"/secret")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer source.Close()
|
||||
|
||||
policy := desktopLoopbackOutboundPolicy(nil)
|
||||
request, _ := http.NewRequest(http.MethodGet, source.URL+"/start", nil)
|
||||
response, err := outboundHTTPClientWithPolicy(time.Second, policy).Do(request)
|
||||
if err == nil {
|
||||
if response != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
t.Fatal("desktop loopback client must reject redirects")
|
||||
}
|
||||
if reachedTarget {
|
||||
t.Fatal("redirect target must not receive the request")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "重定向") {
|
||||
t.Fatalf("redirect error = %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,10 @@ type FeatureAvailability struct {
|
||||
|
||||
type PublicFeatureAvailability struct {
|
||||
FeatureAvailability
|
||||
Configured bool `json:"configured"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
DesktopLocalChannelsEnabled bool `json:"desktopLocalChannelsEnabled"`
|
||||
Configured bool `json:"configured"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
func defaultFeatureAvailability() FeatureAvailability {
|
||||
@@ -42,7 +43,7 @@ func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicFeatureAvailability(setting, value), nil
|
||||
return s.withRuntimeCapabilities(publicFeatureAvailability(setting, value)), nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminFeatureAvailability(actor *model.User) (*PublicFeatureAvailability, error) {
|
||||
@@ -74,7 +75,7 @@ func (s *Service) UpdateFeatureAvailability(actor *model.User, value FeatureAvai
|
||||
if err := s.appendAdminAudit(actor, "feature_availability.update", "system_setting", featureAvailabilitySettingKey, "更新功能开放配置", map[string]any{"before": before, "after": value}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicFeatureAvailability(&setting, value), nil
|
||||
return s.withRuntimeCapabilities(publicFeatureAvailability(&setting, value)), nil
|
||||
}
|
||||
|
||||
func (s *Service) FeatureEnabled(feature string) (bool, error) {
|
||||
@@ -129,6 +130,13 @@ func (s *Service) readFeatureAvailability() (*model.SystemSetting, FeatureAvaila
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func (s *Service) withRuntimeCapabilities(result *PublicFeatureAvailability) *PublicFeatureAvailability {
|
||||
if result != nil {
|
||||
result.DesktopLocalChannelsEnabled = s.DesktopLocalChannelsEnabled()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func publicFeatureAvailability(setting *model.SystemSetting, value FeatureAvailability) *PublicFeatureAvailability {
|
||||
result := &PublicFeatureAvailability{FeatureAvailability: value, Configured: setting != nil}
|
||||
if setting != nil {
|
||||
|
||||
@@ -44,6 +44,7 @@ type providerConfig struct {
|
||||
APIFormat string `json:"apiFormat"`
|
||||
InterfaceType string `json:"interfaceType"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AllowLocalChannel bool `json:"allowLocalChannel"`
|
||||
APIKey string `json:"apiKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
@@ -100,6 +101,12 @@ type providerHTTPError struct {
|
||||
}
|
||||
|
||||
type providerAnalyticsKey struct{}
|
||||
type providerOutboundPolicyKey struct{}
|
||||
|
||||
type providerOutboundPolicyContext struct {
|
||||
scheme string
|
||||
host string
|
||||
}
|
||||
|
||||
type providerAnalyticsContext struct {
|
||||
Service *Service
|
||||
@@ -183,6 +190,7 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
return nil, err
|
||||
}
|
||||
input.Config = config
|
||||
ctx = withProviderOutboundPolicy(ctx, input.Config)
|
||||
if input.Mode == "image" && input.Metadata != nil {
|
||||
if err := s.applyGenerationStyleProfile(userID, taskProjectID, &input); err != nil {
|
||||
return nil, err
|
||||
@@ -567,9 +575,10 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
|
||||
channelID = systemChannelIDFromBaseURL(config.BaseURL)
|
||||
}
|
||||
if channelID == "" {
|
||||
if _, err := ValidateOutboundURL(config.BaseURL); err != nil {
|
||||
if _, err := s.validateChannelOutboundURL(config.BaseURL, config.AllowLocalChannel, false); err != nil {
|
||||
return providerConfig{}, err
|
||||
}
|
||||
config.AllowLocalChannel = s.effectiveAllowLocalChannel(config.AllowLocalChannel)
|
||||
return config, nil
|
||||
}
|
||||
channel, err := s.SystemChannel(channelID)
|
||||
@@ -587,6 +596,9 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
|
||||
if !stringInSlice(modelName, channelModelNames(*channel)) {
|
||||
return providerConfig{}, errors.New("当前系统渠道未授权该模型")
|
||||
}
|
||||
if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil {
|
||||
return providerConfig{}, err
|
||||
}
|
||||
config.ChannelID = channel.ID
|
||||
config.APIFormat = channel.APIFormat
|
||||
channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelName)
|
||||
@@ -601,6 +613,7 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
|
||||
config.APIFormat = "openai"
|
||||
}
|
||||
config.BaseURL = channel.BaseURL
|
||||
config.AllowLocalChannel = s.effectiveAllowLocalChannel(channel.AllowLocalChannel)
|
||||
config.APIKey = channel.APIKey
|
||||
config.SecretKey = channel.SecretKey
|
||||
config.Headers, err = ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
@@ -2664,6 +2677,28 @@ func doJSON(req *http.Request, target interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func withProviderOutboundPolicy(ctx context.Context, config providerConfig) context.Context {
|
||||
if !config.AllowLocalChannel {
|
||||
return ctx
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(config.BaseURL))
|
||||
if err != nil || !isExactDesktopLoopbackHost(parsed.Hostname()) {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, providerOutboundPolicyKey{}, providerOutboundPolicyContext{scheme: strings.ToLower(parsed.Scheme), host: strings.ToLower(parsed.Host)})
|
||||
}
|
||||
|
||||
func providerLoopbackPolicyForRequest(req *http.Request) (OutboundPolicy, bool) {
|
||||
policyContext, ok := req.Context().Value(providerOutboundPolicyKey{}).(providerOutboundPolicyContext)
|
||||
if !ok || policyContext.scheme == "" || policyContext.host == "" {
|
||||
return OutboundPolicy{}, false
|
||||
}
|
||||
if strings.ToLower(req.URL.Scheme) != policyContext.scheme || strings.ToLower(req.URL.Host) != policyContext.host {
|
||||
return OutboundPolicy{}, false
|
||||
}
|
||||
return desktopLoopbackOutboundPolicy(nil), true
|
||||
}
|
||||
|
||||
func doBinary(req *http.Request) ([]byte, string, error) {
|
||||
startedAt := time.Now()
|
||||
requestTimeout := providerHTTPTimeout
|
||||
@@ -2707,12 +2742,21 @@ func doBinary(req *http.Request) ([]byte, string, error) {
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
if _, err := ValidateOutboundURL(req.URL.String()); err != nil {
|
||||
policy, loopback := providerLoopbackPolicyForRequest(req)
|
||||
if loopback {
|
||||
if _, err := validateOutboundURLWithPolicy(req.URL.String(), policy); err != nil {
|
||||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||||
return nil, "", err
|
||||
}
|
||||
} else if _, err := ValidateOutboundURL(req.URL.String()); err != nil {
|
||||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||||
return nil, "", err
|
||||
}
|
||||
ApplyDefaultOutboundHeaders(req)
|
||||
client := OutboundHTTPClient(requestTimeout)
|
||||
if loopback {
|
||||
client = outboundHTTPClientWithPolicy(requestTimeout, policy)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if runtimeService != nil {
|
||||
|
||||
@@ -80,6 +80,7 @@ func (s *Service) requestProviderCancellation(ctx context.Context, task *model.T
|
||||
requestTask := *task
|
||||
requestTask.InputJSON = mustJSON(input)
|
||||
requestCtx = withProviderRequestKind(withProviderAnalytics(requestCtx, s, requestTask), "cancel")
|
||||
requestCtx = withProviderOutboundPolicy(requestCtx, input.Config)
|
||||
if err := cancelProviderTask(requestCtx, input.Config, task.ProviderRequestID); err != nil {
|
||||
return s.markProviderCancellationUncertain(task, "上游取消请求结果不明确,费用待核对:"+safeProviderLogError(err))
|
||||
}
|
||||
@@ -98,6 +99,7 @@ func (s *Service) reconcileProviderCancellation(ctx context.Context, task *model
|
||||
queryTask := *task
|
||||
queryTask.InputJSON = mustJSON(input)
|
||||
queryCtx := withProviderRequestKind(withProviderAnalytics(ctx, s, queryTask), "cancel-query")
|
||||
queryCtx = withProviderOutboundPolicy(queryCtx, input.Config)
|
||||
outcome, providerStatus, err := queryProviderCancellation(queryCtx, input.Config, task.ProviderRequestID)
|
||||
if err != nil {
|
||||
if task.ProviderCancelAttempts >= providerCancellationMaxAttempts-1 {
|
||||
|
||||
@@ -124,6 +124,7 @@ func (s *Service) queryFailedVideoTask(ctx context.Context, task *model.Task, cl
|
||||
defer func() { _ = s.repo.ReleaseTaskProviderRecovery(task.ID, owner) }()
|
||||
|
||||
queryCtx := withProviderAnalytics(ctx, s, *task)
|
||||
queryCtx = withProviderOutboundPolicy(queryCtx, input.Config)
|
||||
result, providerStatus, err := queryNewAPIChannel2VideoTask(queryCtx, input, providerRequestID)
|
||||
if err != nil {
|
||||
_ = s.log(task.UserID, task.ID, "error", "人工查询上游视频任务失败", err.Error())
|
||||
|
||||
@@ -1240,13 +1240,10 @@ func TestValidateGenerationInterfaceRejectsMismatchedType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessTaskValidatesInterfaceBeforeHydratingMedia(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
input := canvasGenerationInput{
|
||||
Mode: "video",
|
||||
Prompt: "make it move",
|
||||
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "text-model", InterfaceType: "chat-completion"},
|
||||
Config: providerConfig{BaseURL: "https://8.8.8.8/v1", APIKey: "key", Model: "text-model", InterfaceType: "chat-completion"},
|
||||
ReferenceImages: []providerMedia{{StorageKey: "resource:missing"}},
|
||||
}
|
||||
raw, _ := json.Marshal(input)
|
||||
|
||||
@@ -22,19 +22,20 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *repository.Repository
|
||||
dataDir string
|
||||
cancelMu sync.Mutex
|
||||
registrationMu sync.Mutex
|
||||
emailCodeMu sync.Mutex
|
||||
redeemBatchMu sync.Mutex
|
||||
storageMu sync.Mutex
|
||||
characterTaskMu sync.Mutex
|
||||
activeCancels map[string]context.CancelFunc
|
||||
pendingStorage map[string]int64
|
||||
coordinator *runtimeCoordinator
|
||||
runtimeErr error
|
||||
workerID string
|
||||
repo *repository.Repository
|
||||
dataDir string
|
||||
runtimeCapabilities RuntimeCapabilities
|
||||
cancelMu sync.Mutex
|
||||
registrationMu sync.Mutex
|
||||
emailCodeMu sync.Mutex
|
||||
redeemBatchMu sync.Mutex
|
||||
storageMu sync.Mutex
|
||||
characterTaskMu sync.Mutex
|
||||
activeCancels map[string]context.CancelFunc
|
||||
pendingStorage map[string]int64
|
||||
coordinator *runtimeCoordinator
|
||||
runtimeErr error
|
||||
workerID string
|
||||
}
|
||||
|
||||
const taskWorkerConcurrency = 3
|
||||
@@ -190,8 +191,12 @@ type agentStoryboardShot struct {
|
||||
}
|
||||
|
||||
func New(repo *repository.Repository, dataDir string) *Service {
|
||||
return NewWithRuntimeCapabilities(repo, dataDir, RuntimeCapabilities{})
|
||||
}
|
||||
|
||||
func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service {
|
||||
coordinator, err := newRuntimeCoordinator(repo.Dialect())
|
||||
return &Service{repo: repo, dataDir: dataDir, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID()}
|
||||
return &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, workerID: newID()}
|
||||
}
|
||||
|
||||
func (s *Service) StartWorker() {
|
||||
@@ -1131,6 +1136,7 @@ func (s *Service) processAgentStoryboardTask(ctx context.Context, task model.Tas
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctx = withProviderOutboundPolicy(ctx, config)
|
||||
plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, 0, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -1174,6 +1180,7 @@ func (s *Service) processStoryboardRowsTask(ctx context.Context, task model.Task
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctx = withProviderOutboundPolicy(ctx, config)
|
||||
plannerPrompt, err := s.buildAgentStoryboardPlannerPrompt(task.UserID, task.Prompt, input.Requirements, assets, input.ProjectStyle, input.Characters, input.ShotDuration, input.ShotCount)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
Reference in New Issue
Block a user