diff --git a/.goreleaser.yml b/.goreleaser.yml index f291b7fb..1e944d1b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -8,6 +8,7 @@ builds: - linux - windows - darwin + - freebsd goarch: - amd64 - arm64 diff --git a/README_JA.md b/README_JA.md index 4dbd36bb..8d5cb2bc 100644 --- a/README_JA.md +++ b/README_JA.md @@ -30,6 +30,10 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB AICodeMirror AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini CLI向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:こちらのリンクから登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます! + +BmoPlus +本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらのBmoPlus AIアカウント専門店/代行チャージ経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます! + diff --git a/assets/bmoplus.png b/assets/bmoplus.png new file mode 100644 index 00000000..27b8df41 Binary files /dev/null and b/assets/bmoplus.png differ diff --git a/config.example.yaml b/config.example.yaml index 0ea51e5b..6d87ee18 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,6 +25,10 @@ remote-management: # Disable the bundled management control panel asset download and HTTP route when true. disable-control-panel: false + # Disable automatic periodic background updates of the management panel from GitHub (default: false). + # When enabled, the panel is only downloaded on first access if missing, and never auto-updated afterward. + # disable-auto-update-panel: false + # GitHub repository for the management control panel. Accepts a repository URL or releases API URL. panel-github-repository: 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center' diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index a0d9c159..5f067e49 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -550,10 +550,23 @@ func isRuntimeOnlyAuth(auth *coreauth.Auth) bool { return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true") } +func isUnsafeAuthFileName(name string) bool { + if strings.TrimSpace(name) == "" { + return true + } + if strings.ContainsAny(name, "/\\") { + return true + } + if filepath.VolumeName(name) != "" { + return true + } + return false +} + // Download single auth file by name func (h *Handler) DownloadAuthFile(c *gin.Context) { - name := c.Query("name") - if name == "" || strings.Contains(name, string(os.PathSeparator)) { + name := strings.TrimSpace(c.Query("name")) + if isUnsafeAuthFileName(name) { c.JSON(400, gin.H{"error": "invalid name"}) return } @@ -635,8 +648,8 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no files uploaded"}) return } - name := c.Query("name") - if name == "" || strings.Contains(name, string(os.PathSeparator)) { + name := strings.TrimSpace(c.Query("name")) + if isUnsafeAuthFileName(name) { c.JSON(400, gin.H{"error": "invalid name"}) return } @@ -869,7 +882,7 @@ func uniqueAuthFileNames(names []string) []string { func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string, int, error) { name = strings.TrimSpace(name) - if name == "" || strings.Contains(name, string(os.PathSeparator)) { + if isUnsafeAuthFileName(name) { return "", http.StatusBadRequest, fmt.Errorf("invalid name") } diff --git a/internal/api/handlers/management/auth_files_download_test.go b/internal/api/handlers/management/auth_files_download_test.go new file mode 100644 index 00000000..a2a20d30 --- /dev/null +++ b/internal/api/handlers/management/auth_files_download_test.go @@ -0,0 +1,62 @@ +package management + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestDownloadAuthFile_ReturnsFile(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + authDir := t.TempDir() + fileName := "download-user.json" + expected := []byte(`{"type":"codex"}`) + if err := os.WriteFile(filepath.Join(authDir, fileName), expected, 0o600); err != nil { + t.Fatalf("failed to write auth file: %v", err) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(fileName), nil) + h.DownloadAuthFile(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected download status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + if got := rec.Body.Bytes(); string(got) != string(expected) { + t.Fatalf("unexpected download content: %q", string(got)) + } +} + +func TestDownloadAuthFile_RejectsPathSeparators(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil) + + for _, name := range []string{ + "../external/secret.json", + `..\\external\\secret.json`, + "nested/secret.json", + `nested\\secret.json`, + } { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(name), nil) + h.DownloadAuthFile(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected %d for name %q, got %d with body %s", http.StatusBadRequest, name, rec.Code, rec.Body.String()) + } + } +} diff --git a/internal/api/handlers/management/auth_files_download_windows_test.go b/internal/api/handlers/management/auth_files_download_windows_test.go new file mode 100644 index 00000000..8c174ccf --- /dev/null +++ b/internal/api/handlers/management/auth_files_download_windows_test.go @@ -0,0 +1,51 @@ +//go:build windows + +package management + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + tempDir := t.TempDir() + authDir := filepath.Join(tempDir, "auth") + externalDir := filepath.Join(tempDir, "external") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatalf("failed to create auth dir: %v", err) + } + if err := os.MkdirAll(externalDir, 0o700); err != nil { + t.Fatalf("failed to create external dir: %v", err) + } + + secretName := "secret.json" + secretPath := filepath.Join(externalDir, secretName) + if err := os.WriteFile(secretPath, []byte(`{"secret":true}`), 0o600); err != nil { + t.Fatalf("failed to write external file: %v", err) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest( + http.MethodGet, + "/v0/management/auth-files/download?name="+url.QueryEscape("../external/"+secretName), + nil, + ) + h.DownloadAuthFile(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d with body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 487f873d..8c6f2f7b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -195,6 +195,9 @@ type RemoteManagement struct { SecretKey string `yaml:"secret-key"` // DisableControlPanel skips serving and syncing the bundled management UI when true. DisableControlPanel bool `yaml:"disable-control-panel"` + // DisableAutoUpdatePanel disables automatic periodic background updates of the management panel asset from GitHub. + // When false (the default), the background updater remains enabled; when true, the panel is only downloaded on first access if missing. + DisableAutoUpdatePanel bool `yaml:"disable-auto-update-panel"` // PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset. // Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint. PanelGitHubRepository string `yaml:"panel-github-repository"` diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 7284b729..ae2bc819 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -31,6 +31,7 @@ const ( httpUserAgent = "CLIProxyAPI-management-updater" managementSyncMinInterval = 30 * time.Second updateCheckInterval = 3 * time.Hour + maxAssetDownloadSize = 50 << 20 // 10 MB safety limit for management asset downloads ) // ManagementFileName exposes the control panel asset filename. @@ -88,6 +89,10 @@ func runAutoUpdater(ctx context.Context) { log.Debug("management asset auto-updater skipped: control panel disabled") return } + if cfg.RemoteManagement.DisableAutoUpdatePanel { + log.Debug("management asset auto-updater skipped: disable-auto-update-panel is enabled") + return + } configPath, _ := schedulerConfigPath.Load().(string) staticDir := StaticDir(configPath) @@ -259,7 +264,8 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL } if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { - log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) + log.Errorf("management asset digest mismatch: expected %s got %s — aborting update for safety", remoteHash, downloadedHash) + return nil, nil } if err = atomicWriteFile(localPath, data); err != nil { @@ -282,6 +288,9 @@ func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, loca return false } + log.Warnf("management asset downloaded from fallback URL without digest verification (hash=%s) — "+ + "enable verified GitHub updates by keeping disable-auto-update-panel set to false", downloadedHash) + if err = atomicWriteFile(localPath, data); err != nil { log.WithError(err).Warn("failed to persist fallback management control panel page") return false @@ -392,10 +401,13 @@ func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } - data, err := io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, maxAssetDownloadSize+1)) if err != nil { return nil, "", fmt.Errorf("read download body: %w", err) } + if int64(len(data)) > maxAssetDownloadSize { + return nil, "", fmt.Errorf("download exceeds maximum allowed size of %d bytes", maxAssetDownloadSize) + } sum := sha256.Sum256(data) return data, hex.EncodeToString(sum[:]), nil diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 112e286d..e9d8d35b 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -165,29 +165,22 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream // Process messages and transform them to Claude Code format if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { messageIndex := 0 - systemMessageIndex := -1 messages.ForEach(func(_, message gjson.Result) bool { role := message.Get("role").String() contentResult := message.Get("content") switch role { case "system": - if systemMessageIndex == -1 { - systemMsg := []byte(`{"role":"user","content":[]}`) - out, _ = sjson.SetRawBytes(out, "messages.-1", systemMsg) - systemMessageIndex = messageIndex - messageIndex++ - } if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" { textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String()) - out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart) + out, _ = sjson.SetRawBytes(out, "system.-1", textPart) } else if contentResult.Exists() && contentResult.IsArray() { contentResult.ForEach(func(_, part gjson.Result) bool { if part.Get("type").String() == "text" { textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String()) - out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart) + out, _ = sjson.SetRawBytes(out, "system.-1", textPart) } return true }) @@ -269,6 +262,16 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream } return true }) + + // Preserve a minimal conversational turn for system-only inputs. + // Claude payloads with top-level system instructions but no messages are risky for downstream validation. + if messageIndex == 0 { + system := gjson.GetBytes(out, "system") + if system.Exists() && system.IsArray() && len(system.Array()) > 0 { + fallbackMsg := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`) + out, _ = sjson.SetRawBytes(out, "messages.-1", fallbackMsg) + } + } } // Tools mapping: OpenAI tools -> Claude Code tools diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index ed84661d..ead08d72 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -135,3 +135,111 @@ func TestConvertOpenAIRequestToClaude_ToolResultURLImageOnly(t *testing.T) { t.Fatalf("Unexpected image URL: %q", got) } } + +func TestConvertOpenAIRequestToClaude_SystemRoleBecomesTopLevelSystem(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + system := resultJSON.Get("system") + if !system.IsArray() { + t.Fatalf("Expected top-level system array, got %s", system.Raw) + } + if len(system.Array()) != 1 { + t.Fatalf("Expected 1 system block, got %d. System: %s", len(system.Array()), system.Raw) + } + if got := system.Get("0.type").String(); got != "text" { + t.Fatalf("Expected system block type %q, got %q", "text", got) + } + if got := system.Get("0.text").String(); got != "You are a helpful assistant." { + t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got) + } + + messages := resultJSON.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("Expected remaining message role %q, got %q", "user", got) + } + if got := messages[0].Get("content.0.text").String(); got != "Hello" { + t.Fatalf("Expected user text %q, got %q", "Hello", got) + } +} + +func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSystem(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "system", "content": "Rule 1"}, + {"role": "system", "content": [{"type": "text", "text": "Rule 2"}]}, + {"role": "user", "content": "Hello"} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + system := resultJSON.Get("system").Array() + if len(system) != 2 { + t.Fatalf("Expected 2 system blocks, got %d. System: %s", len(system), resultJSON.Get("system").Raw) + } + if got := system[0].Get("text").String(); got != "Rule 1" { + t.Fatalf("Expected first system text %q, got %q", "Rule 1", got) + } + if got := system[1].Get("text").String(); got != "Rule 2" { + t.Fatalf("Expected second system text %q, got %q", "Rule 2", got) + } + + messages := resultJSON.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("Expected remaining message role %q, got %q", "user", got) + } + if got := messages[0].Get("content.0.text").String(); got != "Hello" { + t.Fatalf("Expected user text %q, got %q", "Hello", got) + } +} + +func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + system := resultJSON.Get("system").Array() + if len(system) != 1 { + t.Fatalf("Expected 1 system block, got %d. System: %s", len(system), resultJSON.Get("system").Raw) + } + if got := system[0].Get("text").String(); got != "You are a helpful assistant." { + t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got) + } + + messages := resultJSON.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("Expected 1 fallback message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("Expected fallback message role %q, got %q", "user", got) + } + if got := messages[0].Get("content.0.type").String(); got != "text" { + t.Fatalf("Expected fallback content type %q, got %q", "text", got) + } + if got := messages[0].Get("content.0.text").String(); got != "" { + t.Fatalf("Expected fallback text %q, got %q", "", got) + } +} diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 7997f04e..fccdaf8d 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -256,6 +256,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel { changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel)) } + if oldCfg.RemoteManagement.DisableAutoUpdatePanel != newCfg.RemoteManagement.DisableAutoUpdatePanel { + changes = append(changes, fmt.Sprintf("remote-management.disable-auto-update-panel: %t -> %t", oldCfg.RemoteManagement.DisableAutoUpdatePanel, newCfg.RemoteManagement.DisableAutoUpdatePanel)) + } oldPanelRepo := strings.TrimSpace(oldCfg.RemoteManagement.PanelGitHubRepository) newPanelRepo := strings.TrimSpace(newCfg.RemoteManagement.PanelGitHubRepository) if oldPanelRepo != newPanelRepo { diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index f35ceeea..c7b73f11 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -20,10 +20,11 @@ func TestBuildConfigChangeDetails(t *testing.T) { RestrictManagementToLocalhost: false, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: false, - SecretKey: "old", - DisableControlPanel: false, - PanelGitHubRepository: "repo-old", + AllowRemote: false, + SecretKey: "old", + DisableControlPanel: false, + DisableAutoUpdatePanel: false, + PanelGitHubRepository: "repo-old", }, OAuthExcludedModels: map[string][]string{ "providerA": {"m1"}, @@ -54,10 +55,11 @@ func TestBuildConfigChangeDetails(t *testing.T) { }, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: true, - SecretKey: "new", - DisableControlPanel: true, - PanelGitHubRepository: "repo-new", + AllowRemote: true, + SecretKey: "new", + DisableControlPanel: true, + DisableAutoUpdatePanel: true, + PanelGitHubRepository: "repo-new", }, OAuthExcludedModels: map[string][]string{ "providerA": {"m1", "m2"}, @@ -88,6 +90,7 @@ func TestBuildConfigChangeDetails(t *testing.T) { expectContains(t, details, "ampcode.upstream-url: http://old-upstream -> http://new-upstream") expectContains(t, details, "ampcode.model-mappings: updated (1 -> 2 entries)") expectContains(t, details, "remote-management.allow-remote: false -> true") + expectContains(t, details, "remote-management.disable-auto-update-panel: false -> true") expectContains(t, details, "remote-management.secret-key: updated") expectContains(t, details, "oauth-excluded-models[providera]: updated (1 -> 2 entries)") expectContains(t, details, "oauth-excluded-models[providerb]: added (1 entries)") @@ -265,9 +268,10 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { ModelMappings: []config.AmpModelMapping{{From: "a", To: "b"}}, }, RemoteManagement: config.RemoteManagement{ - DisableControlPanel: true, - PanelGitHubRepository: "new/repo", - SecretKey: "", + DisableControlPanel: true, + DisableAutoUpdatePanel: true, + PanelGitHubRepository: "new/repo", + SecretKey: "", }, SDKConfig: sdkconfig.SDKConfig{ RequestLog: true, @@ -299,6 +303,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { expectContains(t, details, "ampcode.restrict-management-to-localhost: false -> true") expectContains(t, details, "ampcode.upstream-api-key: removed") expectContains(t, details, "remote-management.disable-control-panel: false -> true") + expectContains(t, details, "remote-management.disable-auto-update-panel: false -> true") expectContains(t, details, "remote-management.panel-github-repository: old/repo -> new/repo") expectContains(t, details, "remote-management.secret-key: deleted") } @@ -336,10 +341,11 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { ForceModelMappings: false, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: false, - DisableControlPanel: false, - PanelGitHubRepository: "old/repo", - SecretKey: "old", + AllowRemote: false, + DisableControlPanel: false, + DisableAutoUpdatePanel: false, + PanelGitHubRepository: "old/repo", + SecretKey: "old", }, SDKConfig: sdkconfig.SDKConfig{ RequestLog: false, @@ -389,10 +395,11 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { ForceModelMappings: true, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: true, - DisableControlPanel: true, - PanelGitHubRepository: "new/repo", - SecretKey: "", + AllowRemote: true, + DisableControlPanel: true, + DisableAutoUpdatePanel: true, + PanelGitHubRepository: "new/repo", + SecretKey: "", }, SDKConfig: sdkconfig.SDKConfig{ RequestLog: true, @@ -460,6 +467,7 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { expectContains(t, changes, "oauth-excluded-models[p2]: added (1 entries)") expectContains(t, changes, "remote-management.allow-remote: false -> true") expectContains(t, changes, "remote-management.disable-control-panel: false -> true") + expectContains(t, changes, "remote-management.disable-auto-update-panel: false -> true") expectContains(t, changes, "remote-management.panel-github-repository: old/repo -> new/repo") expectContains(t, changes, "remote-management.secret-key: deleted") expectContains(t, changes, "openai-compatibility:")