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]
