mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/1Panel-dev/1Panel.git
synced 2026-09-20 16:13:59 +08:00
* feat: Optimize application upgrade logic * feat: Optimize application upgrade logic * feat: Optimize application upgrade logic
1630 lines
48 KiB
Go
1630 lines
48 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
|
|
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
"github.com/1Panel-dev/1Panel/agent/global"
|
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
|
agentenv "github.com/1Panel-dev/1Panel/agent/utils/env"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
|
|
"github.com/joho/godotenv"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AgentService struct{}
|
|
|
|
func NewIAgentService() IAgentService {
|
|
return &AgentService{}
|
|
}
|
|
|
|
type resolvedAgentAccountInput struct {
|
|
Provider string
|
|
APIKey string
|
|
BaseURL string
|
|
APIType string
|
|
AuthMode string
|
|
}
|
|
|
|
func ensureAgentAccountNameAvailable(provider, name string, excludeID uint) error {
|
|
opts := []repo.DBOption{repo.WithByProvider(provider), repo.WithByName(name)}
|
|
if excludeID > 0 {
|
|
opts = append(opts, repo.WithByNOTID(excludeID))
|
|
}
|
|
account, err := agentAccountRepo.GetFirst(opts...)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if account != nil && account.ID > 0 {
|
|
return buserr.New("ErrRecordExist")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func agentAccountUsedBySetting(tx *gorm.DB, accountID uint, statusKey, accountIDKey string) (bool, error) {
|
|
var settings []model.Setting
|
|
if err := tx.Where("key IN ?", []string{statusKey, accountIDKey}).Find(&settings).Error; err != nil {
|
|
return false, err
|
|
}
|
|
values := make(map[string]string, len(settings))
|
|
for _, setting := range settings {
|
|
values[setting.Key] = setting.Value
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(values[statusKey]), constant.StatusEnable) &&
|
|
strings.TrimSpace(values[accountIDKey]) == strconv.FormatUint(uint64(accountID), 10), nil
|
|
}
|
|
|
|
func loadOpenclawAgentByID(agentID uint) (*model.Agent, error) {
|
|
agent, err := agentRepo.GetFirst(repo.WithByID(agentID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if agent.AgentType != constant.AppOpenclaw {
|
|
return nil, fmt.Errorf("%s does not support", agent.AgentType)
|
|
}
|
|
return agent, nil
|
|
}
|
|
|
|
func ensureContainerRunning(containerName string) error {
|
|
status, err := checkContainerStatus(containerName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != "running" {
|
|
return fmt.Errorf("container %s is not running, please check and retry", containerName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
|
|
resolvedAPIKey := strings.TrimSpace(apiKey)
|
|
resolvedAPIType := strings.TrimSpace(apiType)
|
|
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
|
|
if err != nil {
|
|
return resolvedAgentAccountInput{}, err
|
|
}
|
|
resolvedBaseURL, err := providercatalog.ResolveBaseURL(provider, resolvedAPIType, baseURL)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "base url is required") {
|
|
return resolvedAgentAccountInput{}, buserr.New("ErrAgentBaseURLRequired")
|
|
}
|
|
return resolvedAgentAccountInput{}, err
|
|
}
|
|
modelID = strings.TrimSpace(modelID)
|
|
if modelID == "" {
|
|
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
|
|
}
|
|
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
|
|
if validateAvailability && (imageAPI || providercatalog.IsEmbeddingAPIType(resolvedAPIType) || !providercatalog.SkipVerification(provider)) {
|
|
if err := providercatalog.VerifyAccount(provider, resolvedAPIType, resolvedAuthMode, resolvedBaseURL, resolvedAPIKey, modelID); err != nil {
|
|
return resolvedAgentAccountInput{}, err
|
|
}
|
|
}
|
|
return resolvedAgentAccountInput{
|
|
Provider: provider,
|
|
APIKey: resolvedAPIKey,
|
|
BaseURL: resolvedBaseURL,
|
|
APIType: resolvedAPIType,
|
|
AuthMode: resolvedAuthMode,
|
|
}, nil
|
|
}
|
|
|
|
func readOpenclawConfig(configPath string) (map[string]interface{}, error) {
|
|
if strings.TrimSpace(configPath) == "" {
|
|
return nil, buserr.New("ErrRecordNotFound")
|
|
}
|
|
fileOp := files.NewFileOp()
|
|
content, err := fileOp.GetContent(configPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conf := map[string]interface{}{}
|
|
if err := json.Unmarshal(content, &conf); err != nil {
|
|
return nil, err
|
|
}
|
|
return conf, nil
|
|
}
|
|
|
|
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
|
|
ensureGatewaySecurityDefaults(conf)
|
|
ensureOpenclawUpdateDefaults(conf)
|
|
payload, err := json.MarshalIndent(conf, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileOp := files.NewFileOp()
|
|
return fileOp.SaveFile(configPath, string(payload), 0600)
|
|
}
|
|
|
|
func normalizeAllowedOrigins(origins []string) ([]string, error) {
|
|
if len(origins) == 0 {
|
|
return nil, nil
|
|
}
|
|
result := make([]string, 0, len(origins))
|
|
seen := make(map[string]struct{}, len(origins))
|
|
for _, origin := range origins {
|
|
origin = strings.TrimSpace(origin)
|
|
if origin == "" {
|
|
continue
|
|
}
|
|
normalized, err := normalizeAllowedOrigin(origin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := seen[normalized]; ok {
|
|
continue
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
result = append(result, normalized)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func normalizeAllowedOrigin(origin string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(origin))
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid allowed origin: %s", origin)
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return "", fmt.Errorf("invalid allowed origin: %s", origin)
|
|
}
|
|
if parsed.User != nil || parsed.Host == "" || parsed.Hostname() == "" {
|
|
return "", fmt.Errorf("invalid allowed origin: %s", origin)
|
|
}
|
|
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return "", fmt.Errorf("invalid allowed origin: %s", origin)
|
|
}
|
|
if pathValue := strings.TrimSpace(parsed.EscapedPath()); pathValue != "" && pathValue != "/" {
|
|
return "", fmt.Errorf("invalid allowed origin: %s", origin)
|
|
}
|
|
host := parsed.Hostname()
|
|
if strings.Contains(host, ":") {
|
|
host = "[" + host + "]"
|
|
}
|
|
normalized := parsed.Scheme + "://" + host
|
|
if parsed.Port() != "" {
|
|
normalized += ":" + parsed.Port()
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func extractSecurityConfig(conf map[string]interface{}) dto.AgentSecurityConfig {
|
|
result := dto.AgentSecurityConfig{AllowedOrigins: []string{}}
|
|
gateway, ok := conf["gateway"].(map[string]interface{})
|
|
if !ok {
|
|
return result
|
|
}
|
|
controlUi, ok := gateway["controlUi"].(map[string]interface{})
|
|
if !ok {
|
|
return result
|
|
}
|
|
switch values := controlUi["allowedOrigins"].(type) {
|
|
case []interface{}:
|
|
for _, value := range values {
|
|
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
|
result.AllowedOrigins = append(result.AllowedOrigins, strings.TrimSpace(text))
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func setSecurityConfig(conf map[string]interface{}, config dto.AgentSecurityConfig) {
|
|
ensureGatewaySecurityDefaults(conf)
|
|
gateway := ensureChildMap(conf, "gateway")
|
|
controlUi := ensureChildMap(gateway, "controlUi")
|
|
allowedOrigins := append([]string(nil), config.AllowedOrigins...)
|
|
if len(allowedOrigins) > 0 {
|
|
controlUi["allowedOrigins"] = allowedOrigins
|
|
} else {
|
|
delete(controlUi, "allowedOrigins")
|
|
}
|
|
}
|
|
|
|
func ensureGatewaySecurityDefaults(conf map[string]interface{}) {
|
|
gateway := ensureChildMap(conf, "gateway")
|
|
controlUi := ensureChildMap(gateway, "controlUi")
|
|
if _, ok := controlUi["dangerouslyDisableDeviceAuth"]; !ok {
|
|
controlUi["dangerouslyDisableDeviceAuth"] = true
|
|
}
|
|
delete(controlUi, "dangerouslyAllowHostHeaderOriginFallback")
|
|
setTrustedProxies(gateway)
|
|
}
|
|
|
|
func ensureOpenclawUpdateDefaults(conf map[string]interface{}) {
|
|
update := ensureChildMap(conf, "update")
|
|
if _, ok := update["checkOnStart"]; !ok {
|
|
update["checkOnStart"] = false
|
|
}
|
|
}
|
|
|
|
func setTrustedProxies(gateway map[string]interface{}) {
|
|
proxies := make([]string, 0, 4)
|
|
seen := map[string]struct{}{}
|
|
switch values := gateway["trustedProxies"].(type) {
|
|
case []interface{}:
|
|
for _, value := range values {
|
|
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
|
if text == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[text]; ok {
|
|
continue
|
|
}
|
|
seen[text] = struct{}{}
|
|
proxies = append(proxies, text)
|
|
}
|
|
case []string:
|
|
for _, value := range values {
|
|
text := strings.TrimSpace(value)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[text]; ok {
|
|
continue
|
|
}
|
|
seen[text] = struct{}{}
|
|
proxies = append(proxies, text)
|
|
}
|
|
}
|
|
if _, ok := seen[openclawTrustedProxyLoopback]; !ok {
|
|
proxies = append(proxies, openclawTrustedProxyLoopback)
|
|
}
|
|
gateway["trustedProxies"] = proxies
|
|
}
|
|
|
|
func extractBrowserConfig(conf map[string]interface{}) browserConfig {
|
|
result := browserConfig{
|
|
Enabled: true,
|
|
ExecutablePath: defaultBrowserExecutablePath,
|
|
Headless: true,
|
|
NoSandbox: true,
|
|
DefaultProfile: defaultBrowserProfile,
|
|
}
|
|
browser, ok := conf["browser"].(map[string]interface{})
|
|
if !ok {
|
|
return result
|
|
}
|
|
if enabled, ok := browser["enabled"].(bool); ok {
|
|
result.Enabled = enabled
|
|
}
|
|
if executablePath, ok := browser["executablePath"].(string); ok && strings.TrimSpace(executablePath) != "" {
|
|
result.ExecutablePath = executablePath
|
|
}
|
|
if headless, ok := browser["headless"].(bool); ok {
|
|
result.Headless = headless
|
|
}
|
|
if noSandbox, ok := browser["noSandbox"].(bool); ok {
|
|
result.NoSandbox = noSandbox
|
|
}
|
|
if defaultProfile, ok := browser["defaultProfile"].(string); ok && strings.TrimSpace(defaultProfile) != "" {
|
|
result.DefaultProfile = defaultProfile
|
|
}
|
|
return result
|
|
}
|
|
|
|
func setBrowserConfig(conf map[string]interface{}, config browserConfig) {
|
|
browser := ensureChildMap(conf, "browser")
|
|
browser["enabled"] = config.Enabled
|
|
browser["executablePath"] = defaultBrowserExecutablePath
|
|
browser["headless"] = config.Headless
|
|
browser["noSandbox"] = config.NoSandbox
|
|
if strings.TrimSpace(config.DefaultProfile) == "" {
|
|
browser["defaultProfile"] = defaultBrowserProfile
|
|
} else {
|
|
browser["defaultProfile"] = strings.TrimSpace(config.DefaultProfile)
|
|
}
|
|
}
|
|
|
|
func extractOtherConfig(conf map[string]interface{}) dto.AgentOtherConfig {
|
|
result := dto.AgentOtherConfig{
|
|
UserTimezone: resolveServerTimezone(),
|
|
BrowserEnabled: true,
|
|
NPMRegistry: defaultOpenclawNPMRegistry,
|
|
}
|
|
agents, ok := conf["agents"].(map[string]interface{})
|
|
if !ok {
|
|
browser := extractBrowserConfig(conf)
|
|
result.BrowserEnabled = browser.Enabled
|
|
return result
|
|
}
|
|
defaults, ok := agents["defaults"].(map[string]interface{})
|
|
if !ok {
|
|
browser := extractBrowserConfig(conf)
|
|
result.BrowserEnabled = browser.Enabled
|
|
return result
|
|
}
|
|
if timezone, ok := defaults["userTimezone"].(string); ok && strings.TrimSpace(timezone) != "" {
|
|
result.UserTimezone = strings.TrimSpace(timezone)
|
|
}
|
|
browser := extractBrowserConfig(conf)
|
|
result.BrowserEnabled = browser.Enabled
|
|
return result
|
|
}
|
|
|
|
func setOtherConfig(conf map[string]interface{}, config dto.AgentOtherConfig) {
|
|
agents := ensureChildMap(conf, "agents")
|
|
defaults := ensureChildMap(agents, "defaults")
|
|
timezone := strings.TrimSpace(config.UserTimezone)
|
|
if timezone == "" {
|
|
timezone = resolveServerTimezone()
|
|
}
|
|
defaults["userTimezone"] = timezone
|
|
setBrowserConfig(conf, browserConfig{
|
|
Enabled: config.BrowserEnabled,
|
|
ExecutablePath: defaultBrowserExecutablePath,
|
|
Headless: true,
|
|
NoSandbox: true,
|
|
DefaultProfile: defaultBrowserProfile,
|
|
})
|
|
}
|
|
|
|
func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem {
|
|
agentType := agent.AgentType
|
|
item := dto.AgentItem{
|
|
ID: agent.ID,
|
|
Name: agent.Name,
|
|
Remark: agent.Remark,
|
|
AgentType: agentType,
|
|
Provider: agent.Provider,
|
|
ProviderName: localizedAgentProviderName(agent.Provider),
|
|
Model: agent.Model,
|
|
APIType: agent.APIType,
|
|
BaseURL: agent.BaseURL,
|
|
APIKey: maskKey(agent.APIKey),
|
|
Token: agent.Token,
|
|
Status: agent.Status,
|
|
Message: agent.Message,
|
|
AppInstallID: agent.AppInstallID,
|
|
WebsiteID: agent.WebsiteID,
|
|
AccountID: agent.AccountID,
|
|
ConfigPath: agent.ConfigPath,
|
|
CreatedAt: agent.CreatedAt,
|
|
}
|
|
if appInstall != nil && appInstall.ID > 0 {
|
|
item.Container = appInstall.ContainerName
|
|
item.AppVersion = appInstall.Version
|
|
if agentType == constant.AppOpenclaw {
|
|
if isOpenclawHTTPSWindowVersion(appInstall.Version) {
|
|
item.WebUIPort = appInstall.HttpsPort
|
|
} else {
|
|
item.WebUIPort = appInstall.HttpPort
|
|
}
|
|
} else {
|
|
item.WebUIPort = appInstall.HttpPort
|
|
}
|
|
item.Path = appInstall.GetPath()
|
|
item.Status = appInstall.Status
|
|
item.Message = appInstall.Message
|
|
if envMap != nil {
|
|
if bridge, ok := envMap["PANEL_APP_PORT_BRIDGE"]; ok {
|
|
item.BridgePort = toInt(bridge)
|
|
}
|
|
}
|
|
if _, _, ok := agentDashboardAuthEnvKeys(agentType); ok {
|
|
auth := readAgentDashboardAuthFromInstall(appInstall, agentType)
|
|
item.DashboardUsername = auth.Username
|
|
item.DashboardPassword = auth.Password
|
|
}
|
|
}
|
|
return item
|
|
}
|
|
|
|
func localizedAgentProviderName(provider string) string {
|
|
if key := providercatalog.DisplayNameKey(provider); key != "" {
|
|
if name := strings.TrimSpace(i18n.GetMsgByKey(key)); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return providercatalog.DisplayName(provider)
|
|
}
|
|
|
|
func isAgentAppKey(appKey string) bool {
|
|
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent
|
|
}
|
|
|
|
func isOpenclawLegacyHTTPVersion(version string) bool {
|
|
return !common.CompareAppVersion(version, openclawHTTPSVersion)
|
|
}
|
|
|
|
func isOpenclawHTTPSWindowVersion(version string) bool {
|
|
return common.CompareAppVersion(version, openclawHTTPSVersion) && !common.CompareAppVersion(version, openclawHTTPVersion)
|
|
}
|
|
|
|
func isOpenclawCurrentHTTPVersion(version string) bool {
|
|
return common.CompareAppVersion(version, openclawHTTPVersion)
|
|
}
|
|
|
|
func shouldMigrateOpenclawHTTPSUpgrade(install *model.AppInstall, fromVersion, toVersion string) bool {
|
|
if install == nil || install.App.Key != constant.AppOpenclaw {
|
|
return false
|
|
}
|
|
return isOpenclawLegacyHTTPVersion(fromVersion) && isOpenclawHTTPSWindowVersion(toVersion)
|
|
}
|
|
|
|
func shouldMigrateOpenclawHTTPUpgrade(install *model.AppInstall, fromVersion, toVersion string) bool {
|
|
if install == nil || install.App.Key != constant.AppOpenclaw {
|
|
return false
|
|
}
|
|
return !isOpenclawCurrentHTTPVersion(fromVersion) && isOpenclawCurrentHTTPVersion(toVersion)
|
|
}
|
|
|
|
func migrateOpenclawProtocolUpgrade(install *model.AppInstall, fromVersion, toVersion string) error {
|
|
systemIP, _ := settingRepo.GetValueByKey("SystemIP")
|
|
return migrateOpenclawProtocolUpgradeWithSystemIP(install, fromVersion, toVersion, systemIP)
|
|
}
|
|
|
|
func migrateOpenclawProtocolUpgradeWithSystemIP(install *model.AppInstall, fromVersion, toVersion, systemIP string) error {
|
|
if shouldMigrateOpenclawHTTPSUpgrade(install, fromVersion, toVersion) {
|
|
return applyOpenclawProtocolUpgradeWithSystemIP(install, toVersion, systemIP, true)
|
|
}
|
|
if shouldMigrateOpenclawHTTPUpgrade(install, fromVersion, toVersion) {
|
|
return applyOpenclawProtocolUpgradeWithSystemIP(install, toVersion, systemIP, false)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyOpenclawProtocolUpgradeWithSystemIP(install *model.AppInstall, toVersion, systemIP string, useHTTPS bool) error {
|
|
if useHTTPS {
|
|
migrateOpenclawInstallPortsToHTTPS(install)
|
|
} else {
|
|
migrateOpenclawInstallPortsToHTTP(install)
|
|
}
|
|
configPath := path.Join(install.GetPath(), "data", "conf", "openclaw.json")
|
|
var allowedOrigins []string
|
|
if conf, err := readOpenclawConfig(configPath); err == nil {
|
|
allowedOrigins = extractSecurityConfig(conf).AllowedOrigins
|
|
}
|
|
originHost := strings.TrimSpace(systemIP)
|
|
if originHost == "" {
|
|
originHost = openclawAllowedOriginHost
|
|
}
|
|
port := install.HttpPort
|
|
if useHTTPS {
|
|
port = install.HttpsPort
|
|
}
|
|
if port > 0 {
|
|
allowedOrigin, err := buildOpenclawAllowedOrigin(openclawAllowedOriginScheme(toVersion), originHost, port)
|
|
if err == nil {
|
|
conf, err := readOpenclawConfig(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
allowedOrigins = []string{allowedOrigin}
|
|
setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins})
|
|
if err := writeOpenclawConfigRaw(configPath, conf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return migrateOpenclawInstallEnv(install, allowedOrigins)
|
|
}
|
|
|
|
func openclawAllowedOriginScheme(version string) string {
|
|
if isOpenclawHTTPSWindowVersion(version) {
|
|
return "https"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
func migrateOpenclawInstallPortsToHTTPS(install *model.AppInstall) {
|
|
if install == nil {
|
|
return
|
|
}
|
|
if install.HttpsPort == 0 && install.HttpPort > 0 {
|
|
install.HttpsPort = install.HttpPort
|
|
}
|
|
if install.HttpPort > 0 {
|
|
install.HttpPort = 0
|
|
}
|
|
}
|
|
|
|
func migrateOpenclawInstallPortsToHTTP(install *model.AppInstall) {
|
|
if install == nil {
|
|
return
|
|
}
|
|
if install.HttpPort == 0 && install.HttpsPort > 0 {
|
|
install.HttpPort = install.HttpsPort
|
|
}
|
|
if install.HttpsPort > 0 {
|
|
install.HttpsPort = 0
|
|
}
|
|
}
|
|
|
|
func migrateOpenclawInstallEnv(install *model.AppInstall, allowedOrigins []string) error {
|
|
if install == nil {
|
|
return nil
|
|
}
|
|
envMap := make(map[string]interface{})
|
|
if strings.TrimSpace(install.Env) != "" {
|
|
if err := json.Unmarshal([]byte(install.Env), &envMap); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if install.HttpsPort > 0 {
|
|
envMap["PANEL_APP_PORT_HTTPS"] = install.HttpsPort
|
|
} else {
|
|
delete(envMap, "PANEL_APP_PORT_HTTPS")
|
|
}
|
|
if install.HttpPort > 0 {
|
|
envMap["PANEL_APP_PORT_HTTP"] = install.HttpPort
|
|
}
|
|
if install.HttpPort == 0 {
|
|
delete(envMap, "PANEL_APP_PORT_HTTP")
|
|
}
|
|
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
|
|
envMap["ALLOWED_ORIGIN"] = allowedOrigin
|
|
} else {
|
|
delete(envMap, "ALLOWED_ORIGIN")
|
|
}
|
|
payload, err := json.Marshal(envMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
install.Env = string(payload)
|
|
return nil
|
|
}
|
|
|
|
func syncOpenclawAllowedOriginEnv(install *model.AppInstall, allowedOrigins []string) error {
|
|
if install == nil {
|
|
return nil
|
|
}
|
|
envMap := make(map[string]interface{})
|
|
if strings.TrimSpace(install.Env) != "" {
|
|
if err := json.Unmarshal([]byte(install.Env), &envMap); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
|
|
envMap["ALLOWED_ORIGIN"] = allowedOrigin
|
|
} else {
|
|
delete(envMap, "ALLOWED_ORIGIN")
|
|
}
|
|
payload, err := json.Marshal(envMap)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
install.Env = string(payload)
|
|
return nil
|
|
}
|
|
|
|
func firstAllowedOrigin(allowedOrigins []string) string {
|
|
for _, origin := range allowedOrigins {
|
|
trimmed := strings.TrimSpace(origin)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildOpenclawAllowedOrigin(scheme, host string, port int) (string, error) {
|
|
scheme = strings.TrimSpace(strings.ToLower(scheme))
|
|
host = strings.TrimSpace(host)
|
|
if (scheme != "http" && scheme != "https") || host == "" || port <= 0 {
|
|
return "", fmt.Errorf("invalid openclaw allowed origin")
|
|
}
|
|
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") && strings.Count(host, ":") > 1 {
|
|
host = "[" + host + "]"
|
|
}
|
|
return normalizeAllowedOrigin(fmt.Sprintf("%s://%s:%d", scheme, host, port))
|
|
}
|
|
|
|
func checkAgentUpgradable(install model.AppInstall) bool {
|
|
if install.ID == 0 || install.Version == "" {
|
|
return false
|
|
}
|
|
if install.App.ID == 0 {
|
|
return false
|
|
}
|
|
if ignoreUpdate(install) {
|
|
return false
|
|
}
|
|
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(install.App.ID))
|
|
if err != nil || len(details) == 0 {
|
|
return false
|
|
}
|
|
versions := make([]string, 0, len(details))
|
|
for _, item := range details {
|
|
ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(item.ID), appIgnoreUpgradeRepo.WithScope("version"))
|
|
if len(ignores) > 0 {
|
|
continue
|
|
}
|
|
if common.IsCrossVersion(install.Version, item.Version) && !install.App.CrossVersionUpdate {
|
|
continue
|
|
}
|
|
versions = append(versions, item.Version)
|
|
}
|
|
if len(versions) == 0 {
|
|
return false
|
|
}
|
|
versions = common.GetSortedVersions(versions)
|
|
lastVersion := versions[0]
|
|
if common.IsCrossVersion(install.Version, lastVersion) {
|
|
return install.App.CrossVersionUpdate
|
|
}
|
|
return common.CompareVersion(lastVersion, install.Version)
|
|
}
|
|
|
|
type openclawConfig struct {
|
|
Gateway gatewayConfig `json:"gateway"`
|
|
Agents agentsConfig `json:"agents"`
|
|
Browser browserConfig `json:"browser"`
|
|
Tools toolsConfig `json:"tools"`
|
|
Update updateConfig `json:"update"`
|
|
Models *modelsConfig `json:"models,omitempty"`
|
|
}
|
|
|
|
type toolsConfig struct {
|
|
Profile string `json:"profile,omitempty"`
|
|
Sessions toolSessionsConfig `json:"sessions,omitempty"`
|
|
}
|
|
|
|
type toolSessionsConfig struct {
|
|
Visibility string `json:"visibility,omitempty"`
|
|
}
|
|
|
|
type updateConfig struct {
|
|
CheckOnStart bool `json:"checkOnStart"`
|
|
}
|
|
|
|
type gatewayConfig struct {
|
|
Mode string `json:"mode"`
|
|
Bind string `json:"bind"`
|
|
Port int `json:"port"`
|
|
Auth gatewayAuth `json:"auth"`
|
|
ControlUi gatewayControlUi `json:"controlUi"`
|
|
TrustedProxies []string `json:"trustedProxies,omitempty"`
|
|
}
|
|
|
|
type gatewayControlUi struct {
|
|
DangerouslyDisableDeviceAuth bool `json:"dangerouslyDisableDeviceAuth"`
|
|
AllowedOrigins []string `json:"allowedOrigins,omitempty"`
|
|
}
|
|
|
|
type gatewayAuth struct {
|
|
Mode string `json:"mode"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type agentsConfig struct {
|
|
Defaults agentDefaults `json:"defaults"`
|
|
}
|
|
|
|
type agentDefaults struct {
|
|
UserTimezone string `json:"userTimezone,omitempty"`
|
|
Model modelRef `json:"model"`
|
|
Models map[string]map[string]interface{} `json:"models,omitempty"`
|
|
}
|
|
|
|
type modelRef struct {
|
|
Primary string `json:"primary"`
|
|
Fallbacks []string `json:"fallbacks,omitempty"`
|
|
}
|
|
|
|
type modelsConfig struct {
|
|
Mode string `json:"mode,omitempty"`
|
|
Providers map[string]modelProvider `json:"providers,omitempty"`
|
|
}
|
|
|
|
type modelProvider struct {
|
|
ApiKey string `json:"apiKey,omitempty"`
|
|
BaseUrl string `json:"baseUrl,omitempty"`
|
|
Api string `json:"api,omitempty"`
|
|
AuthHeader bool `json:"authHeader,omitempty"`
|
|
Models []modelEntry `json:"models,omitempty"`
|
|
}
|
|
|
|
type modelEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Input []string `json:"input,omitempty"`
|
|
}
|
|
|
|
func requiresOpenclawProviderModels(provider string) bool {
|
|
return provider != "gemini"
|
|
}
|
|
|
|
func applyOpenclawModelsConfig(conf map[string]interface{}, models *modelsConfig) error {
|
|
if models == nil {
|
|
delete(conf, "models")
|
|
return nil
|
|
}
|
|
modelsMap, err := structToMap(models)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conf["models"] = modelsMap
|
|
return nil
|
|
}
|
|
|
|
type browserConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
ExecutablePath string `json:"executablePath"`
|
|
Headless bool `json:"headless"`
|
|
NoSandbox bool `json:"noSandbox"`
|
|
DefaultProfile string `json:"defaultProfile"`
|
|
}
|
|
|
|
func writeOpenclawConfig(confDir string, account *model.AgentAccount, modelName, token string, allowedOrigins []string, fallbacks []string) error {
|
|
if strings.TrimSpace(confDir) == "" {
|
|
return fmt.Errorf("config dir is required")
|
|
}
|
|
if account == nil {
|
|
return fmt.Errorf("account is required")
|
|
}
|
|
if strings.TrimSpace(modelName) == "" {
|
|
return fmt.Errorf("model is required")
|
|
}
|
|
if strings.TrimSpace(token) == "" {
|
|
return fmt.Errorf("gateway token is required")
|
|
}
|
|
fileOp := files.NewFileOp()
|
|
if !fileOp.Stat(confDir) {
|
|
if err := fileOp.CreateDir(confDir, constant.DirPerm); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
primaryModel, defaultsModels, models, err := buildOpenclawModelsFromAccount(account, modelName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolvedFallbacks, err := resolveOpenclawFallbackModels(account, modelName, fallbacks)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg := openclawConfig{
|
|
Gateway: gatewayConfig{
|
|
Mode: "local",
|
|
Bind: "lan",
|
|
Port: openclawGatewayPort,
|
|
Auth: gatewayAuth{
|
|
Mode: "token",
|
|
Token: token,
|
|
},
|
|
ControlUi: gatewayControlUi{
|
|
DangerouslyDisableDeviceAuth: true,
|
|
AllowedOrigins: append([]string(nil), allowedOrigins...),
|
|
},
|
|
TrustedProxies: []string{openclawTrustedProxyLoopback},
|
|
},
|
|
Agents: agentsConfig{
|
|
Defaults: agentDefaults{
|
|
UserTimezone: resolveServerTimezone(),
|
|
Model: modelRef{Primary: primaryModel, Fallbacks: resolvedFallbacks},
|
|
Models: defaultsModels,
|
|
},
|
|
},
|
|
Browser: browserConfig{
|
|
Enabled: true,
|
|
ExecutablePath: defaultBrowserExecutablePath,
|
|
Headless: true,
|
|
NoSandbox: true,
|
|
DefaultProfile: defaultBrowserProfile,
|
|
},
|
|
Tools: toolsConfig{
|
|
Profile: defaultToolsProfile,
|
|
Sessions: toolSessionsConfig{
|
|
Visibility: defaultToolsSessionVisibility,
|
|
},
|
|
},
|
|
Update: updateConfig{
|
|
CheckOnStart: false,
|
|
},
|
|
Models: models,
|
|
}
|
|
|
|
configPath := path.Join(confDir, "openclaw.json")
|
|
conf := map[string]interface{}{}
|
|
if fileOp.Stat(configPath) {
|
|
existing, err := readOpenclawConfig(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conf = existing
|
|
}
|
|
if len(conf) == 0 {
|
|
initial, err := structToMap(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conf = initial
|
|
} else {
|
|
if err := applyOpenclawModelsConfig(conf, cfg.Models); err != nil {
|
|
return err
|
|
}
|
|
if _, ok := conf["browser"]; !ok {
|
|
browserMap, err := structToMap(cfg.Browser)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conf["browser"] = browserMap
|
|
}
|
|
toolsMap := ensureChildMap(conf, "tools")
|
|
if profile, ok := toolsMap["profile"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", profile)) == "" {
|
|
toolsMap["profile"] = defaultToolsProfile
|
|
}
|
|
sessionsMap := ensureChildMap(toolsMap, "sessions")
|
|
if visibility, ok := sessionsMap["visibility"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", visibility)) == "" {
|
|
sessionsMap["visibility"] = defaultToolsSessionVisibility
|
|
}
|
|
agentsMap := ensureChildMap(conf, "agents")
|
|
defaultsMap := ensureChildMap(agentsMap, "defaults")
|
|
if tz, ok := defaultsMap["userTimezone"]; !ok || strings.TrimSpace(fmt.Sprintf("%v", tz)) == "" {
|
|
defaultsMap["userTimezone"] = resolveServerTimezone()
|
|
}
|
|
modelMap := ensureChildMap(defaultsMap, "model")
|
|
modelMap["primary"] = cfg.Agents.Defaults.Model.Primary
|
|
if len(cfg.Agents.Defaults.Model.Fallbacks) > 0 {
|
|
modelMap["fallbacks"] = cfg.Agents.Defaults.Model.Fallbacks
|
|
} else {
|
|
delete(modelMap, "fallbacks")
|
|
}
|
|
if cfg.Agents.Defaults.Models != nil {
|
|
defaultsMap["models"] = cfg.Agents.Defaults.Models
|
|
}
|
|
|
|
ensureGatewaySecurityDefaults(conf)
|
|
gatewayMap := ensureChildMap(conf, "gateway")
|
|
if _, ok := gatewayMap["mode"]; !ok {
|
|
gatewayMap["mode"] = "local"
|
|
}
|
|
if _, ok := gatewayMap["bind"]; !ok {
|
|
gatewayMap["bind"] = "lan"
|
|
}
|
|
if _, ok := gatewayMap["port"]; !ok {
|
|
gatewayMap["port"] = openclawGatewayPort
|
|
}
|
|
authMap := ensureChildMap(gatewayMap, "auth")
|
|
if _, ok := authMap["mode"]; !ok {
|
|
authMap["mode"] = "token"
|
|
}
|
|
authMap["token"] = token
|
|
}
|
|
if allowedOrigins != nil {
|
|
setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins})
|
|
}
|
|
if err := writeOpenclawConfigRaw(configPath, conf); err != nil {
|
|
return err
|
|
}
|
|
envMap := map[string]string{
|
|
"OPENCLAW_GATEWAY_TOKEN": token,
|
|
}
|
|
order := []string{"OPENCLAW_GATEWAY_TOKEN"}
|
|
if envKey := providercatalog.EnvKey(account.Provider); envKey != "" && account.APIKey != "" {
|
|
envMap[envKey] = account.APIKey
|
|
order = append(order, envKey)
|
|
}
|
|
return writeAgentEnvMap(path.Join(confDir, ".env"), envMap, order)
|
|
}
|
|
|
|
func resolveOpenclawFallbackModels(account *model.AgentAccount, primaryModel string, fallbackIDs []string) ([]string, error) {
|
|
accountModels, err := loadAgentAccountModels(account)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resolveOpenclawFallbackModelsFromPool(account, accountModels, primaryModel, fallbackIDs, true)
|
|
}
|
|
|
|
func resolveOpenclawFallbackModelsFromPool(account *model.AgentAccount, accountModels []dto.AgentAccountModel, primaryModel string, fallbackIDs []string, strict bool) ([]string, error) {
|
|
if len(fallbackIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
result := make([]string, 0, len(fallbackIDs))
|
|
seen := make(map[string]struct{}, len(fallbackIDs))
|
|
for _, item := range fallbackIDs {
|
|
target := strings.TrimSpace(item)
|
|
if target == "" {
|
|
continue
|
|
}
|
|
accountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, target)
|
|
if err != nil {
|
|
if strict {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
if sameProviderModelID(account.Provider, accountModel.ID, primaryModel) {
|
|
if strict {
|
|
return nil, buserr.New("ErrAgentFallbackModelPrimary")
|
|
}
|
|
continue
|
|
}
|
|
resolvedPrimary, _, _, _, err := buildOpenclawAccountModelConfig(account, accountModel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := seen[resolvedPrimary]; ok {
|
|
if strict {
|
|
return nil, buserr.New("ErrAgentFallbackModelDuplicate")
|
|
}
|
|
continue
|
|
}
|
|
seen[resolvedPrimary] = struct{}{}
|
|
result = append(result, resolvedPrimary)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func extractOpenclawPrimaryModelID(conf map[string]interface{}, account *model.AgentAccount, accountModels []dto.AgentAccountModel) string {
|
|
modelMap := openclawModelRefMap(conf)
|
|
primary := strings.TrimSpace(fmt.Sprintf("%v", modelMap["primary"]))
|
|
if primary == "" {
|
|
return ""
|
|
}
|
|
resolved, err := buildOpenclawResolvedModelIDMap(account, accountModels)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return resolved[primary]
|
|
}
|
|
|
|
func extractOpenclawFallbackModelIDs(conf map[string]interface{}, account *model.AgentAccount, accountModels []dto.AgentAccountModel, primaryModel string) []string {
|
|
modelMap := openclawModelRefMap(conf)
|
|
raw, ok := modelMap["fallbacks"].([]interface{})
|
|
if !ok || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
resolved, err := buildOpenclawResolvedModelIDMap(account, accountModels)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(raw))
|
|
seen := make(map[string]struct{}, len(raw))
|
|
for _, item := range raw {
|
|
target := strings.TrimSpace(fmt.Sprintf("%v", item))
|
|
modelID, ok := resolved[target]
|
|
if !ok || sameProviderModelID(account.Provider, modelID, primaryModel) {
|
|
continue
|
|
}
|
|
if _, ok := seen[modelID]; ok {
|
|
continue
|
|
}
|
|
seen[modelID] = struct{}{}
|
|
result = append(result, modelID)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func openclawModelRefMap(conf map[string]interface{}) map[string]interface{} {
|
|
agentsMap, ok := conf["agents"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
defaultsMap, ok := agentsMap["defaults"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
modelMap, ok := defaultsMap["model"].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return modelMap
|
|
}
|
|
|
|
func buildOpenclawResolvedModelIDMap(account *model.AgentAccount, accountModels []dto.AgentAccountModel) (map[string]string, error) {
|
|
result := make(map[string]string, len(accountModels))
|
|
for _, item := range accountModels {
|
|
resolvedPrimary, _, _, _, err := buildOpenclawAccountModelConfig(account, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[resolvedPrimary] = item.ID
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func prepareOpenclawInstallFiles(appInstall *model.AppInstall, account *model.AgentAccount, modelName, token string, allowedOrigins []string) error {
|
|
if appInstall == nil {
|
|
return fmt.Errorf("app install is required")
|
|
}
|
|
confDir := path.Join(appInstall.GetPath(), "data", "conf")
|
|
if err := writeOpenclawConfig(confDir, account, modelName, token, allowedOrigins, nil); err != nil {
|
|
return err
|
|
}
|
|
dataDir := path.Join(appInstall.GetPath(), "data")
|
|
return files.NewFileOp().ChownR(dataDir, "1000", "1000", true)
|
|
}
|
|
|
|
func buildOpenclawModelsFromAccount(account *model.AgentAccount, selectedModel string) (string, map[string]map[string]interface{}, *modelsConfig, error) {
|
|
accountModels, err := loadAgentAccountModels(account)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
if len(accountModels) == 0 {
|
|
return "", nil, nil, buserr.New("ErrAgentModelNotInAccount")
|
|
}
|
|
if selectedModel == "" {
|
|
return "", nil, nil, buserr.New("ErrAgentModelNotInAccount")
|
|
}
|
|
selectedAccountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, selectedModel)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
selectedModel = selectedAccountModel.ID
|
|
|
|
providerKey := ""
|
|
providerCfg := modelProvider{}
|
|
entries := make([]modelEntry, 0, len(accountModels))
|
|
primaryModel := ""
|
|
defaultsModels := make(map[string]map[string]interface{}, len(accountModels))
|
|
for _, item := range accountModels {
|
|
resolvedPrimary, entry, key, baseCfg, err := buildOpenclawAccountModelConfig(account, item)
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
if providerKey == "" {
|
|
providerKey = key
|
|
providerCfg.ApiKey = baseCfg.ApiKey
|
|
providerCfg.BaseUrl = baseCfg.BaseUrl
|
|
providerCfg.Api = baseCfg.Api
|
|
providerCfg.AuthHeader = baseCfg.AuthHeader
|
|
}
|
|
entries = append(entries, entry)
|
|
defaultsModels[resolvedPrimary] = map[string]interface{}{}
|
|
if sameProviderModelID(account.Provider, item.ID, selectedModel) {
|
|
primaryModel = resolvedPrimary
|
|
}
|
|
}
|
|
if primaryModel == "" {
|
|
return "", nil, nil, buserr.New("ErrAgentModelNotInAccount")
|
|
}
|
|
if !requiresOpenclawProviderModels(account.Provider) {
|
|
return primaryModel, defaultsModels, nil, nil
|
|
}
|
|
providerCfg.Models = entries
|
|
return primaryModel, defaultsModels, &modelsConfig{
|
|
Mode: "merge",
|
|
Providers: map[string]modelProvider{
|
|
providerKey: providerCfg,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.AgentAccountModel) (string, modelEntry, string, modelProvider, error) {
|
|
providerPatch, err := providercatalog.BuildOpenClawProviderPatch(account.Provider, model.ID, account.APIType, account.AuthMode, account.BaseURL, account.APIKey)
|
|
if err != nil {
|
|
return "", modelEntry{}, "", modelProvider{}, err
|
|
}
|
|
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{
|
|
ApiKey: providerPatch.APIKey,
|
|
BaseUrl: providerPatch.BaseURL,
|
|
Api: providerPatch.APIType,
|
|
AuthHeader: providerPatch.AuthHeader,
|
|
}, nil
|
|
}
|
|
|
|
var openclawVisionModelPattern = regexp.MustCompile(`(?i)(\b(gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b|\bclaude-(3|4|sonnet|opus|haiku)\b|\bgemini\b|\b(qwen[\w.-]*-?vl|qwen-vl|qwen3\.[5-9]-plus)\b|\b(kimi-k2\.(5|6)|kimi-k2\.7-code|minimax-m3)\b|\b(vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v|omni)\b|(^|[-_/])vl([-_/]|$))`)
|
|
|
|
func buildOpenclawModelEntry(modelID string, model dto.AgentAccountModel) modelEntry {
|
|
name := strings.TrimSpace(model.Name)
|
|
if name == "" {
|
|
name = strings.TrimSpace(modelID)
|
|
}
|
|
entry := modelEntry{ID: strings.TrimSpace(modelID), Name: name}
|
|
if openclawVisionModelPattern.MatchString(modelID) {
|
|
entry.Input = []string{"text", "image"}
|
|
}
|
|
return entry
|
|
}
|
|
|
|
type openclawAccountModelRuntime struct {
|
|
StoredModel string
|
|
PrimaryModel string
|
|
APIType string
|
|
}
|
|
|
|
func buildOpenclawAccountModelRuntime(account *model.AgentAccount, model dto.AgentAccountModel) (openclawAccountModelRuntime, error) {
|
|
primaryModel, _, _, _, err := buildOpenclawAccountModelConfig(account, model)
|
|
if err != nil {
|
|
return openclawAccountModelRuntime{}, err
|
|
}
|
|
return openclawAccountModelRuntime{
|
|
StoredModel: model.ID,
|
|
PrimaryModel: primaryModel,
|
|
APIType: account.APIType,
|
|
}, nil
|
|
}
|
|
|
|
func resolveOpenclawAccountModelRuntimeByID(account *model.AgentAccount, modelID string) (openclawAccountModelRuntime, error) {
|
|
accountModels, err := loadAgentAccountModels(account)
|
|
if err != nil {
|
|
return openclawAccountModelRuntime{}, err
|
|
}
|
|
selectedAccountModel, err := requireAgentAccountModelForProvider(account.Provider, accountModels, modelID)
|
|
if err != nil {
|
|
return openclawAccountModelRuntime{}, err
|
|
}
|
|
return buildOpenclawAccountModelRuntime(account, selectedAccountModel)
|
|
}
|
|
|
|
func buildInitialAgentAccountModels(account *model.AgentAccount, requested []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) {
|
|
if account == nil {
|
|
return nil, fmt.Errorf("account is required")
|
|
}
|
|
if account.Provider != "custom" && requiresInitialAgentAccountModels(account.Provider) && len(requested) > 1 {
|
|
return nil, buserr.New("ErrAgentAccountSingleInitialModel")
|
|
}
|
|
if len(requested) > 0 {
|
|
return normalizeAgentAccountModels(account, requested)
|
|
}
|
|
defaultModels := providercatalog.DefaultModels(account.Provider, account.APIType)
|
|
if len(defaultModels) == 0 {
|
|
if requiresInitialAgentAccountModels(account.Provider) {
|
|
return nil, buserr.New("ErrAgentAccountModelsRequired")
|
|
}
|
|
return nil, nil
|
|
}
|
|
requested = make([]dto.AgentAccountModel, 0, len(defaultModels))
|
|
for _, item := range defaultModels {
|
|
requested = append(requested, dto.AgentAccountModel{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
})
|
|
}
|
|
return normalizeAgentAccountModels(account, requested)
|
|
}
|
|
|
|
func buildDiscoveredAgentAccountModels(modelIDs []string) []dto.AgentAccountModel {
|
|
models := make([]dto.AgentAccountModel, 0, len(modelIDs))
|
|
for _, modelID := range modelIDs {
|
|
models = append(models, dto.AgentAccountModel{
|
|
ID: modelID,
|
|
Name: modelID,
|
|
})
|
|
}
|
|
return models
|
|
}
|
|
|
|
func compactPersistedAgentAccountModelSortOrder(accountID uint) error {
|
|
rows, err := agentAccountModelRepo.List(repo.WithByAccountID(accountID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for index := range rows {
|
|
order := index + 1
|
|
if rows[index].SortOrder == order {
|
|
continue
|
|
}
|
|
rows[index].SortOrder = order
|
|
if err := agentAccountModelRepo.Save(&rows[index]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadAgentAccountModels(account *model.AgentAccount) ([]dto.AgentAccountModel, error) {
|
|
if account == nil {
|
|
return nil, fmt.Errorf("account is required")
|
|
}
|
|
return listPersistedAgentAccountModels(account.ID)
|
|
}
|
|
|
|
func MergeCatalogAgentAccountModelsForMigration(account *model.AgentAccount, existing []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) {
|
|
if account == nil {
|
|
return nil, fmt.Errorf("account is required")
|
|
}
|
|
meta, ok := providercatalog.Get(account.Provider)
|
|
if !ok || len(meta.Models) == 0 {
|
|
return append([]dto.AgentAccountModel(nil), existing...), nil
|
|
}
|
|
requested := append([]dto.AgentAccountModel(nil), existing...)
|
|
seen := make(map[string]struct{}, len(existing))
|
|
for _, item := range existing {
|
|
target := strings.TrimSpace(item.ID)
|
|
if target == "" {
|
|
continue
|
|
}
|
|
seen[target] = struct{}{}
|
|
}
|
|
for _, item := range meta.Models {
|
|
target := strings.TrimSpace(item.ID)
|
|
if _, ok := seen[target]; ok {
|
|
continue
|
|
}
|
|
requested = append(requested, dto.AgentAccountModel{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
})
|
|
}
|
|
if len(requested) == len(existing) {
|
|
return append([]dto.AgentAccountModel(nil), existing...), nil
|
|
}
|
|
return normalizeAgentAccountModels(account, requested)
|
|
}
|
|
|
|
func listPersistedAgentAccountModels(accountID uint) ([]dto.AgentAccountModel, error) {
|
|
if accountID == 0 {
|
|
return nil, nil
|
|
}
|
|
rows, err := agentAccountModelRepo.List(repo.WithByAccountID(accountID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]dto.AgentAccountModel, 0, len(rows))
|
|
for _, row := range rows {
|
|
result = append(result, dto.AgentAccountModel{
|
|
RecordID: row.ID,
|
|
ID: strings.TrimSpace(row.Model),
|
|
Name: strings.TrimSpace(row.Name),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func replacePersistedAgentAccountModelsWithTx(tx *gorm.DB, accountID uint, models []dto.AgentAccountModel) error {
|
|
if err := tx.Where("account_id = ?", accountID).Delete(&model.AgentAccountModel{}).Error; err != nil {
|
|
return err
|
|
}
|
|
for index, item := range models {
|
|
record := &model.AgentAccountModel{
|
|
AccountID: accountID,
|
|
Model: strings.TrimSpace(item.ID),
|
|
Name: strings.TrimSpace(item.Name),
|
|
SortOrder: index + 1,
|
|
}
|
|
if err := tx.Create(record).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeAgentAccountModels(account *model.AgentAccount, models []dto.AgentAccountModel) ([]dto.AgentAccountModel, error) {
|
|
requested := append([]dto.AgentAccountModel(nil), models...)
|
|
if len(requested) == 0 {
|
|
return nil, fmt.Errorf("model is required")
|
|
}
|
|
normalized := make([]dto.AgentAccountModel, 0, len(requested))
|
|
seen := make(map[string]struct{}, len(requested))
|
|
for _, item := range requested {
|
|
normalizedItem, err := normalizeAgentAccountModel(account, item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(normalizedItem.ID) == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[normalizedItem.ID]; ok {
|
|
continue
|
|
}
|
|
seen[normalizedItem.ID] = struct{}{}
|
|
normalized = append(normalized, normalizedItem)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil, fmt.Errorf("model is required")
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func normalizeAgentAccountModel(account *model.AgentAccount, model dto.AgentAccountModel) (dto.AgentAccountModel, error) {
|
|
modelID := strings.TrimSpace(model.ID)
|
|
if modelID == "" {
|
|
return dto.AgentAccountModel{}, fmt.Errorf("model is required")
|
|
}
|
|
modelID = providercatalog.NormalizeModelID(account.Provider, modelID)
|
|
name := strings.TrimSpace(model.Name)
|
|
if name == "" {
|
|
name = modelID
|
|
}
|
|
return dto.AgentAccountModel{
|
|
ID: modelID,
|
|
Name: name,
|
|
}, nil
|
|
}
|
|
|
|
func requiresInitialAgentAccountModels(provider string) bool {
|
|
switch provider {
|
|
case "custom", "vllm", "ollama":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func sameProviderModelID(provider, left, right string) bool {
|
|
leftTrimmed := strings.TrimSpace(left)
|
|
rightTrimmed := strings.TrimSpace(right)
|
|
if leftTrimmed == rightTrimmed {
|
|
return true
|
|
}
|
|
leftComparable := providercatalog.NormalizeModelID(provider, leftTrimmed)
|
|
rightComparable := providercatalog.NormalizeModelID(provider, rightTrimmed)
|
|
return leftComparable != "" && leftComparable == rightComparable
|
|
}
|
|
|
|
func findAgentAccountModelForProvider(provider string, models []dto.AgentAccountModel, modelID string) (dto.AgentAccountModel, bool) {
|
|
for _, item := range models {
|
|
if sameProviderModelID(provider, item.ID, modelID) {
|
|
return item, true
|
|
}
|
|
}
|
|
return dto.AgentAccountModel{}, false
|
|
}
|
|
|
|
func requireAgentAccountModelForProvider(provider string, models []dto.AgentAccountModel, modelID string) (dto.AgentAccountModel, error) {
|
|
selectedAccountModel, ok := findAgentAccountModelForProvider(provider, models, modelID)
|
|
if !ok {
|
|
return dto.AgentAccountModel{}, buserr.New("ErrAgentModelNotInAccount")
|
|
}
|
|
return selectedAccountModel, nil
|
|
}
|
|
|
|
func resolveAgentAccountVerifyModel(provider, requested string, models []dto.AgentAccountModel) (string, error) {
|
|
if len(models) == 0 {
|
|
return "", buserr.New("ErrAgentAccountModelsRequired")
|
|
}
|
|
if strings.TrimSpace(requested) == "" {
|
|
return models[0].ID, nil
|
|
}
|
|
selected, ok := findAgentAccountModelForProvider(provider, models, requested)
|
|
if !ok {
|
|
return "", buserr.New("ErrAgentModelNotInAccount")
|
|
}
|
|
return selected.ID, nil
|
|
}
|
|
|
|
func ensureAccountModelsNotBound(account *model.AgentAccount, models []dto.AgentAccountModel) error {
|
|
if account == nil || account.ID == 0 {
|
|
return nil
|
|
}
|
|
agents, err := agentRepo.List(repo.WithByAccountID(account.ID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, agent := range agents {
|
|
if _, ok := findAgentAccountModelForProvider(account.Provider, models, agent.Model); !ok {
|
|
return buserr.WithName("ErrAgentModelInUse", agent.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveServerTimezone() string {
|
|
timezone := strings.TrimSpace(common.LoadTimeZoneByCmd())
|
|
if timezone == "" {
|
|
return defaultUserTimezone
|
|
}
|
|
if _, err := time.LoadLocation(timezone); err != nil {
|
|
return defaultUserTimezone
|
|
}
|
|
return timezone
|
|
}
|
|
|
|
func extractStringList(value interface{}) []string {
|
|
switch values := value.(type) {
|
|
case []interface{}:
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
|
if text == "" {
|
|
continue
|
|
}
|
|
result = append(result, text)
|
|
}
|
|
return result
|
|
default:
|
|
return []string{}
|
|
}
|
|
}
|
|
|
|
func ensureChildMap(parent map[string]interface{}, key string) map[string]interface{} {
|
|
if child, ok := parent[key].(map[string]interface{}); ok {
|
|
return child
|
|
}
|
|
child := map[string]interface{}{}
|
|
parent[key] = child
|
|
return child
|
|
}
|
|
|
|
func structToMap(value interface{}) (map[string]interface{}, error) {
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := map[string]interface{}{}
|
|
if err := json.Unmarshal(payload, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func readInstallEnv(envStr string) map[string]interface{} {
|
|
if strings.TrimSpace(envStr) == "" {
|
|
return nil
|
|
}
|
|
data := map[string]interface{}{}
|
|
if err := json.Unmarshal([]byte(envStr), &data); err != nil {
|
|
return nil
|
|
}
|
|
return data
|
|
}
|
|
|
|
const (
|
|
hermesDashboardUsernameEnvKey = "HERMES_DASHBOARD_USERNAME"
|
|
hermesDashboardPasswordEnvKey = "HERMES_DASHBOARD_PASSWORD"
|
|
qwenPawAuthEnabledEnvKey = "QWENPAW_AUTH_ENABLED"
|
|
qwenPawAuthUsernameEnvKey = "QWENPAW_AUTH_USERNAME"
|
|
qwenPawAuthPasswordEnvKey = "QWENPAW_AUTH_PASSWORD"
|
|
)
|
|
|
|
type agentDashboardAuth struct {
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
func normalizeAgentDashboardAuth(username, password string) agentDashboardAuth {
|
|
auth := agentDashboardAuth{
|
|
Username: strings.TrimSpace(username),
|
|
Password: strings.TrimSpace(password),
|
|
}
|
|
if auth.Username == "" {
|
|
auth.Username = "admin"
|
|
}
|
|
if auth.Password == "" {
|
|
auth.Password = common.RandStr(8)
|
|
}
|
|
return auth
|
|
}
|
|
|
|
func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
|
|
switch agentType {
|
|
case constant.AppHermesAgent:
|
|
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
|
|
case constant.AppCopaw:
|
|
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func writeAgentDashboardAuthEnv(envPath, agentType string, auth agentDashboardAuth, overwrite bool) error {
|
|
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
|
|
if !ok {
|
|
return fmt.Errorf("dashboard auth is not supported for %s", agentType)
|
|
}
|
|
values := map[string]string{
|
|
usernameKey: auth.Username,
|
|
passwordKey: auth.Password,
|
|
}
|
|
order := []string{usernameKey, passwordKey}
|
|
if agentType == constant.AppCopaw {
|
|
values[qwenPawAuthEnabledEnvKey] = "true"
|
|
order = append([]string{qwenPawAuthEnabledEnvKey}, order...)
|
|
}
|
|
return upsertAgentEnv(envPath, values, order, overwrite)
|
|
}
|
|
|
|
func readAgentDashboardAuthEnv(envPath, agentType string) (agentDashboardAuth, error) {
|
|
usernameKey, passwordKey, ok := agentDashboardAuthEnvKeys(agentType)
|
|
if !ok {
|
|
return agentDashboardAuth{}, fmt.Errorf("dashboard auth is not supported for %s", agentType)
|
|
}
|
|
envMap, err := readAgentEnvMap(envPath)
|
|
if err != nil {
|
|
return agentDashboardAuth{}, err
|
|
}
|
|
return agentDashboardAuth{
|
|
Username: strings.TrimSpace(envMap[usernameKey]),
|
|
Password: strings.TrimSpace(envMap[passwordKey]),
|
|
}, nil
|
|
}
|
|
|
|
func readAgentDashboardAuthFromInstall(appInstall *model.AppInstall, agentType string) agentDashboardAuth {
|
|
if appInstall == nil || appInstall.ID == 0 {
|
|
return agentDashboardAuth{}
|
|
}
|
|
auth, err := readAgentDashboardAuthEnv(appInstall.GetEnvPath(), agentType)
|
|
if err != nil {
|
|
return agentDashboardAuth{}
|
|
}
|
|
return auth
|
|
}
|
|
|
|
func readAgentEnvMap(envPath string) (map[string]string, error) {
|
|
fileOp := files.NewFileOp()
|
|
if !fileOp.Stat(envPath) {
|
|
return map[string]string{}, nil
|
|
}
|
|
envMap, err := godotenv.Read(envPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return envMap, nil
|
|
}
|
|
|
|
func writeAgentEnvMap(envPath string, envMap map[string]string, order []string) error {
|
|
if len(envMap) == 0 {
|
|
return files.NewFileOp().SaveFile(envPath, "", 0600)
|
|
}
|
|
return agentenv.WriteWithOrder(envMap, envPath, order)
|
|
}
|
|
|
|
func upsertAgentEnv(envPath string, values map[string]string, order []string, overwrite bool) error {
|
|
envMap, err := readAgentEnvMap(envPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for key, value := range values {
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if overwrite || strings.TrimSpace(envMap[key]) == "" {
|
|
envMap[key] = value
|
|
}
|
|
}
|
|
return writeAgentEnvMap(envPath, envMap, order)
|
|
}
|
|
|
|
func maskKey(value string) string {
|
|
trim := strings.TrimSpace(value)
|
|
if len(trim) <= 6 {
|
|
return trim
|
|
}
|
|
return fmt.Sprintf("%s****%s", trim[:3], trim[len(trim)-3:])
|
|
}
|
|
|
|
func toInt(value interface{}) int {
|
|
switch v := value.(type) {
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
case string:
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
parsed, _ := strconv.Atoi(v)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func generateToken() string {
|
|
bytes := make([]byte, 24)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return ""
|
|
}
|
|
return hex.EncodeToString(bytes)
|
|
}
|
|
|
|
func asyncReportAIProviderInstall(provider string) {
|
|
if global.CONF.Base.Mode != "stable" || provider == "" {
|
|
return
|
|
}
|
|
go func(provider string) {
|
|
query := url.Values{}
|
|
query.Set("product", "ai-provider")
|
|
query.Set("type", "install")
|
|
query.Set("version", provider)
|
|
reqURL := "https://community.fit2cloud.com/installation-analytics?" + query.Encode()
|
|
_, _, _ = req_helper.HandleRequest(reqURL, http.MethodGet, constant.TimeOut5s)
|
|
}(provider)
|
|
}
|