diff --git a/sdk/pluginhost/host.go b/sdk/pluginhost/host.go index b9c5405f..22508d41 100644 --- a/sdk/pluginhost/host.go +++ b/sdk/pluginhost/host.go @@ -87,6 +87,14 @@ func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (* return h.inner.ParseAuth(ctx, req) } +// ParseAuths lets plugin auth providers expand one credential payload into multiple auth records. +func (h *Host) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) { + if h == nil || h.inner == nil { + return nil, false, nil + } + return h.inner.ParseAuths(ctx, req) +} + // ModelsForAuth lets plugin model providers discover auth-bound models. func (h *Host) ModelsForAuth(ctx context.Context, auth *coreauth.Auth) AuthModelResult { if h == nil || h.inner == nil { -- 2.51.2 From df10a5b1c7697706d3317c73e384f98eac9125ba Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:29:13 +0800 Subject: [PATCH 2/2] feat(pluginhost): add shadow plugin management and cleanup functionality --- internal/pluginhost/loader_windows.go | 195 +++++++++++++++++++-- internal/pluginhost/loader_windows_test.go | 165 +++++++++++++++++ sdk/pluginhost/host.go | 13 ++ 3 files changed, 362 insertions(+), 11 deletions(-) create mode 100644 internal/pluginhost/loader_windows_test.go diff --git a/internal/pluginhost/loader_windows.go b/internal/pluginhost/loader_windows.go index 7bdc12dd..cbae0a7f 100644 --- a/internal/pluginhost/loader_windows.go +++ b/internal/pluginhost/loader_windows.go @@ -4,7 +4,14 @@ package pluginhost import ( "context" + "crypto/sha256" + "encoding/hex" + "errors" "fmt" + "io" + "os" + "path/filepath" + "strings" "sync" "sync/atomic" "syscall" @@ -37,15 +44,24 @@ var ( windowsHostCallbackEntries sync.Map windowsHostCallCallback = syscall.NewCallback(windowsHostCall) windowsHostFreeCallback = syscall.NewCallback(windowsHostFree) + shadowPluginCleanupOnce sync.Once +) + +const ( + shadowPluginPrefix = "cliproxy-plugin-" + shadowPluginTempPrefix = ".cliproxy-plugin-" + shadowPluginProcessDirPrefix = "pid-" + shadowPluginDigestLength = 32 ) type dynamicLibraryLoader struct{} type dynamicLibraryClient struct { - dll *syscall.DLL - hostAPI *windowsHostAPI - hostCtx *uintptr - api windowsPluginAPI + dll *syscall.DLL + tempPath string + hostAPI *windowsHostAPI + hostCtx *uintptr + api windowsPluginAPI } func defaultPluginLoader() pluginLoader { @@ -53,13 +69,19 @@ func defaultPluginLoader() pluginLoader { } func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, error) { - dll, errLoad := syscall.LoadDLL(file.Path) + loadPath, errShadow := shadowCopyPlugin(file) + if errShadow != nil { + return nil, errShadow + } + dll, errLoad := syscall.LoadDLL(loadPath) if errLoad != nil { + removeShadowPlugin(loadPath) return nil, errLoad } proc, errProc := dll.FindProc("cliproxy_plugin_init") if errProc != nil { _ = dll.Release() + removeShadowPlugin(loadPath) return nil, errProc } id := windowsHostCallbackID.Add(1) @@ -67,8 +89,9 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err *hostCtx = id windowsHostCallbackEntries.Store(id, dynamicHostCallbackEntry{host: host, pluginID: file.ID}) client := &dynamicLibraryClient{ - dll: dll, - hostCtx: hostCtx, + dll: dll, + tempPath: loadPath, + hostCtx: hostCtx, hostAPI: &windowsHostAPI{ abiVersion: pluginHostABIVersion, hostCtx: uintptr(unsafe.Pointer(hostCtx)), @@ -78,20 +101,155 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err } rc, _, errCall := proc.Call(uintptr(unsafe.Pointer(client.hostAPI)), uintptr(unsafe.Pointer(&client.api))) if rc != 0 { - client.Shutdown() + client.closeAfterOpenFailure() return nil, fmt.Errorf("cliproxy_plugin_init returned %d: %v", rc, errCall) } if client.api.abiVersion != pluginHostABIVersion { - client.Shutdown() + client.closeAfterOpenFailure() return nil, fmt.Errorf("plugin ABI version %d is not supported", client.api.abiVersion) } if client.api.call == 0 || client.api.freeBuffer == 0 { - client.Shutdown() + client.closeAfterOpenFailure() return nil, fmt.Errorf("plugin function table is incomplete") } return client, nil } +func shadowCopyPlugin(file pluginFile) (string, error) { + dir, errDir := shadowPluginDir() + if errDir != nil { + return "", errDir + } + shadowPluginCleanupOnce.Do(func() { + removeStaleShadowPlugins(dir) + }) + return shadowCopyPluginToDir(file, dir) +} + +func shadowCopyPluginToDir(file pluginFile, dir string) (string, error) { + source := filepath.Clean(file.Path) + tmp, errTemp := os.CreateTemp(dir, shadowPluginTempPrefix+file.ID+"-*"+filepath.Ext(source)) + if errTemp != nil { + return "", errTemp + } + tmpName := tmp.Name() + removeTemp := true + defer func() { + if removeTemp { + removeShadowPlugin(tmpName) + } + }() + + in, errOpen := os.Open(source) + if errOpen != nil { + _ = tmp.Close() + return "", errOpen + } + defer func() { + _ = in.Close() + }() + hasher := sha256.New() + size, errCopy := io.Copy(io.MultiWriter(tmp, hasher), in) + if errCopy != nil { + _ = tmp.Close() + return "", errCopy + } + if errClose := tmp.Close(); errClose != nil { + return "", errClose + } + digest := hex.EncodeToString(hasher.Sum(nil)) + target := shadowPluginPath(dir, file.ID, digest, filepath.Ext(source)) + if shadowPluginMatches(target, size, digest) { + return target, nil + } + if errRemove := os.Remove(target); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) { + if shadowPluginMatches(target, size, digest) { + return target, nil + } + removeShadowPlugin(target) + return "", fmt.Errorf("remove stale shadow plugin: %w", errRemove) + } + if errRename := os.Rename(tmpName, target); errRename != nil { + if shadowPluginMatches(target, size, digest) { + return target, nil + } + return "", fmt.Errorf("move shadow plugin: %w", errRename) + } + removeTemp = false + return target, nil +} + +func shadowPluginDir() (string, error) { + dir := filepath.Join(os.TempDir(), "cliproxy-pluginhost", shadowPluginProcessDirName(os.Getpid())) + if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil { + return "", errMkdir + } + return dir, nil +} + +func shadowPluginProcessDirName(pid int) string { + return fmt.Sprintf("%s%d", shadowPluginProcessDirPrefix, pid) +} + +func removeShadowPlugin(path string) { + if path == "" { + return + } + if errRemove := os.Remove(path); errRemove == nil { + return + } + pathPtr, errPath := windows.UTF16PtrFromString(path) + if errPath != nil { + return + } + _ = windows.MoveFileEx(pathPtr, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT) +} + +func removeStaleShadowPlugins(dir string) { + entries, errRead := os.ReadDir(dir) + if errRead != nil { + return + } + for _, entry := range entries { + if entry == nil || entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, shadowPluginPrefix) || strings.HasPrefix(name, shadowPluginTempPrefix) { + removeShadowPlugin(filepath.Join(dir, name)) + } + } +} + +func shadowPluginPath(dir string, id string, digest string, extension string) string { + if len(digest) > shadowPluginDigestLength { + digest = digest[:shadowPluginDigestLength] + } + return filepath.Join(dir, shadowPluginPrefix+id+"-"+digest+extension) +} + +func shadowPluginMatches(path string, size int64, digest string) bool { + info, errStat := os.Stat(path) + if errStat != nil { + return false + } + if !info.Mode().IsRegular() || info.Size() != size { + return false + } + file, errOpen := os.Open(path) + if errOpen != nil { + return false + } + defer func() { + _ = file.Close() + }() + hasher := sha256.New() + if _, errCopy := io.Copy(hasher, file); errCopy != nil { + return false + } + return hex.EncodeToString(hasher.Sum(nil)) == digest +} + func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) { if c == nil || c.api.call == 0 { return nil, fmt.Errorf("plugin client is closed") @@ -137,6 +295,17 @@ func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request } func (c *dynamicLibraryClient) Shutdown() { + // Windows Go DLLs are not safe to hot-unload from the host process. + // The plugin was loaded from a shadow copy, so keeping the module mapped + // does not block deleting or replacing the source artifact. + c.close(false) +} + +func (c *dynamicLibraryClient) closeAfterOpenFailure() { + c.close(true) +} + +func (c *dynamicLibraryClient) close(releaseDLL bool) { if c == nil { return } @@ -149,9 +318,13 @@ func (c *dynamicLibraryClient) Shutdown() { c.hostCtx = nil } if c.dll != nil { - _ = c.dll.Release() + if releaseDLL { + _ = c.dll.Release() + } c.dll = nil } + removeShadowPlugin(c.tempPath) + c.tempPath = "" } func windowsHostCall(hostCtx uintptr, methodPtr uintptr, requestPtr uintptr, requestLen uintptr, responsePtr uintptr) uintptr { diff --git a/internal/pluginhost/loader_windows_test.go b/internal/pluginhost/loader_windows_test.go new file mode 100644 index 00000000..c3cd3a7e --- /dev/null +++ b/internal/pluginhost/loader_windows_test.go @@ -0,0 +1,165 @@ +//go:build windows + +package pluginhost + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestShadowPluginDirIsProcessScoped(t *testing.T) { + dir, errDir := shadowPluginDir() + if errDir != nil { + t.Fatalf("shadowPluginDir() error = %v", errDir) + } + want := filepath.Join(os.TempDir(), "cliproxy-pluginhost", fmt.Sprintf("pid-%d", os.Getpid())) + if dir != want { + t.Fatalf("shadowPluginDir() = %q, want %q", dir, want) + } +} + +func TestShadowCopyPluginReusesContentAddressedShadow(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(t.TempDir(), "alpha.dll") + content := []byte("plugin-v1") + if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil { + t.Fatalf("WriteFile() error = %v", errWrite) + } + file := pluginFile{ID: "alpha", Path: source} + + first, errFirst := shadowCopyPluginToDir(file, dir) + if errFirst != nil { + t.Fatalf("shadowCopyPluginToDir() first error = %v", errFirst) + } + second, errSecond := shadowCopyPluginToDir(file, dir) + if errSecond != nil { + t.Fatalf("shadowCopyPluginToDir() second error = %v", errSecond) + } + + if second != first { + t.Fatalf("second shadow path = %q, want reused path %q", second, first) + } + gotContent, errRead := os.ReadFile(first) + if errRead != nil { + t.Fatalf("ReadFile(%s) error = %v", first, errRead) + } + if string(gotContent) != string(content) { + t.Fatalf("shadow content = %q, want %q", gotContent, content) + } + digest := sha256.Sum256(content) + wantDigest := hex.EncodeToString(digest[:])[:shadowPluginDigestLength] + name := filepath.Base(first) + if !strings.HasPrefix(name, shadowPluginPrefix+"alpha-") || !strings.Contains(name, wantDigest) { + t.Fatalf("shadow file name = %q, want alpha content digest %s", name, wantDigest) + } + if count := countShadowPluginFiles(t, dir); count != 1 { + t.Fatalf("shadow file count = %d, want 1", count) + } +} + +func TestShadowCopyPluginCreatesNewPathForChangedContent(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(t.TempDir(), "alpha.dll") + file := pluginFile{ID: "alpha", Path: source} + if errWrite := os.WriteFile(source, []byte("plugin-v1"), 0o644); errWrite != nil { + t.Fatalf("WriteFile() v1 error = %v", errWrite) + } + first, errFirst := shadowCopyPluginToDir(file, dir) + if errFirst != nil { + t.Fatalf("shadowCopyPluginToDir() v1 error = %v", errFirst) + } + + if errWrite := os.WriteFile(source, []byte("plugin-v2"), 0o644); errWrite != nil { + t.Fatalf("WriteFile() v2 error = %v", errWrite) + } + second, errSecond := shadowCopyPluginToDir(file, dir) + if errSecond != nil { + t.Fatalf("shadowCopyPluginToDir() v2 error = %v", errSecond) + } + + if second == first { + t.Fatalf("second shadow path reused %q after content changed", second) + } + if count := countShadowPluginFiles(t, dir); count != 2 { + t.Fatalf("shadow file count = %d, want 2 versions", count) + } +} + +func TestShadowCopyPluginReplacesCorruptSameSizeShadow(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(t.TempDir(), "alpha.dll") + content := []byte("plugin-v1") + if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil { + t.Fatalf("WriteFile() source error = %v", errWrite) + } + digest := sha256.Sum256(content) + target := shadowPluginPath(dir, "alpha", hex.EncodeToString(digest[:]), ".dll") + if errWrite := os.WriteFile(target, []byte("corrupt!!"), 0o644); errWrite != nil { + t.Fatalf("WriteFile() corrupt shadow error = %v", errWrite) + } + + gotPath, errCopy := shadowCopyPluginToDir(pluginFile{ID: "alpha", Path: source}, dir) + if errCopy != nil { + t.Fatalf("shadowCopyPluginToDir() error = %v", errCopy) + } + + if gotPath != target { + t.Fatalf("shadow path = %q, want %q", gotPath, target) + } + gotContent, errRead := os.ReadFile(target) + if errRead != nil { + t.Fatalf("ReadFile(%s) error = %v", target, errRead) + } + if string(gotContent) != string(content) { + t.Fatalf("shadow content = %q, want %q", gotContent, content) + } + if count := countShadowPluginFiles(t, dir); count != 1 { + t.Fatalf("shadow file count = %d, want 1", count) + } +} + +func TestRemoveStaleShadowPluginsOnlyRemovesShadowFiles(t *testing.T) { + dir := t.TempDir() + stale := filepath.Join(dir, shadowPluginPrefix+"alpha-deadbeef.dll") + temp := filepath.Join(dir, shadowPluginTempPrefix+"alpha-temp.dll") + keep := filepath.Join(dir, "keep.dll") + for _, path := range []string{stale, temp, keep} { + if errWrite := os.WriteFile(path, []byte("x"), 0o644); errWrite != nil { + t.Fatalf("WriteFile(%s) error = %v", path, errWrite) + } + } + + removeStaleShadowPlugins(dir) + + for _, path := range []string{stale, temp} { + if _, errStat := os.Stat(path); !os.IsNotExist(errStat) { + t.Fatalf("Stat(%s) error = %v, want not exist", path, errStat) + } + } + if _, errStat := os.Stat(keep); errStat != nil { + t.Fatalf("Stat(%s) error = %v, want kept", keep, errStat) + } +} + +func countShadowPluginFiles(t *testing.T, dir string) int { + t.Helper() + entries, errRead := os.ReadDir(dir) + if errRead != nil { + t.Fatalf("ReadDir(%s) error = %v", dir, errRead) + } + count := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), shadowPluginPrefix) { + count++ + } + if strings.HasPrefix(entry.Name(), shadowPluginTempPrefix) { + t.Fatalf("temporary shadow file was not cleaned up: %s", entry.Name()) + } + } + return count +} diff --git a/sdk/pluginhost/host.go b/sdk/pluginhost/host.go index 22508d41..7b230c66 100644 --- a/sdk/pluginhost/host.go +++ b/sdk/pluginhost/host.go @@ -79,6 +79,19 @@ func (h *Host) ShutdownAll() { h.inner.ShutdownAll() } +// PluginBusy reports whether a plugin dynamic library is loaded or being loaded. +func (h *Host) PluginBusy(id string) bool { + return h != nil && h.inner != nil && h.inner.PluginBusy(id) +} + +// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library. +func (h *Host) UnloadPlugin(id string) bool { + if h == nil || h.inner == nil { + return false + } + return h.inner.UnloadPlugin(id) +} + // ParseAuth lets plugin auth providers parse a credential payload. func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) { if h == nil || h.inner == nil {