This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 3be4f14da46953d324bb6df8c6acf4749d615c25
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 18:29:05 2026 +0800

    feat(agents): add uninstall and stop treating a stale patch as active
---
 agent/fingerprint.go                |   3 +
 agent/fingerprint_load.go           |   3 +
 agent/fingerprints/copilot-cli.json |   4 +-
 agent/version.go                    |  44 +++++++--
 agentpatch/claude_code.go           |  13 ++-
 agentpatch/cursor.go                |  10 +-
 agentpatch/hook_groups.go           |  31 ++++++
 agentpatch/openclaw.go              |  12 ++-
 agentpatch/settings_hooks.go        |  12 ++-
 agentpatch/windsurf.go              |  10 +-
 main.go                             |   6 ++
 service/uninstall.go                | 181 ++++++++++++++++++++++++++++++++++++
 12 files changed, 314 insertions(+), 15 deletions(-)

diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 6373872..3b47f6a 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -71,6 +71,9 @@ type Fingerprint struct {
        // records carry the version of the agent that wrote them. It is the 
only
        // version an installation found by its configuration alone can report.
        StateVersionGlob string `json:"stateVersionGlob,omitempty"`
+       // StateVersionField is the dotted path to that version within a 
record, for
+       // an agent that does not keep it at the top level under "version".
+       StateVersionField string `json:"stateVersionField,omitempty"`
        // StateIgnore names the entries an agent writes on its own, without 
being
        // installed here: what an app that embeds the agent leaves behind. A 
state
        // directory holding nothing else is not evidence of an installation.
diff --git a/agent/fingerprint_load.go b/agent/fingerprint_load.go
index 905b6c1..efa8cc2 100644
--- a/agent/fingerprint_load.go
+++ b/agent/fingerprint_load.go
@@ -111,6 +111,9 @@ func validateFingerprint(f Fingerprint, name string) error {
        if f.StateVersionGlob != "" && f.StateDir == "" {
                return errors.New("stateVersionGlob has no stateDir to resolve 
against")
        }
+       if f.StateVersionField != "" && f.StateVersionGlob == "" {
+               return errors.New("stateVersionField has no stateVersionGlob to 
read")
+       }
        if len(f.StateIgnore) > 0 && f.StateDir == "" {
                return errors.New("stateIgnore has no stateDir to resolve 
against")
        }
diff --git a/agent/fingerprints/copilot-cli.json 
b/agent/fingerprints/copilot-cli.json
index 5ee11b2..22931cb 100644
--- a/agent/fingerprints/copilot-cli.json
+++ b/agent/fingerprints/copilot-cli.json
@@ -5,5 +5,7 @@
   "execName": "copilot",
   "stateDir": "copilot",
   "stateIgnore": ["config.json", "logs"],
-  "npmPackage": "@github/copilot"
+  "npmPackage": "@github/copilot",
+  "stateVersionGlob": "session-state/*/events.jsonl",
+  "stateVersionField": "data.copilotVersion"
 }
diff --git a/agent/version.go b/agent/version.go
index fa8aa33..b41a8d4 100644
--- a/agent/version.go
+++ b/agent/version.go
@@ -123,10 +123,13 @@ func executableVersionFile(binaryPath, fileName string) 
string {
 // stateDirVersion reads the version out of the newest records the agent wrote
 // under its state directory. An installation found by its configuration alone
 // has no program to read a version from, but its own records name one.
-func stateDirVersion(stateDir, glob string) string {
+func stateDirVersion(stateDir, glob, field string) string {
        if stateDir == "" || glob == "" {
                return ""
        }
+       if field == "" {
+               field = "version"
+       }
        matches, err := filepath.Glob(filepath.Join(stateDir, 
filepath.FromSlash(glob)))
        if err != nil {
                return ""
@@ -152,15 +155,15 @@ func stateDirVersion(stateDir, glob string) string {
        }
 
        for _, file := range files {
-               if version := recordVersion(file.path); version != "" {
+               if version := recordVersion(file.path, field); version != "" {
                        return version
                }
        }
        return ""
 }
 
-// recordVersion is the "version" field of the first JSON record that has one.
-func recordVersion(path string) string {
+// recordVersion is the version field of the first JSON record that has one.
+func recordVersion(path, field string) string {
        file, err := os.Open(path)
        if err != nil {
                return ""
@@ -181,20 +184,42 @@ func recordVersion(path string) string {
                return ""
        }
 
+       segments := strings.Split(field, ".")
        for _, line := range bytes.Split(head, []byte("\n")) {
-               var record struct {
-                       Version string `json:"version"`
-               }
+               var record map[string]json.RawMessage
                if json.Unmarshal(bytes.TrimSpace(line), &record) != nil {
                        continue
                }
-               if version := sanitizeVersion(record.Version); version != "" {
+               if version := sanitizeVersion(recordField(record, segments)); 
version != "" {
                        return version
                }
        }
        return ""
 }
 
+// recordField walks a dotted path into one record, and is empty unless the 
path
+// ends on a string.
+func recordField(record map[string]json.RawMessage, segments []string) string {
+       for index, segment := range segments {
+               raw, ok := record[segment]
+               if !ok {
+                       return ""
+               }
+               if index == len(segments)-1 {
+                       var value string
+                       if json.Unmarshal(raw, &value) != nil {
+                               return ""
+                       }
+                       return value
+               }
+               record = nil
+               if json.Unmarshal(raw, &record) != nil {
+                       return ""
+               }
+       }
+       return ""
+}
+
 func fillMissingVersions(installations []Installation, mark int, fingerprint 
*Fingerprint) {
        if fingerprint.BuildInfoModule == "" && fingerprint.VersionFile == "" 
&& fingerprint.StateVersionGlob == "" {
                return
@@ -204,7 +229,8 @@ func fillMissingVersions(installations []Installation, mark 
int, fingerprint *Fi
                        continue
                }
                if installations[i].InstallMethod == InstallMethodConfig {
-                       installations[i].Version = 
stateDirVersion(installations[i].Path, fingerprint.StateVersionGlob)
+                       installations[i].Version = stateDirVersion(
+                               installations[i].Path, 
fingerprint.StateVersionGlob, fingerprint.StateVersionField)
                        continue
                }
                version := executableBuildVersion(
diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go
index 777a880..27fc7b2 100644
--- a/agentpatch/claude_code.go
+++ b/agentpatch/claude_code.go
@@ -113,7 +113,7 @@ func (claudeCodePatcher) Status(target Target) (Status, 
error) {
                return Status{Detail: "Claude Code hooks are not installed"}, 
nil
        }
        for _, event := range claudeCodeHookEvents {
-               if !hasHook(hooks[event], isClaudeCodeHook) {
+               if !hasHook(hooks[event], isCurrentClaudeCodeHook) {
                        return Status{Detail: "Claude Code hooks need 
refresh"}, nil
                }
        }
@@ -226,6 +226,17 @@ func isClaudeCodeHook(handler map[string]any) bool {
                stringIndex(args, "--records-url") >= 0
 }
 
+// isCurrentClaudeCodeHook narrows isClaudeCodeHook to a hook this Gateway 
would
+// write now. The broad test stays what Unpatch removes by, so one an earlier
+// install left behind is still cleaned up.
+func isCurrentClaudeCodeHook(handler map[string]any) bool {
+       if !isClaudeCodeHook(handler) {
+               return false
+       }
+       command, _ := handler["command"].(string)
+       return argsAreCurrent(command, stringArray(handler["args"]))
+}
+
 func hookEventSupportsMatcher(event string) bool {
        switch event {
        case "PreToolUse", "PostToolUse", "PostToolUseFailure", 
"PermissionRequest", "PermissionDenied":
diff --git a/agentpatch/cursor.go b/agentpatch/cursor.go
index f920b21..991d41c 100644
--- a/agentpatch/cursor.go
+++ b/agentpatch/cursor.go
@@ -123,7 +123,7 @@ func (p cursorPatcher) Status(target Target) (Status, 
error) {
                return Status{Detail: "Cursor hooks are not installed"}, nil
        }
        for _, event := range agenthook.CursorEvents {
-               if !hasFlatHook(hooks[event], isCursorHook) {
+               if !hasFlatHook(hooks[event], isCurrentCursorHook) {
                        return Status{Detail: "Cursor hooks need refresh"}, nil
                }
        }
@@ -215,6 +215,14 @@ func removeCursorHooks(config map[string]any) bool {
        return changed
 }
 
+func isCurrentCursorHook(handler map[string]any) bool {
+       if !isCursorHook(handler) {
+               return false
+       }
+       command, _ := handler["command"].(string)
+       return commandIsCurrent(command)
+}
+
 func isCursorHook(handler map[string]any) bool {
        command, _ := handler["command"].(string)
        return strings.Contains(command, agenthook.OwnershipFlag) &&
diff --git a/agentpatch/hook_groups.go b/agentpatch/hook_groups.go
index 45d3d49..ba1de52 100644
--- a/agentpatch/hook_groups.go
+++ b/agentpatch/hook_groups.go
@@ -177,3 +177,34 @@ func gatewayExecutable() (string, error) {
        }
        return executable, nil
 }
+
+// commandIsCurrent reports whether a hook command line runs this executable 
and
+// reports to this Gateway. A hook an earlier install left behind keeps its own
+// path and port, so calling it active is what leaves an agent reporting 
nowhere
+// while the UI says its hooks are on. Re-patching is what rewrites it.
+func commandIsCurrent(command string) bool {
+       executable, err := gatewayExecutable()
+       if err != nil {
+               return false
+       }
+       url, err := recordsURL()
+       if err != nil {
+               return false
+       }
+       return strings.Contains(command, quoteArg(executable)) && 
strings.Contains(command, url)
+}
+
+// argsAreCurrent is commandIsCurrent for an agent whose configuration keeps 
the
+// program and its arguments apart.
+func argsAreCurrent(executable string, args []string) bool {
+       self, err := gatewayExecutable()
+       if err != nil || executable != self {
+               return false
+       }
+       url, err := recordsURL()
+       if err != nil {
+               return false
+       }
+       index := stringIndex(args, "--records-url")
+       return index >= 0 && index+1 < len(args) && args[index+1] == url
+}
diff --git a/agentpatch/openclaw.go b/agentpatch/openclaw.go
index 85fa2bd..038fb3e 100644
--- a/agentpatch/openclaw.go
+++ b/agentpatch/openclaw.go
@@ -87,12 +87,22 @@ func (p openclawPatcher) Status(target Target) (Status, 
error) {
        if err != nil {
                return Status{}, err
        }
-       if _, err := os.Stat(filepath.Join(layout.hookDir, "handler.js")); err 
!= nil {
+       handler, err := os.ReadFile(filepath.Join(layout.hookDir, "handler.js"))
+       if err != nil {
                if os.IsNotExist(err) {
                        return Status{Detail: "OpenClaw hook is not 
installed"}, nil
                }
                return Status{}, err
        }
+       current, err := recordsURL()
+       if err != nil {
+               return Status{}, err
+       }
+       // A handler left by an older Gateway, or by one listening on another 
port,
+       // reports nowhere. Re-patching is what rewrites it.
+       if !strings.Contains(string(handler), jsonString(current)) {
+               return Status{Detail: "OpenClaw hook needs refresh"}, nil
+       }
        enabled, err := openclawHookEnabled(layout.configPath)
        if err != nil {
                return Status{}, err
diff --git a/agentpatch/settings_hooks.go b/agentpatch/settings_hooks.go
index f7ccc7e..77969b7 100644
--- a/agentpatch/settings_hooks.go
+++ b/agentpatch/settings_hooks.go
@@ -116,7 +116,7 @@ func (p settingsHookPatcher) Status(target Target) (Status, 
error) {
                return Status{Detail: p.name + " hooks are not installed"}, nil
        }
        for _, event := range p.events {
-               if !hasHook(hooks[event], p.owns) {
+               if !hasHook(hooks[event], p.current) {
                        return Status{Detail: p.name + " hooks need refresh"}, 
nil
                }
        }
@@ -206,6 +206,16 @@ func (p settingsHookPatcher) owns(handler map[string]any) 
bool {
                strings.Contains(command, "--records-url")
 }
 
+// current narrows owns to a hook this Gateway would write now. owns stays the
+// broad test, so Unpatch still removes one an earlier install left behind.
+func (p settingsHookPatcher) current(handler map[string]any) bool {
+       if !p.owns(handler) {
+               return false
+       }
+       command, _ := handler["command"].(string)
+       return commandIsCurrent(command)
+}
+
 func newSettingsHook(command string) map[string]any {
        return map[string]any{
                "type":    "command",
diff --git a/agentpatch/windsurf.go b/agentpatch/windsurf.go
index 750ab6a..478d89d 100644
--- a/agentpatch/windsurf.go
+++ b/agentpatch/windsurf.go
@@ -103,7 +103,7 @@ func (p windsurfPatcher) Status(target Target) (Status, 
error) {
                return Status{Detail: "Cascade hooks are not installed"}, nil
        }
        for _, event := range agenthook.WindsurfEvents {
-               if !hasFlatHook(hooks[event], isWindsurfHook) {
+               if !hasFlatHook(hooks[event], isCurrentWindsurfHook) {
                        return Status{Detail: "Cascade hooks need refresh"}, nil
                }
        }
@@ -190,6 +190,14 @@ func removeWindsurfHooks(config map[string]any) bool {
        return changed
 }
 
+func isCurrentWindsurfHook(handler map[string]any) bool {
+       if !isWindsurfHook(handler) {
+               return false
+       }
+       command, _ := handler["command"].(string)
+       return commandIsCurrent(command)
+}
+
 func isWindsurfHook(handler map[string]any) bool {
        command, _ := handler["command"].(string)
        return strings.Contains(command, agenthook.OwnershipFlag) &&
diff --git a/main.go b/main.go
index 3639947..1d4c6a7 100644
--- a/main.go
+++ b/main.go
@@ -63,6 +63,12 @@ func main() {
                return
        }
 
+       // "uninstall" gives back what installing took outside the install
+       // directory, which deleting that directory cannot do for itself.
+       if service.RunUninstall(os.Args, conf.GetHttpPort()) {
+               return
+       }
+
        // The executable an earlier update replaced is only removable once 
nothing
        // is running from it, which is now.
        version.CleanupBackup()
diff --git a/service/uninstall.go b/service/uninstall.go
new file mode 100644
index 0000000..6d44fcc
--- /dev/null
+++ b/service/uninstall.go
@@ -0,0 +1,181 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package service
+
+import (
+       "errors"
+       "fmt"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "runtime"
+       "time"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agentpatch"
+       "github.com/apache/casbin-gateway/agentprovider"
+       "github.com/apache/casbin-gateway/autostart"
+       "github.com/apache/casbin-gateway/util"
+)
+
+// launcherTimeout bounds the launcher call that hands back the desktop entries
+// and the URL scheme.
+const launcherTimeout = 30 * time.Second
+
+// RunUninstall handles "casbin-gateway uninstall" and reports whether it did.
+//
+// Deleting the install directory is the part of removing Gateway anyone can 
do.
+// What it cannot undo is everything Gateway wrote outside that directory: a
+// monitoring hook in every agent's own configuration, the provider each agent
+// was pointed at, the login entry, the desktop entries and the ccswitch:// URL
+// scheme taken from whatever held it. A deleted directory leaves all of it
+// naming a program that is not there any more.
+func RunUninstall(args []string, port int) bool {
+       if !isUninstall(args) {
+               return false
+       }
+
+       fmt.Println()
+       fmt.Println("Casbin Gateway: giving back what installing took outside 
this directory.")
+       fmt.Println()
+
+       stopServer(port)
+       releaseAgents()
+       clearAutostart()
+       releaseDesktop()
+       printLeftovers()
+       return true
+}
+
+func isUninstall(args []string) bool {
+       for _, arg := range args[1:] {
+               if arg == "uninstall" || arg == "--uninstall" {
+                       return true
+               }
+       }
+       return false
+}
+
+func stopServer(port int) {
+       if err := util.StopOldInstance(port); err != nil {
+               var foreign *util.ForeignPortError
+               if errors.As(err, &foreign) {
+                       fmt.Printf("  server           port %d is held by %s, 
left alone\n", port, foreign.Holder)
+                       return
+               }
+               fmt.Printf("  server           could not be stopped: %v\n", err)
+               return
+       }
+       fmt.Println("  server           stopped")
+}
+
+// releaseAgents puts every agent installation back the way Gateway found it:
+// monitoring hooks removed, and the provider configuration Gateway rewrote
+// restored from the backup taken before it was written.
+func releaseAgents() {
+       installations, err := agent.Scan(true)
+       if err != nil {
+               fmt.Printf("  agents           could not be listed: %v\n", err)
+               return
+       }
+
+       released, failures := 0, []string{}
+       for _, installation := range installations {
+               target := agentpatch.Target{AgentId: installation.AgentId, 
Path: installation.Path, Owner: installation.Owner}
+               touched := false
+               if err := agentpatch.Unpatch(target); err != nil {
+                       if !errors.Is(err, agentpatch.ErrNotSupported) {
+                               failures = append(failures, 
installation.AgentId+": "+err.Error())
+                       }
+               } else {
+                       touched = true
+               }
+               provider := agentprovider.Target{AgentId: target.AgentId, Path: 
target.Path, Owner: target.Owner}
+               if err := agentprovider.Restore(provider); err != nil {
+                       failures = append(failures, installation.AgentId+": 
"+err.Error())
+               } else {
+                       touched = true
+               }
+               if touched {
+                       released++
+               }
+       }
+
+       fmt.Printf("  agents           %d installations restored\n", released)
+       for _, failure := range failures {
+               fmt.Printf("                   %s\n", failure)
+       }
+}
+
+func clearAutostart() {
+       if !autostart.Supported() {
+               return
+       }
+       if err := autostart.Set(false); err != nil {
+               fmt.Printf("  start at login   could not be removed: %v\n", err)
+               return
+       }
+       fmt.Println("  start at login   removed")
+}
+
+// releaseDesktop asks the launcher to drop the desktop entries and hand the
+// ccswitch:// scheme back to whatever held it before Gateway, which is the one
+// part of this only the launcher knows how to undo.
+func releaseDesktop() {
+       launcher := filepath.Join(filepath.Dir(executablePath()), 
launcherName())
+       if _, err := os.Stat(launcher); err != nil {
+               fmt.Println("  desktop entries  launcher not found, nothing 
changed")
+               return
+       }
+
+       command := exec.Command(launcher, "shortcut", "off")
+       command.Dir = filepath.Dir(launcher)
+       timer := time.AfterFunc(launcherTimeout, func() {
+               if command.Process != nil {
+                       _ = command.Process.Kill()
+               }
+       })
+       defer timer.Stop()
+       if err := command.Run(); err != nil {
+               fmt.Printf("  desktop entries  could not be removed: %v\n", err)
+               return
+       }
+       fmt.Println("  desktop entries  removed, ccswitch:// links handed back")
+}
+
+func printLeftovers() {
+       directory := filepath.Dir(executablePath())
+       fmt.Println()
+       fmt.Println("Delete this directory to finish, once nothing is running 
from it:")
+       fmt.Printf("  %s\n", directory)
+       fmt.Println("Then take its \"bin\" directory off your PATH:")
+       fmt.Printf("  %s\n", filepath.Join(directory, "bin"))
+       fmt.Println()
+}
+
+func executablePath() string {
+       path, err := os.Executable()
+       if err != nil {
+               return "."
+       }
+       return path
+}
+
+func launcherName() string {
+       if runtime.GOOS == "windows" {
+               return "casbin-gateway-desktop.exe"
+       }
+       return "casbin-gateway-desktop"
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to