From 60f6a542821fdf7e32a4735e9d381d4a2c561db6 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 13 Jun 2026 00:33:21 +0800 Subject: [PATCH] feat(pluginstore, pluginhost): add plugin unload handling and preserve config during plugin updates - Introduced logic to handle plugin unloading during updates to prevent conflicts with loaded plugins. - Preserved existing plugin configurations during updates, ensuring seamless transitions and maintaining custom fields. - Added support for reloading the configuration after management saves changes. - Enhanced unit tests to validate unloading, configuration preservation, and reloading behaviors. --- internal/api/handlers/management/handler.go | 29 +++++ .../api/handlers/management/plugin_store.go | 45 +++++++- .../handlers/management/plugin_store_test.go | 79 ++++++++++++++ internal/api/server.go | 9 ++ internal/pluginhost/host.go | 103 +++++++++++++++++- internal/pluginhost/host_test.go | 61 +++++++++++ internal/pluginhost/test_helpers_test.go | 5 +- internal/pluginstore/install.go | 25 ++++- internal/pluginstore/install_test.go | 46 ++++++++ sdk/cliproxy/builder.go | 8 +- 10 files changed, 396 insertions(+), 14 deletions(-) diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index b89830a1..98d333d3 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -3,6 +3,7 @@ package management import ( + "context" "crypto/subtle" "fmt" "net/http" @@ -50,6 +51,7 @@ type Handler struct { postAuthHook coreauth.PostAuthHook postAuthPersistHook coreauth.PostAuthHook pluginHost *pluginhost.Host + configReloadHook func(context.Context, *config.Config) pluginStoreRegistryURL string pluginStoreHTTPClient pluginstore.HTTPDoer } @@ -137,6 +139,33 @@ func (h *Handler) SetPluginHost(host *pluginhost.Host) { h.mu.Unlock() } +// SetConfigReloadHook updates the callback used after management saves config changes. +func (h *Handler) SetConfigReloadHook(hook func(context.Context, *config.Config)) { + if h == nil { + return + } + h.mu.Lock() + h.configReloadHook = hook + h.mu.Unlock() +} + +func (h *Handler) reloadConfigAfterManagementSave(ctx context.Context, cfg *config.Config) { + if h == nil || cfg == nil { + return + } + h.mu.Lock() + hook := h.configReloadHook + host := h.pluginHost + h.mu.Unlock() + if hook != nil { + hook(ctx, cfg) + return + } + if host != nil { + host.ApplyConfig(ctx, cfg) + } +} + // SetLocalPassword configures the runtime-local password accepted for localhost requests. func (h *Handler) SetLocalPassword(password string) { h.localPassword = password } diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index 9a84f271..cab4f27f 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -13,6 +13,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + log "github.com/sirupsen/logrus" ) type pluginStoreListResponse struct { @@ -131,17 +132,41 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { } pluginIsLoaded := func() bool { return pluginLoaded(host, id) } + unloadedBeforeWrite := false result, errInstall := client.Install(c.Request.Context(), plugin, pluginstore.InstallOptions{ PluginsDir: pluginsDir, GOOS: goos, GOARCH: goarch, PluginLoaded: pluginIsLoaded, + BeforeWrite: func() error { + if !pluginIsLoaded() { + return nil + } + if host == nil { + return pluginstore.ErrLoadedPluginLocked + } + log.WithFields(log.Fields{ + "plugin_id": id, + "version": plugin.Version, + }).Info("pluginstore: unloading loaded plugin before install") + if !host.UnloadPlugin(id) && pluginIsLoaded() { + return pluginstore.ErrLoadedPluginLocked + } + unloadedBeforeWrite = true + return nil + }, }) if errInstall != nil { + if unloadedBeforeWrite { + h.mu.Lock() + reloadCfg := h.cfg + h.mu.Unlock() + h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg) + } if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) { c.JSON(http.StatusConflict, gin.H{ "error": "plugin_update_requires_restart", - "message": "loaded Windows plugins cannot be overwritten while the server is running", + "message": "loaded plugin cannot be overwritten while the server is running", "restart_required": true, }) return @@ -149,13 +174,11 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_install_failed", "message": errInstall.Error()}) return } - // Sample after the install so the response reflects the library state at - // the time the new file landed on disk. - restartRequired := pluginIsLoaded() + restartRequired := false h.mu.Lock() - defer h.mu.Unlock() if h.cfg == nil { + h.mu.Unlock() c.JSON(http.StatusInternalServerError, gin.H{ "error": "config_unavailable", "message": fmt.Sprintf("plugin file installed at %s but config is unavailable to enable it", result.Path), @@ -164,6 +187,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { return } if errEnable := h.enablePluginConfigLocked(id); errEnable != nil { + h.mu.Unlock() c.JSON(http.StatusInternalServerError, gin.H{ "error": "config_update_failed", "message": fmt.Sprintf("plugin file installed at %s but enabling it in config failed: %s", result.Path, errEnable.Error()), @@ -172,6 +196,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { return } if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil { + h.mu.Unlock() c.JSON(http.StatusInternalServerError, gin.H{ "error": "config_save_failed", "message": fmt.Sprintf("plugin file installed at %s but saving config failed: %s", result.Path, errSave.Error()), @@ -179,6 +204,16 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { }) return } + reloadCfg := h.cfg + h.mu.Unlock() + + h.reloadConfigAfterManagementSave(c.Request.Context(), reloadCfg) + log.WithFields(log.Fields{ + "plugin_id": result.ID, + "version": result.Version, + "path": result.Path, + "overwritten": result.Overwritten, + }).Info("pluginstore: plugin installed") c.JSON(http.StatusOK, pluginInstallResponse{ Status: "installed", diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go index a6ec621a..f707bab1 100644 --- a/internal/api/handlers/management/plugin_store_test.go +++ b/internal/api/handlers/management/plugin_store_test.go @@ -3,6 +3,7 @@ package management import ( "archive/zip" "bytes" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -156,6 +157,84 @@ func TestInstallPluginFromStoreWritesFileAndEnablesConfig(t *testing.T) { } } +func TestInstallPluginFromStoreOverwritesFilePreservesConfigAndReloads(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + pluginsDir := t.TempDir() + existingPath := filepath.Join(pluginsDir, "sample-provider"+managementPluginExtension(runtime.GOOS)) + if errWrite := os.WriteFile(existingPath, []byte("old-library-data"), 0o644); errWrite != nil { + t.Fatalf("WriteFile(%s) error = %v", existingPath, errWrite) + } + archiveData := makeManagementPluginStoreZip(t, "sample-provider"+managementPluginExtension(runtime.GOOS), "new-library-data") + archiveName := "sample-provider_0.1.0_" + runtime.GOOS + "_" + runtime.GOARCH + ".zip" + checksum := sha256.Sum256(archiveData) + h := &Handler{ + cfg: &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: pluginsDir, + Configs: map[string]config.PluginInstanceConfig{ + "sample-provider": pluginConfigFromYAML(t, "enabled: false\npriority: 5\nmode: fast\nextra: keep\n"), + }, + }, + }, + configFilePath: writeTestConfigFile(t), + pluginStoreRegistryURL: "https://registry.example/registry.json", + pluginStoreHTTPClient: fakePluginStoreHTTPClient{ + "https://registry.example/registry.json": registryJSON(t), + "https://api.github.com/repos/author-name/cliproxy-sample-provider-plugin/releases/tags/v0.1.0": []byte(`{ + "tag_name": "v0.1.0", + "assets": [ + {"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"}, + {"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"} + ] + }`), + "https://downloads.example/" + archiveName: archiveData, + "https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"), + }, + } + reloads := 0 + h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { + reloads++ + if cfg != h.cfg { + t.Fatalf("reload config = %p, want handler config %p", cfg, h.cfg) + } + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: "sample-provider"}} + c.Request = httptest.NewRequest(http.MethodPost, "/v0/management/plugin-store/sample-provider/install", nil) + + h.InstallPluginFromStore(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if reloads != 1 { + t.Fatalf("reloads = %d, want 1", reloads) + } + data, errRead := os.ReadFile(existingPath) + if errRead != nil { + t.Fatalf("ReadFile(%s) error = %v", existingPath, errRead) + } + if string(data) != "new-library-data" { + t.Fatalf("installed file = %q, want new-library-data", data) + } + item := h.cfg.Plugins.Configs["sample-provider"] + if item.Enabled == nil || !*item.Enabled { + t.Fatalf("plugin enabled = %#v, want true", item.Enabled) + } + if item.Priority != 5 { + t.Fatalf("plugin priority = %d, want 5", item.Priority) + } + raw := marshalPluginRaw(t, item) + if !strings.Contains(raw, "mode: fast") || !strings.Contains(raw, "extra: keep") { + t.Fatalf("plugin raw config lost custom fields:\n%s", raw) + } +} + func TestEnablePluginConfigLockedPreservesExistingFields(t *testing.T) { t.Parallel() diff --git a/internal/api/server.go b/internal/api/server.go index dc939b52..f7bed664 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -62,6 +62,7 @@ type serverOptionConfig struct { postAuthHook auth.PostAuthHook postAuthPersistHook auth.PostAuthHook pluginHost *pluginhost.Host + configReloadHook func(context.Context, *config.Config) } // ServerOption customises HTTP server construction. @@ -154,6 +155,13 @@ func WithPluginHost(host *pluginhost.Host) ServerOption { } } +// WithConfigReloadHook registers a callback used after management saves config changes. +func WithConfigReloadHook(hook func(context.Context, *config.Config)) ServerOption { + return func(cfg *serverOptionConfig) { + cfg.configReloadHook = hook + } +} + // Server represents the main API server. // It encapsulates the Gin engine, HTTP server, handlers, and configuration. type Server struct { @@ -316,6 +324,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // Initialize management handler s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager) s.mgmt.SetPluginHost(optionState.pluginHost) + s.mgmt.SetConfigReloadHook(optionState.configReloadHook) if optionState.localPassword != "" { s.mgmt.SetLocalPassword(optionState.localPassword) } diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go index 6f563b63..26e2a2d9 100644 --- a/internal/pluginhost/host.go +++ b/internal/pluginhost/host.go @@ -28,6 +28,12 @@ type modelExecutor interface { ExecuteModelStream(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) } +type pluginUnloadTarget struct { + id string + path string + client pluginClient +} + type Host struct { mu sync.Mutex loader pluginLoader @@ -180,6 +186,10 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) { } lp = loaded h.loaded[file.ID] = lp + log.WithFields(log.Fields{ + "plugin_id": file.ID, + "path": file.Path, + }).Info("pluginhost: plugin loaded") } plugin, okCall := h.callRegisterLocked(ctx, lp, item) @@ -213,19 +223,60 @@ func (h *Host) loadLocked(file pluginFile) (*loadedPlugin, error) { }, nil } +// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library. +func (h *Host) UnloadPlugin(id string) bool { + if h == nil { + return false + } + id = strings.TrimSpace(id) + if id == "" { + return false + } + + var target pluginUnloadTarget + h.mu.Lock() + lp := h.loaded[id] + if lp == nil { + h.mu.Unlock() + return false + } + target = pluginUnloadTarget{id: lp.id, path: lp.path, client: lp.client} + delete(h.loaded, id) + delete(h.fused, id) + records, enabled := h.snapshotWithoutPluginLocked(id) + h.removePluginRuntimeStateLocked(id) + h.snapshot.Store(&Snapshot{enabled: enabled, records: records}) + h.mu.Unlock() + + h.refreshThinkingProviders(records) + h.RegisterFrontendAuthProviders() + if target.client != nil { + target.client.Shutdown() + } + log.WithFields(log.Fields{ + "plugin_id": target.id, + "path": target.path, + }).Info("pluginhost: plugin unloaded") + return true +} + // ShutdownAll removes active plugin capabilities and closes all loaded dynamic libraries. func (h *Host) ShutdownAll() { if h == nil { return } - clients := make([]pluginClient, 0) + targets := make([]pluginUnloadTarget, 0) h.mu.Lock() for _, lp := range h.loaded { if lp == nil || lp.client == nil { continue } - clients = append(clients, lp.client) + targets = append(targets, pluginUnloadTarget{ + id: lp.id, + path: lp.path, + client: lp.client, + }) } h.loaded = make(map[string]*loadedPlugin) h.modelClientIDs = make(map[string]struct{}) @@ -243,9 +294,53 @@ func (h *Host) ShutdownAll() { h.refreshThinkingProviders(nil) h.RegisterFrontendAuthProviders() - for _, client := range clients { - client.Shutdown() + for _, target := range targets { + target.client.Shutdown() + log.WithFields(log.Fields{ + "plugin_id": target.id, + "path": target.path, + }).Info("pluginhost: plugin unloaded") + } +} + +func (h *Host) snapshotWithoutPluginLocked(id string) ([]capabilityRecord, bool) { + raw := h.snapshot.Load() + snap, _ := raw.(*Snapshot) + if snap == nil || len(snap.records) == 0 { + return nil, snap != nil && snap.enabled + } + records := make([]capabilityRecord, 0, len(snap.records)) + for _, record := range snap.records { + if record.id == id { + continue + } + records = append(records, record) + } + return records, snap.enabled +} + +func (h *Host) removePluginRuntimeStateLocked(id string) { + for key, record := range h.managementRoutes { + if record.pluginID == id { + delete(h.managementRoutes, key) + } + } + for key, record := range h.resourceRoutes { + if record.pluginID == id { + delete(h.resourceRoutes, key) + } + } + for name, record := range h.commandLineFlags { + if record.pluginID == id { + delete(h.commandLineFlags, name) + delete(h.commandLineHits, name) + } + } + if registration, ok := h.modelRegistrations[id]; ok { + delete(h.providerModels, registration.provider) } + delete(h.modelProviders, id) + delete(h.modelRegistrations, id) } func (h *Host) callRegisterLocked(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) { diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go index 0075204a..2272da8e 100644 --- a/internal/pluginhost/host_test.go +++ b/internal/pluginhost/host_test.go @@ -113,6 +113,67 @@ func TestPluginLoadedTracksLoadedPluginAfterDisabled(t *testing.T) { } } +func TestHostUnloadPluginTargetsOnlyRequestedPlugin(t *testing.T) { + loader := newTestSymbolLoader() + alpha := &testPlugin{ + registerResult: validTestPlugin("alpha"), + reconfigureResult: validTestPlugin("alpha"), + } + bravo := &testPlugin{ + registerResult: validTestPlugin("bravo"), + reconfigureResult: validTestPlugin("bravo"), + } + alphaLookup := newTestSymbolLookup(alpha) + bravoLookup := newTestSymbolLookup(bravo) + loader.lookups["alpha"] = alphaLookup + loader.lookups["bravo"] = bravoLookup + h := NewForTest(loader) + t.Cleanup(h.ShutdownAll) + cfg := &config.Config{ + Plugins: config.PluginsConfig{ + Enabled: true, + Dir: makePluginDir(t, "alpha", "bravo"), + }, + } + + h.ApplyConfig(context.Background(), cfg) + + if !h.UnloadPlugin("alpha") { + t.Fatal("UnloadPlugin(alpha) = false, want true") + } + if h.PluginLoaded("alpha") { + t.Fatal("PluginLoaded(alpha) = true, want false after targeted unload") + } + if !h.PluginLoaded("bravo") { + t.Fatal("PluginLoaded(bravo) = false, want true after alpha unload") + } + if alphaLookup.shutdownCalls != 1 { + t.Fatalf("alpha shutdown calls = %d, want 1", alphaLookup.shutdownCalls) + } + if bravoLookup.shutdownCalls != 0 { + t.Fatalf("bravo shutdown calls = %d, want 0", bravoLookup.shutdownCalls) + } + plugins := h.RegisteredPlugins() + if len(plugins) != 1 || plugins[0].ID != "bravo" { + t.Fatalf("RegisteredPlugins() = %#v, want only bravo", plugins) + } + + h.ApplyConfig(context.Background(), cfg) + + if loader.openCalls != 3 { + t.Fatalf("Open calls = %d, want 3", loader.openCalls) + } + if alpha.registerCalls != 2 { + t.Fatalf("alpha register calls = %d, want 2", alpha.registerCalls) + } + if bravo.registerCalls != 1 { + t.Fatalf("bravo register calls = %d, want 1", bravo.registerCalls) + } + if bravo.reconfigureCalls != 1 { + t.Fatalf("bravo reconfigure calls = %d, want 1", bravo.reconfigureCalls) + } +} + func TestHostApplyConfigRegistersPluginThinkingApplier(t *testing.T) { loader := newTestSymbolLoader() plugin := &testPlugin{ diff --git a/internal/pluginhost/test_helpers_test.go b/internal/pluginhost/test_helpers_test.go index f169ad70..da87e936 100644 --- a/internal/pluginhost/test_helpers_test.go +++ b/internal/pluginhost/test_helpers_test.go @@ -34,6 +34,7 @@ func (l *testSymbolLoader) Open(file pluginFile, host *Host) (pluginClient, erro type testSymbolLookup struct { plugin *testPlugin active pluginapi.Plugin + shutdownCalls int registerOverride func([]byte) pluginapi.Plugin reconfigureOverride func([]byte) pluginapi.Plugin } @@ -148,7 +149,9 @@ func (l *testSymbolLookup) Call(ctx context.Context, method string, request []by } } -func (l *testSymbolLookup) Shutdown() {} +func (l *testSymbolLookup) Shutdown() { + l.shutdownCalls++ +} func (l *testSymbolLookup) callLifecycle(request []byte, reload bool) ([]byte, error) { var req rpcLifecycleRequest diff --git a/internal/pluginstore/install.go b/internal/pluginstore/install.go index ef3e3e2c..900515c6 100644 --- a/internal/pluginstore/install.go +++ b/internal/pluginstore/install.go @@ -22,9 +22,12 @@ type InstallOptions struct { GOOS string GOARCH string // PluginLoaded reports whether the plugin's dynamic library is currently - // loaded by the running host. Loaded libraries cannot be overwritten on - // Windows, so installs targeting Windows are rejected while it returns true. + // loaded by the running host. Windows installs are rejected while it returns + // true unless BeforeWrite can unload the plugin before replacement. PluginLoaded func() bool + // BeforeWrite runs after the archive has been downloaded and verified, but + // before the target plugin file is replaced. + BeforeWrite func() error } // ErrLoadedPluginLocked is returned when an install would overwrite a plugin @@ -43,7 +46,7 @@ func (c Client) Install(ctx context.Context, plugin Plugin, options InstallOptio return InstallResult{}, errValidate } options = normalizeInstallOptions(options) - if loadedPluginInstallBlocked(options) { + if loadedPluginInstallBlocked(options) && options.BeforeWrite == nil { return InstallResult{}, ErrLoadedPluginLocked } release, errRelease := c.FetchRelease(ctx, plugin) @@ -100,6 +103,11 @@ func InstallArchive(archiveData []byte, plugin Plugin, options InstallOptions) ( } // Re-check immediately before writing: the plugin may have been loaded // while the archive was being downloaded and verified. + if options.BeforeWrite != nil { + if errBeforeWrite := options.BeforeWrite(); errBeforeWrite != nil { + return InstallResult{}, fmt.Errorf("prepare plugin write: %w", errBeforeWrite) + } + } if loadedPluginInstallBlocked(options) { return InstallResult{}, ErrLoadedPluginLocked } @@ -250,6 +258,17 @@ func writeFileAtomic(targetPath string, data []byte, mode os.FileMode) error { } closed = true if errRename := os.Rename(tempPath, targetPath); errRename != nil { + if runtime.GOOS == "windows" { + if errRemove := os.Remove(targetPath); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) { + return fmt.Errorf("remove old plugin file: %w", errRemove) + } + if errRenameRetry := os.Rename(tempPath, targetPath); errRenameRetry == nil { + removeTemp = false + return nil + } else { + return fmt.Errorf("install plugin file: %w", errRenameRetry) + } + } return fmt.Errorf("install plugin file: %w", errRename) } removeTemp = false diff --git a/internal/pluginstore/install_test.go b/internal/pluginstore/install_test.go index aacd8103..4beed53e 100644 --- a/internal/pluginstore/install_test.go +++ b/internal/pluginstore/install_test.go @@ -65,6 +65,52 @@ func TestInstallArchiveBlocksLoadedWindowsPluginBeforeWrite(t *testing.T) { } } +func TestInstallArchivePreparesLoadedWindowsPluginBeforeWrite(t *testing.T) { + t.Parallel() + + root := t.TempDir() + targetDir := filepath.Join(root, "windows", "amd64") + if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil { + t.Fatalf("MkdirAll() error = %v", errMkdir) + } + targetPath := filepath.Join(targetDir, "sample-provider.dll") + if errWrite := os.WriteFile(targetPath, []byte("old"), 0o644); errWrite != nil { + t.Fatalf("WriteFile() error = %v", errWrite) + } + loaded := true + prepared := false + + result, errInstall := InstallArchive(makeZip(t, map[string]string{ + "sample-provider.dll": "new", + }), testPlugin(), InstallOptions{ + PluginsDir: root, + GOOS: "windows", + GOARCH: "amd64", + PluginLoaded: func() bool { return loaded }, + BeforeWrite: func() error { + prepared = true + loaded = false + return nil + }, + }) + if errInstall != nil { + t.Fatalf("InstallArchive() error = %v", errInstall) + } + if !prepared { + t.Fatal("BeforeWrite was not called") + } + if !result.Overwritten { + t.Fatal("Overwritten = false, want true") + } + data, errRead := os.ReadFile(targetPath) + if errRead != nil { + t.Fatalf("ReadFile() error = %v", errRead) + } + if string(data) != "new" { + t.Fatalf("installed data = %q, want new", data) + } +} + func TestInstallArchiveWritesPlatformPlugin(t *testing.T) { t.Parallel() diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index 54a83c46..91c24913 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -286,7 +286,13 @@ func (b *Builder) Build() (*Service, error) { if b.postAuthHook != nil { service.serverOptions = append(service.serverOptions, api.WithPostAuthHook(b.postAuthHook)) } - service.serverOptions = append(service.serverOptions, api.WithPostAuthPersistHook(service.runtimeAuthSyncHook()), api.WithPluginHost(pluginHost)) + service.serverOptions = append(service.serverOptions, + api.WithPostAuthPersistHook(service.runtimeAuthSyncHook()), + api.WithPluginHost(pluginHost), + api.WithConfigReloadHook(func(ctx context.Context, cfg *config.Config) { + service.applyConfigUpdate(cfg) + }), + ) return service, nil } -- 2.51.2