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
The following commit(s) were added to refs/heads/master by this push:
new 78687bb feat: add cross-platform AI agent detection- #192 (#192)
78687bb is described below
commit 78687bb1878275911cd8b72fcec315c37cc61cdf
Author: Paperlz <[email protected]>
AuthorDate: Thu Aug 13 15:04:22 2026 +0800
feat: add cross-platform AI agent detection- #192 (#192)
---
agent/agent.go | 26 +++
agent/codex_scan_unix.go | 146 +++++++++++++++
agent/codex_windows_shared.go | 43 +++++
agent/codex_windows_shared_test.go | 56 ++++++
agent/fingerprint.go | 40 ++++
agent/fingerprint_load.go | 146 +++++++++++++++
agent/fingerprint_load_test.go | 125 +++++++++++++
agent/fingerprints/claude-code.json | 10 +
agent/fingerprints/claude-desktop.json | 7 +
agent/fingerprints/codex-cli.json | 9 +
agent/fingerprints/codex.json | 6 +
agent/fingerprints/cursor-agent.json | 7 +
agent/fingerprints/cursor.json | 7 +
agent/fingerprints/openagent.json | 17 ++
agent/fingerprints/openclaw.json | 8 +
agent/fingerprints/windsurf.json | 7 +
agent/scan.go | 102 ++++++++++
agent/scan_common.go | 188 +++++++++++++++++++
agent/scan_darwin.go | 130 +++++++++++++
agent/scan_linux.go | 216 +++++++++++++++++++++
agent/scan_localserver.go | 75 ++++++++
agent/scan_unix.go | 109 +++++++++++
agent/scan_windows.go | 333 +++++++++++++++++++++++++++++++++
agent/scan_windows_desktop.go | 153 +++++++++++++++
agent/version.go | 127 +++++++++++++
agent/version_windows.go | 73 ++++++++
controllers/agent.go | 32 ++++
go.mod | 92 ++++++++-
go.sum | 17 ++
internal/hermes/inspect.go | 165 ++++++++++++++++
localserver/listener_darwin.go | 71 +++++++
localserver/listener_linux.go | 141 ++++++++++++++
localserver/listener_windows.go | 178 ++++++++++++++++++
localserver/localserver.go | 146 +++++++++++++++
routers/router.go | 1 +
web/src/AgentsPage.js | 130 +++++++++++++
web/src/App.js | 15 +-
web/src/backend/AgentBackend.js | 23 +++
web/src/components/AgentIcon.js | 56 ++++++
web/src/locales/de/data.json | 10 +
web/src/locales/en/data.json | 10 +
web/src/locales/es/data.json | 10 +
web/src/locales/fr/data.json | 10 +
web/src/locales/id/data.json | 10 +
web/src/locales/ja/data.json | 10 +
web/src/locales/ko/data.json | 10 +
web/src/locales/ru/data.json | 10 +
web/src/locales/zh/data.json | 10 +
48 files changed, 3321 insertions(+), 2 deletions(-)
diff --git a/agent/agent.go b/agent/agent.go
new file mode 100644
index 0000000..c8fca24
--- /dev/null
+++ b/agent/agent.go
@@ -0,0 +1,26 @@
+// Copyright 2025 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 agent detects AI agents in known installation locations.
+package agent
+
+// Installation describes an AI agent installation found on the host.
+type Installation struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+ Version string `json:"version,omitempty"`
+ Path string `json:"path"`
+ InstallMethod string `json:"installMethod"`
+ Owner string `json:"owner"`
+}
diff --git a/agent/codex_scan_unix.go b/agent/codex_scan_unix.go
new file mode 100644
index 0000000..997d7f2
--- /dev/null
+++ b/agent/codex_scan_unix.go
@@ -0,0 +1,146 @@
+// Copyright 2025 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.
+
+//go:build linux || darwin
+
+package agent
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+var systemBinDirs = []string{"/usr/bin", "/usr/local/bin"}
+
+func scanCodexStandalone() []Installation {
+ var result []Installation
+ for _, dir := range systemBinDirs {
+ executable := filepath.Join(dir, "codex")
+ info, err := os.Stat(executable)
+ if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111
== 0 ||
+ !isCodexNativeBinary(executable) {
+ continue
+ }
+ result = append(result, Installation{
+ AgentId: "codex-cli", Name: "Codex CLI", Path:
executable,
+ InstallMethod: "standalone", Owner:
fileOwner(executable),
+ })
+ }
+ return result
+}
+
+func isCodexNativeBinary(path string) bool {
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
+ normalized := strings.ToLower(filepath.ToSlash(resolved))
+ if strings.Contains(normalized, "/node_modules/@openai/codex/")
||
+ strings.Contains(normalized, "/caskroom/codex/") ||
+ strings.Contains(normalized, "/caskroom/codex@latest/")
{
+ return false
+ }
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return false
+ }
+ defer file.Close()
+
+ magic := make([]byte, 4)
+ if _, err := io.ReadFull(file, magic); err != nil {
+ return false
+ }
+ if bytes.Equal(magic, []byte{0x7f, 'E', 'L', 'F'}) {
+ return true
+ }
+ for _, macho := range [][]byte{
+ {0xfe, 0xed, 0xfa, 0xcf},
+ {0xcf, 0xfa, 0xed, 0xfe},
+ {0xca, 0xfe, 0xba, 0xbe},
+ {0xbe, 0xba, 0xfe, 0xca},
+ } {
+ if bytes.Equal(magic, macho) {
+ return true
+ }
+ }
+ return false
+}
+
+func scanCodexDarwinApps(homes []homeDir) []Installation {
+ if runtime.GOOS != "darwin" {
+ return nil
+ }
+ apps := []struct {
+ bundle string
+ executable string
+ }{
+ {bundle: "ChatGPT.app", executable: "ChatGPT"},
+ {bundle: "Codex.app", executable: "Codex"},
+ }
+
+ var result []Installation
+ for _, app := range apps {
+ systemExecutable := filepath.Join("/Applications", app.bundle,
"Contents", "MacOS", app.executable)
+ if info, err := os.Stat(systemExecutable); err == nil &&
info.Mode().IsRegular() {
+ result = append(result, Installation{
+ AgentId: "codex", Name: "ChatGPT Desktop
(Codex)", Path: systemExecutable,
+ InstallMethod: "app", Owner:
fileOwner(systemExecutable),
+ })
+ }
+ for _, home := range homes {
+ executable := filepath.Join(home.path, "Applications",
app.bundle, "Contents", "MacOS", app.executable)
+ if info, err := os.Stat(executable); err != nil ||
!info.Mode().IsRegular() {
+ continue
+ }
+ result = append(result, Installation{
+ AgentId: "codex", Name: "ChatGPT Desktop
(Codex)", Path: executable,
+ InstallMethod: "app", Owner: home.owner,
+ })
+ }
+ }
+ return result
+}
+
+func expandSharedCodexInstallations(installations []Installation, homes
[]homeDir) []Installation {
+ var result []Installation
+ for _, installation := range installations {
+ clean := filepath.Clean(installation.Path)
+ if installation.AgentId == "codex-cli" &&
installation.InstallMethod == "native" &&
+ !isCodexNativeBinary(clean) {
+ continue
+ }
+ shared := installation.AgentId == "codex" &&
strings.HasPrefix(clean, "/Applications/") ||
+ installation.AgentId == "codex-cli" &&
+ (strings.HasPrefix(clean, "/usr/") ||
+ strings.HasPrefix(clean, "/opt/") ||
+ strings.HasPrefix(clean,
"/home/linuxbrew/"))
+ if !shared || len(homes) == 0 {
+ result = append(result, installation)
+ continue
+ }
+ for _, home := range homes {
+ if home.path != "/root" && home.path != "/var/root" &&
+ !strings.HasPrefix(home.path, "/home/") &&
+ !strings.HasPrefix(home.path, "/Users/") {
+ continue
+ }
+ expanded := installation
+ expanded.Owner = home.owner
+ result = append(result, expanded)
+ }
+ }
+ return result
+}
diff --git a/agent/codex_windows_shared.go b/agent/codex_windows_shared.go
new file mode 100644
index 0000000..d39f82b
--- /dev/null
+++ b/agent/codex_windows_shared.go
@@ -0,0 +1,43 @@
+// Copyright 2025 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 agent
+
+import "strings"
+
+func expandSharedCodexWindowsInstallations(installations []Installation, homes
[]homeDir) []Installation {
+ var result []Installation
+ for _, installation := range installations {
+ if (installation.AgentId != "codex" && installation.AgentId !=
"codex-cli") ||
+ !strings.EqualFold(installation.Owner, "SYSTEM") {
+ result = append(result, installation)
+ continue
+ }
+ matchedProfile := false
+ for _, home := range homes {
+ normalized :=
strings.ToLower(strings.ReplaceAll(home.path, `/`, `\`))
+ if !strings.Contains(normalized, `\users\`) {
+ continue
+ }
+ row := installation
+ row.Owner = home.owner
+ result = append(result, row)
+ matchedProfile = true
+ }
+ if !matchedProfile {
+ result = append(result, installation)
+ }
+ }
+ return result
+}
diff --git a/agent/codex_windows_shared_test.go
b/agent/codex_windows_shared_test.go
new file mode 100644
index 0000000..59d2232
--- /dev/null
+++ b/agent/codex_windows_shared_test.go
@@ -0,0 +1,56 @@
+// Copyright 2025 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 agent
+
+import "testing"
+
+func TestExpandSharedCodexWindowsInstallations(t *testing.T) {
+ installation := Installation{AgentId: "codex", Path: `C:\Program
Files\WindowsApps\Codex.exe`, Owner: "SYSTEM"}
+ tests := []struct {
+ name string
+ homes []homeDir
+ want []string
+ }{
+ {name: "no profiles", want: []string{"SYSTEM"}},
+ {
+ name: "no user profiles",
+ homes: []homeDir{{owner: "SYSTEM", path:
`C:\Windows\System32\config\systemprofile`}},
+ want: []string{"SYSTEM"},
+ },
+ {
+ name: "user profiles",
+ homes: []homeDir{
+ {owner: "SYSTEM", path:
`C:\Windows\System32\config\systemprofile`},
+ {owner: "alice", path: `C:\Users\alice`},
+ {owner: "bob", path: `D:\Users\bob`},
+ },
+ want: []string{"alice", "bob"},
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got :=
expandSharedCodexWindowsInstallations([]Installation{installation}, test.homes)
+ if len(got) != len(test.want) {
+ t.Fatalf("got %d installations, want %d",
len(got), len(test.want))
+ }
+ for i, owner := range test.want {
+ if got[i].Owner != owner {
+ t.Errorf("installation %d owner = %q,
want %q", i, got[i].Owner, owner)
+ }
+ }
+ })
+ }
+}
diff --git a/agent/fingerprint.go b/agent/fingerprint.go
new file mode 100644
index 0000000..0735ce3
--- /dev/null
+++ b/agent/fingerprint.go
@@ -0,0 +1,40 @@
+// Copyright 2025 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 agent
+
+import "github.com/apache/casbin-gateway/localserver"
+
+// Fingerprint describes known installation layouts for one agent.
+type Fingerprint struct {
+ ID string `json:"id"`
+ DisplayName string `json:"displayName"`
+
+ ExecName string `json:"execName,omitempty"`
+ StateDir string `json:"stateDir,omitempty"`
+ NpmPackage string `json:"npmPackage,omitempty"`
+ ExtraUnixNpmDirs []string
`json:"extraUnixNpmDirs,omitempty"`
+ ExtraWindowsNpmDirs []string
`json:"extraWindowsNpmDirs,omitempty"`
+ WingetPackage string `json:"wingetPackage,omitempty"`
+ MSIXFamily string `json:"msixFamily,omitempty"`
+ DesktopInstallerDir string
`json:"desktopInstallerDir,omitempty"`
+ WindowsProgramDirs []string
`json:"windowsProgramDirs,omitempty"`
+ WindowsUserDirs []string
`json:"windowsUserDirs,omitempty"`
+ HomebrewCasks []string `json:"homebrewCasks,omitempty"`
+ SystemPackage string `json:"systemPackage,omitempty"`
+ BuildInfoModule string
`json:"buildInfoModule,omitempty"`
+ BuildInfoVersionVar string
`json:"buildInfoVersionVar,omitempty"`
+ VersionFile string `json:"versionFile,omitempty"`
+ LocalServer *localserver.Server `json:"localServer,omitempty"`
+}
diff --git a/agent/fingerprint_load.go b/agent/fingerprint_load.go
new file mode 100644
index 0000000..f4c8543
--- /dev/null
+++ b/agent/fingerprint_load.go
@@ -0,0 +1,146 @@
+// Copyright 2025 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 agent
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ pathpkg "path"
+ "sort"
+ "strings"
+
+ "github.com/apache/casbin-gateway/localserver"
+)
+
+// Fingerprints are embedded in the Gateway binary.
+//
+//go:embed fingerprints/*.json
+var fingerprintFS embed.FS
+
+const fingerprintDir = "fingerprints"
+
+var fingerprints = mustLoadFingerprints(fingerprintFS)
+
+// mustLoadFingerprints rejects invalid embedded build data at startup.
+func mustLoadFingerprints(files fs.FS) []Fingerprint {
+ loaded, err := loadFingerprints(files)
+ if err != nil {
+ panic("agent: cannot load agent fingerprints: " + err.Error())
+ }
+ return loaded
+}
+
+// loadFingerprints loads and validates fingerprint files in stable order.
+func loadFingerprints(files fs.FS) ([]Fingerprint, error) {
+ names, err := fs.Glob(files, fingerprintDir+"/*.json")
+ if err != nil {
+ return nil, err
+ }
+ sort.Strings(names)
+ if len(names) == 0 {
+ return nil, fmt.Errorf("no fingerprint files under %s/",
fingerprintDir)
+ }
+
+ loaded := make([]Fingerprint, 0, len(names))
+ seen := make(map[string]bool, len(names))
+ for _, name := range names {
+ fingerprint, err := decodeFingerprint(files, name)
+ if err != nil {
+ return nil, err
+ }
+ if seen[fingerprint.ID] {
+ return nil, fmt.Errorf("%s: duplicate agent id %q",
name, fingerprint.ID)
+ }
+ seen[fingerprint.ID] = true
+ loaded = append(loaded, fingerprint)
+ }
+ return loaded, nil
+}
+
+// decodeFingerprint rejects unknown JSON fields.
+func decodeFingerprint(files fs.FS, name string) (Fingerprint, error) {
+ data, err := fs.ReadFile(files, name)
+ if err != nil {
+ return Fingerprint{}, err
+ }
+
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ var fingerprint Fingerprint
+ if err := decoder.Decode(&fingerprint); err != nil {
+ return Fingerprint{}, fmt.Errorf("%s: %w", name, err)
+ }
+ if decoder.More() {
+ return Fingerprint{}, fmt.Errorf("%s: trailing content after
the fingerprint object", name)
+ }
+ if err := validateFingerprint(fingerprint, name); err != nil {
+ return Fingerprint{}, fmt.Errorf("%s: %w", name, err)
+ }
+ return fingerprint, nil
+}
+
+// validateFingerprint requires a usable detection source.
+func validateFingerprint(f Fingerprint, name string) error {
+ if id := strings.TrimSuffix(pathpkg.Base(name), ".json"); f.ID != id {
+ return fmt.Errorf("id %q does not match the file name %q",
f.ID, id)
+ }
+ if f.DisplayName == "" {
+ return errors.New("displayName is required")
+ }
+
+ if err := validateLocalServer(f.LocalServer); err != nil {
+ return err
+ }
+ if !hasScanSource(f) {
+ return errors.New("no supported installation layout or local
server")
+ }
+ return nil
+}
+
+func hasScanSource(f Fingerprint) bool {
+ if f.LocalServer != nil {
+ return true
+ }
+ if f.ExecName == "" {
+ return false
+ }
+ return f.StateDir != "" || f.NpmPackage != "" || f.WingetPackage != ""
||
+ f.MSIXFamily != "" || f.DesktopInstallerDir != "" ||
+ len(f.WindowsProgramDirs) != 0 || len(f.WindowsUserDirs) != 0 ||
+ len(f.HomebrewCasks) != 0 || f.SystemPackage != ""
+}
+
+// validateLocalServer rejects incomplete probes.
+func validateLocalServer(server *localserver.Server) error {
+ if server == nil {
+ return nil
+ }
+ if len(server.Ports) == 0 {
+ return errors.New("localServer.ports is required")
+ }
+ if server.ProbePath == "" || len(server.ProbeMarkers) == 0 {
+ return errors.New("localServer needs both probePath and
probeMarkers to confirm anything")
+ }
+ for i, marker := range server.ProbeMarkers {
+ if strings.TrimSpace(marker) == "" {
+ return fmt.Errorf("localServer.probeMarkers[%d] is
empty, which confirms nothing", i)
+ }
+ }
+ return nil
+}
diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go
new file mode 100644
index 0000000..14fca08
--- /dev/null
+++ b/agent/fingerprint_load_test.go
@@ -0,0 +1,125 @@
+// Copyright 2025 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 agent
+
+import (
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+func TestFingerprintsLoad(t *testing.T) {
+ want := []string{
+ "claude-code", "claude-desktop", "codex-cli", "codex",
"cursor-agent",
+ "cursor", "openagent", "openclaw", "windsurf",
+ }
+
+ loaded, err := loadFingerprints(fingerprintFS)
+ if err != nil {
+ t.Fatalf("loadFingerprints() failed: %v", err)
+ }
+ if len(loaded) != len(want) {
+ t.Fatalf("loaded %d fingerprints, want %d", len(loaded),
len(want))
+ }
+ for i, id := range want {
+ if loaded[i].ID != id {
+ t.Errorf("fingerprint %d is %q, want %q", i,
loaded[i].ID, id)
+ }
+ }
+}
+
+func TestLoadFingerprintsRejects(t *testing.T) {
+ tests := []struct {
+ name string
+ file string
+ content string
+ want string
+ }{{
+ name: "malformed json",
+ file: "fingerprints/broken.json",
+ content: `{"id": "broken",`,
+ want: "unexpected EOF",
+ }, {
+ name: "misspelled field",
+ file: "fingerprints/typo.json",
+ content: `{"id": "typo", "displayName": "Typo", "execName":
"typo", "stateDir": "typo", "execNam": "typo"}`,
+ want: "unknown field",
+ }, {
+ name: "legacy cmdline field",
+ file: "fingerprints/legacy-cmd.json",
+ content: `{"id": "legacy-cmd", "displayName": "Legacy",
"cmdMarkers": ["legacy"]}`,
+ want: "unknown field",
+ }, {
+ name: "legacy executable rule field",
+ file: "fingerprints/legacy-rule.json",
+ content: `{"id": "legacy-rule", "displayName": "Legacy",
"extraExecRules": [{"suffix": "/legacy"}]}`,
+ want: "unknown field",
+ }, {
+ name: "legacy notes field",
+ file: "fingerprints/legacy-notes.json",
+ content: `{"id": "legacy-notes", "displayName": "Legacy",
"notes": ["unused"]}`,
+ want: "unknown field",
+ }, {
+ name: "id does not match file name",
+ file: "fingerprints/renamed.json",
+ content: `{"id": "other", "displayName": "Other", "execName":
"other", "stateDir": "other"}`,
+ want: "does not match the file name",
+ }, {
+ name: "missing display name",
+ file: "fingerprints/nameless.json",
+ content: `{"id": "nameless", "execName": "nameless",
"stateDir": "nameless"}`,
+ want: "displayName is required",
+ }, {
+ name: "no evidence at all",
+ file: "fingerprints/ghost.json",
+ content: `{"id": "ghost", "displayName": "Ghost", "execName":
"ghost"}`,
+ want: "no supported installation layout",
+ }, {
+ name: "incomplete local server",
+ file: "fingerprints/server.json",
+ content: `{"id": "server", "displayName": "Server",
"localServer": {"ports": [1234]}}`,
+ want: "probePath and probeMarkers",
+ }, {
+ name: "local server without ports",
+ file: "fingerprints/portless.json",
+ content: `{"id": "portless", "displayName": "Portless",
"localServer": {"probePath": "/health", "probeMarkers": ["x"]}}`,
+ want: "localServer.ports is required",
+ }, {
+ name: "local server with empty marker",
+ file: "fingerprints/empty-marker.json",
+ content: `{"id": "empty-marker", "displayName": "Empty Marker",
"localServer": {"ports": [1234], "probePath": "/health", "probeMarkers":
[""]}}`,
+ want: "confirms nothing",
+ }}
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ files := fstest.MapFS{test.file: &fstest.MapFile{Data:
[]byte(test.content)}}
+ _, err := loadFingerprints(files)
+ if err == nil {
+ t.Fatalf("loadFingerprints() accepted %s",
test.name)
+ }
+ if !strings.Contains(err.Error(), test.want) {
+ t.Errorf("loadFingerprints() error = %q, want
it to mention %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestLoadFingerprintsRejectsEmptyRegistry(t *testing.T) {
+ _, err := loadFingerprints(fstest.MapFS{"other/claude-code.json":
&fstest.MapFile{Data: []byte("{}")}})
+ if err == nil || !strings.Contains(err.Error(), "no fingerprint files")
{
+ t.Errorf("loadFingerprints() on an empty registry = %v, want a
no-fingerprint-files error", err)
+ }
+}
diff --git a/agent/fingerprints/claude-code.json
b/agent/fingerprints/claude-code.json
new file mode 100644
index 0000000..d488603
--- /dev/null
+++ b/agent/fingerprints/claude-code.json
@@ -0,0 +1,10 @@
+{
+ "id": "claude-code",
+ "displayName": "Claude Code",
+ "execName": "claude",
+ "stateDir": "claude",
+ "npmPackage": "@anthropic-ai/claude-code",
+ "wingetPackage": "Anthropic.ClaudeCode",
+ "homebrewCasks": ["claude-code", "claude-code@latest"],
+ "systemPackage": "claude-code"
+}
diff --git a/agent/fingerprints/claude-desktop.json
b/agent/fingerprints/claude-desktop.json
new file mode 100644
index 0000000..5f95584
--- /dev/null
+++ b/agent/fingerprints/claude-desktop.json
@@ -0,0 +1,7 @@
+{
+ "id": "claude-desktop",
+ "displayName": "Claude Desktop",
+ "execName": "claude",
+ "msixFamily": "Claude_pzs8sxrjxfjjc",
+ "desktopInstallerDir": "AnthropicClaude"
+}
diff --git a/agent/fingerprints/codex-cli.json
b/agent/fingerprints/codex-cli.json
new file mode 100644
index 0000000..c8c523e
--- /dev/null
+++ b/agent/fingerprints/codex-cli.json
@@ -0,0 +1,9 @@
+{
+ "id": "codex-cli",
+ "displayName": "Codex CLI",
+ "execName": "codex",
+ "stateDir": "codex",
+ "npmPackage": "@openai/codex",
+ "homebrewCasks": ["codex", "codex@latest"],
+ "windowsUserDirs": ["OpenAI/Codex/bin"]
+}
diff --git a/agent/fingerprints/codex.json b/agent/fingerprints/codex.json
new file mode 100644
index 0000000..6dc0e6e
--- /dev/null
+++ b/agent/fingerprints/codex.json
@@ -0,0 +1,6 @@
+{
+ "id": "codex",
+ "displayName": "ChatGPT Desktop (Codex)",
+ "execName": "ChatGPT",
+ "msixFamily": "OpenAI.Codex_2p2nqsd0c76g0"
+}
diff --git a/agent/fingerprints/cursor-agent.json
b/agent/fingerprints/cursor-agent.json
new file mode 100644
index 0000000..779e53c
--- /dev/null
+++ b/agent/fingerprints/cursor-agent.json
@@ -0,0 +1,7 @@
+{
+ "id": "cursor-agent",
+ "displayName": "Cursor Agent",
+ "execName": "cursor-agent",
+ "stateDir": "cursor-agent",
+ "windowsProgramDirs": ["cursor-agent"]
+}
diff --git a/agent/fingerprints/cursor.json b/agent/fingerprints/cursor.json
new file mode 100644
index 0000000..6f4bbad
--- /dev/null
+++ b/agent/fingerprints/cursor.json
@@ -0,0 +1,7 @@
+{
+ "id": "cursor",
+ "displayName": "Cursor",
+ "execName": "Cursor",
+ "windowsProgramDirs": ["cursor"],
+ "homebrewCasks": ["cursor"]
+}
diff --git a/agent/fingerprints/openagent.json
b/agent/fingerprints/openagent.json
new file mode 100644
index 0000000..ac4b31b
--- /dev/null
+++ b/agent/fingerprints/openagent.json
@@ -0,0 +1,17 @@
+{
+ "id": "openagent",
+ "displayName": "OpenAgent",
+ "execName": "openagent",
+ "stateDir": "openagent",
+ "windowsUserDirs": ["openagent"],
+ "buildInfoModule": "github.com/the-open-agent/openagent",
+ "buildInfoVersionVar":
"github.com/the-open-agent/openagent/internal/cli.Version",
+ "versionFile": "version",
+ "localServer": {
+ "ports": [14000],
+ "probePath": "/api/health",
+ "probeMarkers": ["openagent_session_id"],
+ "versionPath": "/api/get-version-info",
+ "versionFields": ["data", "version"]
+ }
+}
diff --git a/agent/fingerprints/openclaw.json b/agent/fingerprints/openclaw.json
new file mode 100644
index 0000000..1c60c1f
--- /dev/null
+++ b/agent/fingerprints/openclaw.json
@@ -0,0 +1,8 @@
+{
+ "id": "openclaw",
+ "displayName": "OpenClaw",
+ "execName": "openclaw",
+ "npmPackage": "openclaw",
+ "extraUnixNpmDirs": [".openclaw/tools/node-v*/lib"],
+ "extraWindowsNpmDirs": ["OpenClaw/deps/portable-node"]
+}
diff --git a/agent/fingerprints/windsurf.json b/agent/fingerprints/windsurf.json
new file mode 100644
index 0000000..c18800f
--- /dev/null
+++ b/agent/fingerprints/windsurf.json
@@ -0,0 +1,7 @@
+{
+ "id": "windsurf",
+ "displayName": "Windsurf",
+ "execName": "Windsurf",
+ "windowsProgramDirs": ["Windsurf"],
+ "homebrewCasks": ["windsurf"]
+}
diff --git a/agent/scan.go b/agent/scan.go
new file mode 100644
index 0000000..944ba6a
--- /dev/null
+++ b/agent/scan.go
@@ -0,0 +1,102 @@
+// Copyright 2025 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 agent
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+)
+
+const (
+ scanCacheTTL = 30 * time.Second
+ scanTimeout = 10 * time.Second
+)
+
+type scanCall struct {
+ done chan struct{}
+ result []Installation
+ err error
+}
+
+var installationCache = struct {
+ sync.Mutex
+ result []Installation
+ updatedAt time.Time
+ current *scanCall
+}{}
+
+// Scan returns installations visible to Gateway and caches successful results.
+func Scan(forceRefresh bool) ([]Installation, error) {
+ installationCache.Lock()
+ if !forceRefresh && time.Since(installationCache.updatedAt) <
scanCacheTTL {
+ result := cloneInstallations(installationCache.result)
+ installationCache.Unlock()
+ return result, nil
+ }
+ if installationCache.current != nil {
+ current := installationCache.current
+ installationCache.Unlock()
+ <-current.done
+ return cloneInstallations(current.result), current.err
+ }
+
+ current := &scanCall{done: make(chan struct{})}
+ installationCache.current = current
+ installationCache.Unlock()
+
+ current.result, current.err = scanWithTimeout()
+
+ installationCache.Lock()
+ if current.err == nil {
+ installationCache.result = cloneInstallations(current.result)
+ installationCache.updatedAt = time.Now()
+ }
+ installationCache.current = nil
+ close(current.done)
+ installationCache.Unlock()
+
+ return cloneInstallations(current.result), current.err
+}
+
+func scanWithTimeout() ([]Installation, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
+ defer cancel()
+
+ result := make(chan []Installation, 1)
+ go func() {
+ result <- scan(ctx)
+ }()
+
+ select {
+ case installations := <-result:
+ if err := ctx.Err(); err != nil {
+ return nil, fmt.Errorf("agent scan timed out after %s:
%w", scanTimeout, err)
+ }
+ return installations, nil
+ case <-ctx.Done():
+ return nil, fmt.Errorf("agent scan timed out after %s: %w",
scanTimeout, ctx.Err())
+ }
+}
+
+func cloneInstallations(installations []Installation) []Installation {
+ if installations == nil {
+ return nil
+ }
+ result := make([]Installation, len(installations))
+ copy(result, installations)
+ return result
+}
diff --git a/agent/scan_common.go b/agent/scan_common.go
new file mode 100644
index 0000000..aa72c53
--- /dev/null
+++ b/agent/scan_common.go
@@ -0,0 +1,188 @@
+// Copyright 2025 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 agent
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+
+ "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// maxPackageJSONSize limits reads from glob-matched manifests.
+const maxPackageJSONSize = 1024 * 1024
+
+// scanNpmPatterns validates npm packages found by known glob patterns.
+func scanNpmPatterns(ctx context.Context, fingerprint *Fingerprint, patterns
[]string, owner string, ownerFor func(string) string) []Installation {
+ if fingerprint.NpmPackage == "" {
+ return nil
+ }
+
+ var result []Installation
+ for _, pattern := range patterns {
+ if ctx.Err() != nil {
+ return result
+ }
+ matches, _ := filepath.Glob(pattern)
+ for _, packageJSON := range matches {
+ if ctx.Err() != nil {
+ return result
+ }
+ info, err := os.Stat(packageJSON)
+ if err != nil || !info.Mode().IsRegular() ||
info.Size() > maxPackageJSONSize {
+ continue
+ }
+ data, err := os.ReadFile(packageJSON)
+ if err != nil {
+ continue
+ }
+ var pkg struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ }
+ if json.Unmarshal(data, &pkg) != nil || pkg.Name !=
fingerprint.NpmPackage || pkg.Version == "" {
+ continue
+ }
+ packageOwner := owner
+ if packageOwner == "" && ownerFor != nil {
+ packageOwner = ownerFor(packageJSON)
+ }
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Version:
pkg.Version, Path: filepath.Dir(packageJSON),
+ InstallMethod: "npm", Owner: packageOwner,
+ })
+ }
+ }
+ return result
+}
+
+func (f *Fingerprint) npmPackagePath() string {
+ return filepath.FromSlash(f.NpmPackage)
+}
+
+func stampAgentId(installations []Installation, mark int, agentId string) {
+ for i := mark; i < len(installations); i++ {
+ installations[i].AgentId = agentId
+ }
+}
+
+// dedupeInstallations returns unique installations ordered by owner and path.
+func dedupeInstallations(installations []Installation) []Installation {
+ seen := map[string]bool{}
+ result := make([]Installation, 0, len(installations))
+ for _, installation := range installations {
+ key := canonicalPath(installation.Path)
+ if key == "" {
+ continue
+ }
+ if runtime.GOOS == "windows" {
+ key = strings.ToLower(key)
+ }
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ result = append(result, installation)
+ }
+ sort.Slice(result, func(i, j int) bool {
+ if result[i].Owner != result[j].Owner {
+ return result[i].Owner < result[j].Owner
+ }
+ return result[i].Path < result[j].Path
+ })
+ return result
+}
+
+func canonicalPath(path string) string {
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
+ return resolved
+ }
+ return filepath.Clean(path)
+}
+
+// versionUnderDir extracts a version directory below root.
+func versionUnderDir(root, target string) string {
+ relative, err := filepath.Rel(root, target)
+ if err != nil || relative == "." || relative == ".." ||
strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
+ return ""
+ }
+ return strings.Split(relative, string(filepath.Separator))[0]
+}
+
+func findExecutablePath(files, execName string) string {
+ for _, line := range strings.Split(files, "\n") {
+ line = strings.TrimSpace(line)
+ if filepath.Base(line) == execName {
+ return line
+ }
+ }
+ return ""
+}
+
+func hermesInstallation(launcher, owner string, officialRoots ...string)
[]Installation {
+ project, err := hermes.InspectLauncher(launcher, officialRoots...)
+ if err != nil {
+ return nil
+ }
+ method := "source"
+ for _, root := range officialRoots {
+ if sameHermesPath(root, project.Root) {
+ method = "native"
+ break
+ }
+ }
+ return []Installation{{
+ AgentId: hermes.AgentID, Name: hermes.DisplayName, Version:
project.Version,
+ Path: launcher, InstallMethod: method, Owner: owner,
+ }}
+}
+
+func scanHermesOnPath() []Installation {
+ launcher, err := exec.LookPath(hermes.ExecName)
+ if err != nil && runtime.GOOS == "windows" {
+ launcher, err = exec.LookPath(hermes.ExecName + ".exe")
+ }
+ if err != nil {
+ return nil
+ }
+ owner := ""
+ if account, userErr := user.Current(); userErr == nil {
+ owner = account.Username
+ }
+ return hermesInstallation(launcher, owner)
+}
+
+func sameHermesPath(left, right string) bool {
+ leftResolved, leftErr := filepath.EvalSymlinks(left)
+ rightResolved, rightErr := filepath.EvalSymlinks(right)
+ if leftErr == nil {
+ left = leftResolved
+ }
+ if rightErr == nil {
+ right = rightResolved
+ }
+ left, right = filepath.Clean(left), filepath.Clean(right)
+ if runtime.GOOS == "windows" {
+ return strings.EqualFold(left, right)
+ }
+ return left == right
+}
diff --git a/agent/scan_darwin.go b/agent/scan_darwin.go
new file mode 100644
index 0000000..1784344
--- /dev/null
+++ b/agent/scan_darwin.go
@@ -0,0 +1,130 @@
+// Copyright 2025 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.
+
+//go:build darwin
+
+package agent
+
+import (
+ "context"
+ "os"
+ "os/user"
+ "path/filepath"
+
+ "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// scan finds installations of every known agent in known macOS layouts. It
+// neither executes discovered binaries nor walks arbitrary filesystem roots.
+func scan(ctx context.Context) []Installation {
+ homes := darwinHomes()
+
+ var installations []Installation
+ for i := range fingerprints {
+ fingerprint := &fingerprints[i]
+ if ctx.Err() != nil {
+ return installations
+ }
+ mark := len(installations)
+ for _, home := range homes {
+ if ctx.Err() != nil {
+ return installations
+ }
+ installations = append(installations,
scanNative(fingerprint, home)...)
+ installations = append(installations,
scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path),
home.owner, fileOwner)...)
+ }
+ for _, prefix := range []string{"/opt/homebrew", "/usr/local"} {
+ installations = append(installations,
scanDarwinHomebrew(fingerprint, prefix)...)
+ installations = append(installations,
scanDarwinSystemNpm(ctx, fingerprint, prefix)...)
+ }
+ stampAgentId(installations, mark, fingerprint.ID)
+ fillMissingVersions(installations, mark, fingerprint)
+ }
+ for _, home := range homes {
+ if ctx.Err() != nil {
+ return installations
+ }
+ installations = append(installations, scanHermesUnix(home,
filepath.Join(home.path, ".local", "bin", hermes.ExecName))...)
+ }
+ installations = append(installations, scanHermesOnPath()...)
+ installations = append(installations, scanCodexStandalone()...)
+ installations = append(installations, scanCodexDarwinApps(homes)...)
+ // Last, so that an agent found both on disk and by its port keeps the
+ // richer install-layout row when the two resolve to the same
executable.
+ installations = append(installations, scanLocalServers(ctx)...)
+ return
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+}
+
+func darwinHomes() []homeDir {
+ seen := map[string]bool{}
+ var homes []homeDir
+ add := func(owner, path string) {
+ path = filepath.Clean(path)
+ if info, err := os.Stat(path); err != nil || !info.IsDir() ||
seen[path] {
+ return
+ }
+ seen[path] = true
+ if owner == "" {
+ owner = filepath.Base(path)
+ }
+ homes = append(homes, homeDir{owner: owner, path: path})
+ }
+
+ if entries, err := os.ReadDir("/Users"); err == nil {
+ for _, entry := range entries {
+ if entry.Name() != "Shared" {
+ add(entry.Name(), filepath.Join("/Users",
entry.Name()))
+ }
+ }
+ }
+ add("root", "/var/root")
+ if home, err := os.UserHomeDir(); err == nil {
+ owner := ""
+ if account, err := user.Current(); err == nil {
+ owner = account.Username
+ }
+ add(owner, home)
+ }
+ return homes
+}
+
+// scanDarwinHomebrew reads the Caskroom layout directly; on macOS every cask
+// version keeps its own directory, so no brew invocation is needed.
+func scanDarwinHomebrew(fingerprint *Fingerprint, prefix string)
[]Installation {
+ if fingerprint.ExecName == "" {
+ return nil
+ }
+
+ var result []Installation
+ for _, cask := range fingerprint.HomebrewCasks {
+ versions, _ := filepath.Glob(filepath.Join(prefix, "Caskroom",
cask, "*"))
+ for _, versionDir := range versions {
+ executable := filepath.Join(versionDir,
fingerprint.ExecName)
+ info, err := os.Stat(executable)
+ if err != nil || !info.Mode().IsRegular() ||
info.Mode()&0o111 == 0 {
+ continue
+ }
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Version:
filepath.Base(versionDir), Path: executable,
+ InstallMethod: "homebrew", Owner:
fileOwner(executable),
+ })
+ }
+ }
+ return result
+}
+
+func scanDarwinSystemNpm(ctx context.Context, fingerprint *Fingerprint, prefix
string) []Installation {
+ pattern := filepath.Join(prefix, "lib", "node_modules",
fingerprint.npmPackagePath(), "package.json")
+ return scanNpmPatterns(ctx, fingerprint, []string{pattern}, "",
fileOwner)
+}
diff --git a/agent/scan_linux.go b/agent/scan_linux.go
new file mode 100644
index 0000000..26b9921
--- /dev/null
+++ b/agent/scan_linux.go
@@ -0,0 +1,216 @@
+// Copyright 2025 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.
+
+//go:build linux
+
+package agent
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// scan finds installations of every known agent without executing any
+// discovered binary or traversing arbitrary filesystem roots.
+func scan(ctx context.Context) []Installation {
+ homes := readHomes("/etc/passwd")
+
+ var installations []Installation
+ for i := range fingerprints {
+ fingerprint := &fingerprints[i]
+ if ctx.Err() != nil {
+ return installations
+ }
+ mark := len(installations)
+ for _, home := range homes {
+ if ctx.Err() != nil {
+ return installations
+ }
+ installations = append(installations,
scanNative(fingerprint, home)...)
+ installations = append(installations,
scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path),
home.owner, fileOwner)...)
+ }
+ installations = append(installations, scanSystemNpm(ctx,
fingerprint)...)
+ installations = append(installations, scanSystemPackages(ctx,
fingerprint)...)
+ installations = append(installations, scanHomebrew(ctx,
fingerprint)...)
+ stampAgentId(installations, mark, fingerprint.ID)
+ fillMissingVersions(installations, mark, fingerprint)
+ }
+ for _, home := range homes {
+ if ctx.Err() != nil {
+ return installations
+ }
+ installations = append(installations, scanHermesUnix(home,
filepath.Join(home.path, ".local", "bin", hermes.ExecName))...)
+ }
+ installations = append(installations, scanHermesUnix(
+ homeDir{owner: "root", path: "/root"},
+ filepath.Join("/usr/local/bin", hermes.ExecName),
+ filepath.Join("/usr/local/lib", hermes.ProjectDir),
+ filepath.Join("/root/.hermes", hermes.ProjectDir),
+ )...)
+ installations = append(installations, scanHermesOnPath()...)
+ installations = append(installations, scanCodexStandalone()...)
+ // Last, so that an agent found both on disk and by its port keeps the
+ // richer install-layout row when the two resolve to the same
executable.
+ installations = append(installations, scanLocalServers(ctx)...)
+ return
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+}
+
+func readHomes(passwdPath string) []homeDir {
+ data, err := os.ReadFile(passwdPath)
+ if err != nil {
+ return nil
+ }
+
+ seen := map[string]bool{}
+ var homes []homeDir
+ for _, line := range strings.Split(string(data), "\n") {
+ fields := strings.Split(line, ":")
+ if len(fields) < 6 || !filepath.IsAbs(fields[5]) ||
seen[fields[5]] {
+ continue
+ }
+ info, err := os.Stat(fields[5])
+ if err != nil || !info.IsDir() {
+ continue
+ }
+ seen[fields[5]] = true
+ homes = append(homes, homeDir{owner: fields[0], path:
fields[5]})
+ }
+ return homes
+}
+
+func scanSystemNpm(ctx context.Context, fingerprint *Fingerprint)
[]Installation {
+ pkg := fingerprint.npmPackagePath()
+ return scanNpmPatterns(ctx, fingerprint, []string{
+ filepath.Join("/usr/local/lib/node_modules", pkg,
"package.json"),
+ filepath.Join("/usr/lib/node_modules", pkg, "package.json"),
+ }, "", fileOwner)
+}
+
+// scanSystemPackages queries each distro package manager for the agent, which
+// reports both its version and the files it owns.
+func scanSystemPackages(ctx context.Context, fingerprint *Fingerprint)
[]Installation {
+ pkg := fingerprint.SystemPackage
+ if pkg == "" {
+ return nil
+ }
+
+ var result []Installation
+ if version, ok := commandOutput(ctx, "dpkg-query", "-W",
"-f=${Version}", pkg); ok {
+ files, _ := commandOutput(ctx, "dpkg", "-L", pkg)
+ result = append(result, packageInstallation(fingerprint, "apt",
version, files))
+ }
+ if version, ok := commandOutput(ctx, "rpm", "-q", "--qf",
"%{VERSION}-%{RELEASE}", pkg); ok {
+ files, _ := commandOutput(ctx, "rpm", "-ql", pkg)
+ result = append(result, packageInstallation(fingerprint, "rpm",
version, files))
+ }
+ if installed, ok := commandOutput(ctx, "apk", "info", "-e", pkg); ok &&
strings.TrimSpace(installed) != "" {
+ version, _ := commandOutput(ctx, "apk", "info", "-v", pkg)
+ files, _ := commandOutput(ctx, "apk", "info", "-L", pkg)
+ version = strings.TrimPrefix(strings.TrimSpace(version),
pkg+"-")
+ result = append(result, packageInstallation(fingerprint, "apk",
version, files))
+ }
+ return result
+}
+
+func packageInstallation(fingerprint *Fingerprint, method, version, files
string) Installation {
+ path := findExecutablePath(files, fingerprint.ExecName)
+ if path == "" {
+ path = filepath.Join("/usr/bin", fingerprint.ExecName)
+ }
+ return Installation{
+ Name: fingerprint.DisplayName, Version:
strings.TrimSpace(version), Path: path,
+ InstallMethod: method, Owner: "root",
+ }
+}
+
+func scanHomebrew(ctx context.Context, fingerprint *Fingerprint)
[]Installation {
+ if len(fingerprint.HomebrewCasks) == 0 {
+ return nil
+ }
+
+ var result []Installation
+ for _, brew := range []string{"/home/linuxbrew/.linuxbrew/bin/brew",
"/opt/homebrew/bin/brew", "/usr/local/bin/brew"} {
+ if ctx.Err() != nil {
+ return result
+ }
+ if info, err := os.Stat(brew); err != nil ||
!info.Mode().IsRegular() {
+ continue
+ }
+ owner := fileOwner(brew)
+ prefix := filepath.Dir(filepath.Dir(brew))
+ result = append(result, scanHomebrewCaskroom(fingerprint,
prefix, owner)...)
+ for _, cask := range fingerprint.HomebrewCasks {
+ out, ok := commandOutputPath(ctx, brew, "list",
"--cask", "--versions", cask)
+ if !ok || strings.TrimSpace(out) == "" {
+ continue
+ }
+ fields := strings.Fields(out)
+ version := ""
+ if len(fields) > 1 {
+ version = fields[len(fields)-1]
+ }
+ files, _ := commandOutputPath(ctx, brew, "list",
"--cask", cask)
+ path := findExecutablePath(files, fingerprint.ExecName)
+ if path == "" {
+ path = filepath.Join(filepath.Dir(brew),
fingerprint.ExecName)
+ }
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Version:
version, Path: path,
+ InstallMethod: "homebrew", Owner: owner,
+ })
+ }
+ }
+ return result
+}
+
+// scanHomebrewCaskroom reads the version straight from the Caskroom layout, so
+// a cask still resolves when the brew command itself is unavailable.
+func scanHomebrewCaskroom(fingerprint *Fingerprint, prefix, owner string)
[]Installation {
+ launcher := filepath.Join(prefix, "bin", fingerprint.ExecName)
+ target, err := filepath.EvalSymlinks(launcher)
+ if err != nil {
+ return nil
+ }
+ for _, cask := range fingerprint.HomebrewCasks {
+ version := versionUnderDir(filepath.Join(prefix, "Caskroom",
cask), target)
+ if version != "" {
+ return []Installation{{
+ Name: fingerprint.DisplayName, Version:
version, Path: launcher,
+ InstallMethod: "homebrew", Owner: owner,
+ }}
+ }
+ }
+ return nil
+}
+
+func commandOutput(ctx context.Context, name string, args ...string) (string,
bool) {
+ path, err := exec.LookPath(name)
+ if err != nil {
+ return "", false
+ }
+ return commandOutputPath(ctx, path, args...)
+}
+
+func commandOutputPath(ctx context.Context, path string, args ...string)
(string, bool) {
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, path, args...).Output()
+ return string(out), err == nil
+}
diff --git a/agent/scan_localserver.go b/agent/scan_localserver.go
new file mode 100644
index 0000000..0898c10
--- /dev/null
+++ b/agent/scan_localserver.go
@@ -0,0 +1,75 @@
+// Copyright 2025 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 agent
+
+import (
+ "context"
+ "os"
+ "strings"
+
+ "github.com/apache/casbin-gateway/localserver"
+)
+
+const localServerInstallMethod = "source"
+
+// scanLocalServers detects agents listening on configured loopback ports.
+func scanLocalServers(ctx context.Context) []Installation {
+ var installations []Installation
+ for i := range fingerprints {
+ fingerprint := &fingerprints[i]
+ if ctx.Err() != nil {
+ return installations
+ }
+ if fingerprint.LocalServer == nil {
+ continue
+ }
+ mark := len(installations)
+ for _, port := range fingerprint.LocalServer.Ports {
+ installations = append(installations,
scanLocalServerPort(ctx, fingerprint, port)...)
+ }
+ stampAgentId(installations, mark, fingerprint.ID)
+ fillMissingVersions(installations, mark, fingerprint)
+ }
+ return installations
+}
+
+func scanLocalServerPort(ctx context.Context, fingerprint *Fingerprint, port
int) []Installation {
+ base, ok := fingerprint.LocalServer.Confirm(ctx, port)
+ if !ok {
+ return nil
+ }
+ version := sanitizeVersion(fingerprint.LocalServer.Version(ctx, base))
+
+ var result []Installation
+ seen := map[string]bool{}
+ for _, process := range localserver.Listeners(ctx, port) {
+ if ctx.Err() != nil {
+ return result
+ }
+ key := strings.ToLower(process.Path)
+ if process.Path == "" || seen[key] {
+ continue
+ }
+ seen[key] = true
+ if info, err := os.Stat(process.Path); err != nil ||
!info.Mode().IsRegular() {
+ continue
+ }
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Version: version, Path:
process.Path,
+ InstallMethod: localServerInstallMethod, Owner:
process.Owner,
+ })
+ }
+ return result
+}
diff --git a/agent/scan_unix.go b/agent/scan_unix.go
new file mode 100644
index 0000000..8883c06
--- /dev/null
+++ b/agent/scan_unix.go
@@ -0,0 +1,109 @@
+// Copyright 2025 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.
+
+//go:build linux || darwin
+
+package agent
+
+import (
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "syscall"
+
+ "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+// homeDir is one user's home directory and the account that owns it.
+type homeDir struct {
+ owner string
+ path string
+}
+
+// scanNative reports the per-user native installer layout: a launcher at
+// ~/.local/bin/<exec> pointing into ~/.local/share/<state>/versions/<version>.
+func scanNative(fingerprint *Fingerprint, home homeDir) []Installation {
+ if fingerprint.ExecName == "" || fingerprint.StateDir == "" {
+ return nil
+ }
+
+ launcher := filepath.Join(home.path, ".local", "bin",
fingerprint.ExecName)
+ info, err := os.Stat(launcher)
+ if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
+ return nil
+ }
+
+ version := ""
+ if target, err := filepath.EvalSymlinks(launcher); err == nil {
+ version = versionUnderDir(filepath.Join(home.path, ".local",
"share", fingerprint.StateDir, "versions"), target)
+ }
+ return []Installation{{
+ Name: fingerprint.DisplayName, Version: version, Path: launcher,
+ InstallMethod: "native", Owner: home.owner,
+ }}
+}
+
+// userNpmPatterns are the per-user Node package manager layouts that can hold
a
+// globally installed agent package, plus any bundled runtime the agent
declares.
+func userNpmPatterns(fingerprint *Fingerprint, home string) []string {
+ pkg := fingerprint.npmPackagePath()
+ patterns := []string{
+ filepath.Join(home, ".npm-global", "lib", "node_modules", pkg,
"package.json"),
+ filepath.Join(home, ".nvm", "versions", "node", "*", "lib",
"node_modules", pkg, "package.json"),
+ // fnm honours XDG when it is set and falls back to ~/.fnm
otherwise.
+ filepath.Join(home, ".fnm", "node-versions", "*",
"installation", "lib", "node_modules", pkg, "package.json"),
+ filepath.Join(home, ".local", "share", "fnm", "node-versions",
"*", "installation", "lib", "node_modules", pkg, "package.json"),
+ filepath.Join(home, ".volta", "tools", "image", "packages",
pkg, "lib", "node_modules", pkg, "package.json"),
+ filepath.Join(home, ".asdf", "installs", "nodejs", "*", "lib",
"node_modules", pkg, "package.json"),
+ // pnpm groups its global packages under a store layout version.
+ filepath.Join(home, ".local", "share", "pnpm", "global", "*",
"node_modules", pkg, "package.json"),
+ }
+ for _, dir := range fingerprint.ExtraUnixNpmDirs {
+ patterns = append(patterns, filepath.Join(home,
filepath.FromSlash(dir), "node_modules", pkg, "package.json"))
+ }
+ return patterns
+}
+
+func fileOwner(path string) string {
+ info, err := os.Stat(path)
+ if err != nil {
+ return "root"
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return "root"
+ }
+ id := strconv.FormatUint(uint64(stat.Uid), 10)
+ account, err := user.LookupId(id)
+ if err != nil {
+ return id
+ }
+ return account.Username
+}
+
+func scanHermesUnix(home homeDir, launcher string, roots ...string)
[]Installation {
+ info, err := os.Stat(launcher)
+ if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
+ return nil
+ }
+ if len(roots) == 0 {
+ roots = []string{filepath.Join(home.path, ".hermes",
hermes.ProjectDir)}
+ if currentHome, homeErr := os.UserHomeDir(); homeErr == nil &&
+ sameHermesPath(currentHome, home.path) &&
os.Getenv("HERMES_HOME") != "" {
+ roots =
[]string{filepath.Join(os.Getenv("HERMES_HOME"), hermes.ProjectDir)}
+ }
+ }
+ return hermesInstallation(launcher, home.owner, roots...)
+}
diff --git a/agent/scan_windows.go b/agent/scan_windows.go
new file mode 100644
index 0000000..f9feb43
--- /dev/null
+++ b/agent/scan_windows.go
@@ -0,0 +1,333 @@
+// Copyright 2025 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.
+
+//go:build windows
+
+package agent
+
+import (
+ "context"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+
+ "github.com/apache/casbin-gateway/internal/hermes"
+ "golang.org/x/sys/windows/registry"
+)
+
+// homeDir is one user's profile directory and the account that owns it.
+type homeDir struct {
+ owner string
+ path string
+}
+
+// scan finds installations of every known agent in known Windows layouts. It
+// neither executes discovered binaries nor walks arbitrary filesystem roots.
+func scan(ctx context.Context) []Installation {
+ homes := windowsHomes(ctx)
+
+ var installations []Installation
+ for i := range fingerprints {
+ fingerprint := &fingerprints[i]
+ if ctx.Err() != nil {
+ return installations
+ }
+ mark := len(installations)
+ for _, home := range homes {
+ if ctx.Err() != nil {
+ return installations
+ }
+ installations = append(installations,
scanWindowsNative(fingerprint, home)...)
+ installations = append(installations,
scanWindowsWinget(ctx, fingerprint, home)...)
+ installations = append(installations,
scanWindowsNpm(ctx, fingerprint, home)...)
+ installations = append(installations,
scanWindowsUserPrograms(ctx, fingerprint, home)...)
+ }
+ installations = append(installations, scanWindowsDesktop(ctx,
fingerprint, homes)...)
+ installations = append(installations, scanMachineWinget(ctx,
fingerprint)...)
+ installations = append(installations, scanMachinePrograms(ctx,
fingerprint)...)
+ stampAgentId(installations, mark, fingerprint.ID)
+ fillMissingVersions(installations, mark, fingerprint)
+ }
+ for _, home := range homes {
+ if ctx.Err() != nil {
+ return installations
+ }
+ installations = append(installations,
scanHermesWindows(home)...)
+ }
+ installations = append(installations, scanHermesOnPath()...)
+
+ // Last, so that an agent found both on disk and by its port keeps the
+ // richer install-layout row when the two resolve to the same
executable.
+ installations = append(installations, scanLocalServers(ctx)...)
+
+ installations =
expandSharedCodexWindowsInstallations(dedupeInstallations(installations), homes)
+ // Whatever layout an installation came from, its launcher may carry a
+ // version resource, so fall back to that rather than reporting no
version.
+ for i := range installations {
+ if ctx.Err() != nil {
+ return installations
+ }
+ if installations[i].Version == "" {
+ installations[i].Version =
executableVersion(installations[i].Path)
+ }
+ }
+ return installations
+}
+
+func windowsHomes(ctx context.Context) []homeDir {
+ seen := map[string]bool{}
+ var homes []homeDir
+ add := func(owner, path string) {
+ path = filepath.Clean(path)
+ if info, err := os.Stat(path); err != nil || !info.IsDir() {
+ return
+ }
+ key := strings.ToLower(path)
+ if seen[key] {
+ return
+ }
+ seen[key] = true
+ if owner == "" {
+ owner = filepath.Base(path)
+ }
+ homes = append(homes, homeDir{owner: owner, path: path})
+ }
+
+ const profileList = `SOFTWARE\Microsoft\Windows
NT\CurrentVersion\ProfileList`
+ key, err := registry.OpenKey(registry.LOCAL_MACHINE, profileList,
registry.ENUMERATE_SUB_KEYS)
+ if err == nil {
+ defer key.Close()
+ if sids, err := key.ReadSubKeyNames(-1); err == nil {
+ for _, sid := range sids {
+ if ctx.Err() != nil {
+ return homes
+ }
+ profile, err := registry.OpenKey(key, sid,
registry.QUERY_VALUE)
+ if err != nil {
+ continue
+ }
+ path, _, readErr :=
profile.GetStringValue("ProfileImagePath")
+ profile.Close()
+ if readErr != nil {
+ continue
+ }
+ if expanded, err :=
registry.ExpandString(path); err == nil {
+ path = expanded
+ }
+ owner := ""
+ if account, err := user.LookupId(sid); err ==
nil {
+ owner = account.Username
+ }
+ add(owner, path)
+ }
+ }
+ }
+
+ if home, err := os.UserHomeDir(); err == nil {
+ owner := ""
+ if account, err := user.Current(); err == nil {
+ owner = account.Username
+ }
+ add(owner, home)
+ }
+ return homes
+}
+
+// appDataDir respects relocated AppData paths for the current user.
+func appDataDir(home homeDir, kind, variable string) string {
+ if current, err := os.UserHomeDir(); err == nil &&
strings.EqualFold(filepath.Clean(current), filepath.Clean(home.path)) {
+ if configured := os.Getenv(variable); configured != "" {
+ return configured
+ }
+ }
+ return filepath.Join(home.path, "AppData", kind)
+}
+
+func localAppData(home homeDir) string { return appDataDir(home, "Local",
"LOCALAPPDATA") }
+func roamingAppData(home homeDir) string { return appDataDir(home, "Roaming",
"APPDATA") }
+
+func scanHermesWindows(home homeDir) []Installation {
+ hermesHome := filepath.Join(localAppData(home), "hermes")
+ if current, err := os.UserHomeDir(); err == nil &&
+ strings.EqualFold(filepath.Clean(current),
filepath.Clean(home.path)) {
+ if configured := os.Getenv("HERMES_HOME"); configured != "" {
+ hermesHome = configured
+ }
+ }
+ root := filepath.Join(hermesHome, hermes.ProjectDir)
+ launcher := filepath.Join(root, "venv", "Scripts",
hermes.ExecName+".exe")
+ return hermesInstallation(launcher, home.owner, root)
+}
+
+// scanWindowsNative reports the per-user native installer layout: a launcher
at
+// %USERPROFILE%\.local\bin\<exec>.exe backed by a versioned payload directory.
+func scanWindowsNative(fingerprint *Fingerprint, home homeDir) []Installation {
+ if fingerprint.ExecName == "" || fingerprint.StateDir == "" {
+ return nil
+ }
+
+ launcher := filepath.Join(home.path, ".local", "bin",
fingerprint.ExecName+".exe")
+ launcherInfo, err := os.Stat(launcher)
+ if err != nil || !launcherInfo.Mode().IsRegular() {
+ return nil
+ }
+
+ version := ""
+ versionsDir := filepath.Join(home.path, ".local", "share",
fingerprint.StateDir, "versions")
+ if target, err := filepath.EvalSymlinks(launcher); err == nil {
+ version = versionUnderDir(versionsDir, target)
+ }
+ // Windows installs may hard-link rather than symlink the launcher, in
which
+ // case the version is only recoverable by identity against the
payloads.
+ if version == "" {
+ if entries, err := os.ReadDir(versionsDir); err == nil {
+ for _, entry := range entries {
+ candidate, err := entry.Info()
+ if err == nil && candidate.Mode().IsRegular()
&& os.SameFile(launcherInfo, candidate) {
+ version = entry.Name()
+ break
+ }
+ }
+ }
+ }
+ return []Installation{{
+ Name: fingerprint.DisplayName, Version: version, Path: launcher,
+ InstallMethod: "native", Owner: home.owner,
+ }}
+}
+
+func scanWindowsWinget(ctx context.Context, fingerprint *Fingerprint, home
homeDir) []Installation {
+ root := filepath.Join(localAppData(home), "Microsoft", "WinGet",
"Packages")
+ return scanWingetPackages(ctx, fingerprint, root, home.owner)
+}
+
+func scanMachineWinget(ctx context.Context, fingerprint *Fingerprint)
[]Installation {
+ var result []Installation
+ seen := map[string]bool{}
+ for _, variable := range []string{"ProgramFiles", "ProgramFiles(x86)"} {
+ if ctx.Err() != nil {
+ return result
+ }
+ base := os.Getenv(variable)
+ if base == "" {
+ continue
+ }
+ root := filepath.Join(base, "WinGet", "Packages")
+ key := strings.ToLower(filepath.Clean(root))
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ result = append(result, scanWingetPackages(ctx, fingerprint,
root, "SYSTEM")...)
+ }
+ return result
+}
+
+// scanWindowsUserPrograms checks known per-user installer directories.
+func scanWindowsUserPrograms(ctx context.Context, fingerprint *Fingerprint,
home homeDir) []Installation {
+ local := localAppData(home)
+ result := scanWindowsInstallDirs(ctx, fingerprint, filepath.Join(local,
"Programs"), fingerprint.WindowsProgramDirs, home.owner, "installer")
+ return append(result, scanWindowsInstallDirs(ctx, fingerprint, local,
fingerprint.WindowsUserDirs, home.owner, "native")...)
+}
+
+func scanMachinePrograms(ctx context.Context, fingerprint *Fingerprint)
[]Installation {
+ var result []Installation
+ seen := map[string]bool{}
+ for _, variable := range []string{"ProgramFiles", "ProgramFiles(x86)"} {
+ if ctx.Err() != nil {
+ return result
+ }
+ root := os.Getenv(variable)
+ if root == "" {
+ continue
+ }
+ key := strings.ToLower(filepath.Clean(root))
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ result = append(result, scanWindowsInstallDirs(ctx,
fingerprint, root, fingerprint.WindowsProgramDirs, "SYSTEM", "installer")...)
+ }
+ return result
+}
+
+// scanWindowsInstallDirs reports an installer layout: one directory per agent
+// holding the whole application, with the launcher at its root.
+func scanWindowsInstallDirs(ctx context.Context, fingerprint *Fingerprint,
root string, dirs []string, owner, method string) []Installation {
+ if fingerprint.ExecName == "" || root == "" {
+ return nil
+ }
+
+ var result []Installation
+ for _, dir := range dirs {
+ if ctx.Err() != nil {
+ return result
+ }
+ installDir := filepath.Join(root, filepath.FromSlash(dir))
+ executable := filepath.Join(installDir,
fingerprint.ExecName+".exe")
+ if info, err := os.Stat(executable); err != nil ||
!info.Mode().IsRegular() {
+ continue
+ }
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Path: executable,
+ InstallMethod: method, Owner: owner,
+ })
+ }
+ return result
+}
+
+func scanWingetPackages(ctx context.Context, fingerprint *Fingerprint, root,
owner string) []Installation {
+ if fingerprint.WingetPackage == "" || fingerprint.ExecName == "" {
+ return nil
+ }
+
+ // winget appends an install-source hash to the package identifier.
+ packages, _ := filepath.Glob(filepath.Join(root,
fingerprint.WingetPackage+"_*"))
+ var result []Installation
+ for _, packageDir := range packages {
+ if ctx.Err() != nil {
+ return result
+ }
+ executable := filepath.Join(packageDir,
fingerprint.ExecName+".exe")
+ if info, err := os.Stat(executable); err == nil &&
info.Mode().IsRegular() {
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Path: executable,
+ InstallMethod: "winget", Owner: owner,
+ })
+ }
+ }
+ return result
+}
+
+func scanWindowsNpm(ctx context.Context, fingerprint *Fingerprint, home
homeDir) []Installation {
+ roaming := roamingAppData(home)
+ local := localAppData(home)
+ pkg := fingerprint.npmPackagePath()
+ patterns := []string{
+ filepath.Join(roaming, "npm", "node_modules", pkg,
"package.json"),
+ filepath.Join(roaming, "nvm", "*", "node_modules", pkg,
"package.json"),
+ filepath.Join(roaming, "fnm", "node-versions", "*",
"installation", "node_modules", pkg, "package.json"),
+ // A Volta package image mirrors the npm prefix layout, which
on Windows
+ // has no "lib" level; match both so a relocated image still
resolves.
+ filepath.Join(local, "Volta", "tools", "image", "packages",
pkg, "node_modules", pkg, "package.json"),
+ filepath.Join(local, "Volta", "tools", "image", "packages",
pkg, "lib", "node_modules", pkg, "package.json"),
+ // pnpm groups its global packages under a store layout version.
+ filepath.Join(local, "pnpm", "global", "*", "node_modules",
pkg, "package.json"),
+ }
+ for _, dir := range fingerprint.ExtraWindowsNpmDirs {
+ patterns = append(patterns, filepath.Join(local,
filepath.FromSlash(dir), "node_modules", pkg, "package.json"))
+ }
+ return scanNpmPatterns(ctx, fingerprint, patterns, home.owner, nil)
+}
diff --git a/agent/scan_windows_desktop.go b/agent/scan_windows_desktop.go
new file mode 100644
index 0000000..655ec19
--- /dev/null
+++ b/agent/scan_windows_desktop.go
@@ -0,0 +1,153 @@
+// Copyright 2025 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.
+
+//go:build windows
+
+package agent
+
+import (
+ "context"
+ "os"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+var (
+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
+ getPackagesByPackageFamily =
kernel32.NewProc("GetPackagesByPackageFamily")
+ getPackagePathByFullName =
kernel32.NewProc("GetPackagePathByFullName")
+)
+
+// scanWindowsDesktop covers the two ways a desktop app ships on Windows: as an
+// MSIX package registered with the OS, or as a per-user standalone installer.
+func scanWindowsDesktop(ctx context.Context, fingerprint *Fingerprint, homes
[]homeDir) []Installation {
+ result := scanMSIX(ctx, fingerprint)
+ for _, home := range homes {
+ if ctx.Err() != nil {
+ return result
+ }
+ result = append(result, scanDesktopInstaller(fingerprint,
home)...)
+ }
+ return result
+}
+
+func scanDesktopInstaller(fingerprint *Fingerprint, home homeDir)
[]Installation {
+ if fingerprint.DesktopInstallerDir == "" || fingerprint.ExecName == "" {
+ return nil
+ }
+
+ executable := filepath.Join(localAppData(home),
fingerprint.DesktopInstallerDir, fingerprint.ExecName+".exe")
+ if info, err := os.Stat(executable); err != nil ||
!info.Mode().IsRegular() {
+ return nil
+ }
+ return []Installation{{
+ Name: fingerprint.DisplayName, Path: executable,
+ InstallMethod: "desktop-installer", Owner: home.owner,
+ }}
+}
+
+func scanMSIX(ctx context.Context, fingerprint *Fingerprint) []Installation {
+ if fingerprint.MSIXFamily == "" || fingerprint.ExecName == "" {
+ return nil
+ }
+ fullNames := packageFullNames(fingerprint.MSIXFamily)
+ if len(fullNames) == 0 {
+ return nil
+ }
+
+ owner := ""
+ if account, err := user.Current(); err == nil {
+ owner = account.Username
+ }
+ var result []Installation
+ for _, fullName := range fullNames {
+ if ctx.Err() != nil {
+ return result
+ }
+ root := packagePath(fullName)
+ if root == "" {
+ continue
+ }
+ executable := filepath.Join(root, "app",
fingerprint.ExecName+".exe")
+ if info, err := os.Stat(executable); err != nil ||
!info.Mode().IsRegular() {
+ continue
+ }
+ // A package full name is
"<Name>_<Version>_<Arch>_<...>_<PublisherId>".
+ version := ""
+ if fields := strings.Split(fullName, "_"); len(fields) > 1 {
+ version = fields[1]
+ }
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Version: version, Path:
executable,
+ InstallMethod: "msix", Owner: owner,
+ })
+ }
+ return result
+}
+
+func packageFullNames(family string) []string {
+ familyName, err := windows.UTF16PtrFromString(family)
+ if err != nil {
+ return nil
+ }
+ var count, bufferLength uint32
+ code, _, _ := getPackagesByPackageFamily.Call(
+ uintptr(unsafe.Pointer(familyName)),
uintptr(unsafe.Pointer(&count)), 0,
+ uintptr(unsafe.Pointer(&bufferLength)), 0,
+ )
+ if windows.Errno(code) != windows.ERROR_INSUFFICIENT_BUFFER || count ==
0 || bufferLength == 0 {
+ return nil
+ }
+
+ names := make([]*uint16, count)
+ buffer := make([]uint16, bufferLength)
+ code, _, _ = getPackagesByPackageFamily.Call(
+ uintptr(unsafe.Pointer(familyName)),
uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&names[0])),
+ uintptr(unsafe.Pointer(&bufferLength)),
uintptr(unsafe.Pointer(&buffer[0])),
+ )
+ if code != 0 {
+ return nil
+ }
+ result := make([]string, 0, count)
+ for _, name := range names[:count] {
+ result = append(result, windows.UTF16PtrToString(name))
+ }
+ runtime.KeepAlive(buffer)
+ return result
+}
+
+func packagePath(fullName string) string {
+ name, err := windows.UTF16PtrFromString(fullName)
+ if err != nil {
+ return ""
+ }
+ var length uint32
+ code, _, _ :=
getPackagePathByFullName.Call(uintptr(unsafe.Pointer(name)),
uintptr(unsafe.Pointer(&length)), 0)
+ if windows.Errno(code) != windows.ERROR_INSUFFICIENT_BUFFER || length
== 0 {
+ return ""
+ }
+ buffer := make([]uint16, length)
+ code, _, _ = getPackagePathByFullName.Call(
+ uintptr(unsafe.Pointer(name)),
uintptr(unsafe.Pointer(&length)), uintptr(unsafe.Pointer(&buffer[0])),
+ )
+ if code != 0 {
+ return ""
+ }
+ return windows.UTF16ToString(buffer)
+}
diff --git a/agent/version.go b/agent/version.go
new file mode 100644
index 0000000..ac07937
--- /dev/null
+++ b/agent/version.go
@@ -0,0 +1,127 @@
+// Copyright 2025 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 agent
+
+import (
+ "bytes"
+ "debug/buildinfo"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// maxVersionFileSize limits reads from version files.
+const maxVersionFileSize = 4 * 1024
+
+var pseudoVersion = regexp.MustCompile(`\d{14}-[0-9a-f]{12}`)
+
+var describeSuffix = regexp.MustCompile(`-\d+-g[0-9a-f]+$`)
+
+// executableBuildVersion reads release metadata without running the binary.
+func executableBuildVersion(path, module, versionVar string) string {
+ if path == "" || module == "" {
+ return ""
+ }
+ info, err := buildinfo.ReadFile(path)
+ if err != nil || info == nil || info.Main.Path != module {
+ return ""
+ }
+ if versionVar != "" {
+ for _, setting := range info.Settings {
+ if setting.Key != "-ldflags" {
+ continue
+ }
+ if version := ldflagsVersion(setting.Value,
versionVar); version != "" {
+ return version
+ }
+ }
+ }
+ return cleanReleaseVersion(info.Main.Version)
+}
+
+func ldflagsVersion(ldflags, versionVar string) string {
+ prefix := versionVar + "="
+ for _, field := range strings.Fields(ldflags) {
+ field = strings.TrimPrefix(field, "-X=")
+ if value, ok := strings.CutPrefix(field, prefix); ok {
+ return sanitizeVersion(value)
+ }
+ }
+ return ""
+}
+
+func cleanReleaseVersion(version string) string {
+ version = sanitizeVersion(version)
+ if version == "" || strings.Contains(version, "+") ||
pseudoVersion.MatchString(version) {
+ return ""
+ }
+ return version
+}
+
+func sanitizeVersion(version string) string {
+ switch version = strings.TrimSpace(version); version {
+ case "", "dev", "unknown", "(devel)":
+ return ""
+ }
+ if len(version) > 1 && version[0] == 'v' && version[1] >= '0' &&
version[1] <= '9' {
+ version = version[1:]
+ }
+ version = strings.TrimSuffix(version, "-dirty")
+ version = describeSuffix.ReplaceAllString(version, "")
+ return version
+}
+
+// executableVersionFile reads a small version file next to the binary.
+func executableVersionFile(binaryPath, fileName string) string {
+ if binaryPath == "" || fileName == "" {
+ return ""
+ }
+ resolved := binaryPath
+ if target, err := filepath.EvalSymlinks(binaryPath); err == nil {
+ resolved = target
+ }
+ path := filepath.Join(filepath.Dir(resolved), fileName)
+
+ info, err := os.Stat(path)
+ if err != nil || !info.Mode().IsRegular() || info.Size() >
maxVersionFileSize {
+ return ""
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ if newline := bytes.IndexByte(data, '\n'); newline >= 0 {
+ data = data[:newline]
+ }
+ return sanitizeVersion(string(data))
+}
+
+func fillMissingVersions(installations []Installation, mark int, fingerprint
*Fingerprint) {
+ if fingerprint.BuildInfoModule == "" && fingerprint.VersionFile == "" {
+ return
+ }
+ for i := mark; i < len(installations); i++ {
+ if installations[i].Version != "" {
+ continue
+ }
+ version := executableBuildVersion(
+ installations[i].Path, fingerprint.BuildInfoModule,
fingerprint.BuildInfoVersionVar)
+ if version == "" {
+ version = executableVersionFile(installations[i].Path,
fingerprint.VersionFile)
+ }
+ installations[i].Version = version
+ }
+}
diff --git a/agent/version_windows.go b/agent/version_windows.go
new file mode 100644
index 0000000..c62f740
--- /dev/null
+++ b/agent/version_windows.go
@@ -0,0 +1,73 @@
+// Copyright 2025 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.
+
+//go:build windows
+
+package agent
+
+import (
+ "fmt"
+ "strings"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+// executableVersion reads a Windows executable's version resource.
+func executableVersion(path string) string {
+ size, err := windows.GetFileVersionInfoSize(path, nil)
+ if err != nil || size == 0 {
+ return ""
+ }
+ block := make([]byte, size)
+ if windows.GetFileVersionInfo(path, 0, size, unsafe.Pointer(&block[0]))
!= nil {
+ return ""
+ }
+
+ for _, language := range versionLanguages(block) {
+ for _, name := range []string{"ProductVersion", "FileVersion"} {
+ if value := versionString(block, language, name); value
!= "" {
+ return value
+ }
+ }
+ }
+ return ""
+}
+
+func versionLanguages(block []byte) []string {
+ var translations *[2]uint16
+ var size uint32
+ err := windows.VerQueryValue(unsafe.Pointer(&block[0]),
`\VarFileInfo\Translation`, unsafe.Pointer(&translations), &size)
+ if err != nil || translations == nil || size < 4 {
+ return []string{"040904b0"}
+ }
+
+ count := int(size / 4)
+ pairs := unsafe.Slice(translations, count)
+ languages := make([]string, 0, count)
+ for _, pair := range pairs {
+ languages = append(languages, fmt.Sprintf("%04x%04x", pair[0],
pair[1]))
+ }
+ return languages
+}
+
+func versionString(block []byte, language, name string) string {
+ var value *uint16
+ var size uint32
+ subBlock := `\StringFileInfo\` + language + `\` + name
+ if windows.VerQueryValue(unsafe.Pointer(&block[0]), subBlock,
unsafe.Pointer(&value), &size) != nil || size == 0 {
+ return ""
+ }
+ return strings.TrimSpace(windows.UTF16ToString(unsafe.Slice(value,
size)))
+}
diff --git a/controllers/agent.go b/controllers/agent.go
new file mode 100644
index 0000000..9493c1c
--- /dev/null
+++ b/controllers/agent.go
@@ -0,0 +1,32 @@
+// Copyright 2025 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 controllers
+
+import "github.com/apache/casbin-gateway/agent"
+
+// GetAgents scans known installation locations and returns the AI agents
+// installed in the environment where Casbin Gateway is running.
+func (c *ApiController) GetAgents() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ installations, err := agent.Scan(c.GetString("refresh") == "true")
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(installations)
+}
diff --git a/go.mod b/go.mod
index 091fd8f..e2d3b02 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/apache/casbin-gateway
-go 1.16
+go 1.20
require (
github.com/Masterminds/squirrel v1.5.4
@@ -22,7 +22,97 @@ require (
github.com/xorm-io/core v0.7.4
github.com/xorm-io/xorm v1.1.6
golang.org/x/net v0.21.0
+ golang.org/x/sys v0.17.0
golang.org/x/text v0.14.0
golang.org/x/time v0.3.0
modernc.org/sqlite v1.18.1
)
+
+require (
+ dario.cat/mergo v1.0.0 // indirect
+ github.com/BurntSushi/toml v0.3.1 // indirect
+ github.com/Microsoft/go-winio v0.6.1 // indirect
+ github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 //
indirect
+ github.com/acomagu/bufpipe v1.0.4 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cenkalti/backoff/v4 v4.1.1 // indirect
+ github.com/cespare/xxhash/v2 v2.1.1 // indirect
+ github.com/cloudflare/circl v1.3.3 // indirect
+ github.com/corazawaf/libinjection-go v0.1.3 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
+ github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.5.0 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
+ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe //
indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da //
indirect
+ github.com/golang/protobuf v1.5.3 // indirect
+ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect
+ github.com/gomodule/redigo v2.0.0+incompatible // indirect
+ github.com/hashicorp/golang-lru v0.5.4 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 //
indirect
+ github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/json-iterator/go v1.1.11 // indirect
+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 //
indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
+ github.com/likexian/gokit v0.25.13 // indirect
+ github.com/magefile/mage v1.15.0 // indirect
+ github.com/mattn/go-isatty v0.0.12 // indirect
+ github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
+ github.com/miekg/dns v1.1.57 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd //
indirect
+ github.com/modern-go/reflect2 v1.0.1 // indirect
+ github.com/petar-dambovaliev/aho-corasick
v0.0.0-20230725210150-fb29fc3c913e // indirect
+ github.com/pingcap/errors v0.11.5-0.20210425183316-da1aaba5fb63 //
indirect
+ github.com/pingcap/log v0.0.0-20210625125904-98ed8e2eb1c7 // indirect
+ github.com/pingcap/tidb/parser v0.0.0-20221126021158-6b02a5d8ba7d //
indirect
+ github.com/pjbgf/sha1cd v0.3.0 // indirect
+ github.com/prometheus/client_golang v1.7.0 // indirect
+ github.com/prometheus/client_model v0.2.0 // indirect
+ github.com/prometheus/common v0.10.0 // indirect
+ github.com/prometheus/procfs v0.1.3 // indirect
+ github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 //
indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 //
indirect
+ github.com/russross/blackfriday/v2 v2.0.1 // indirect
+ github.com/sergi/go-diff v1.1.0 // indirect
+ github.com/shiena/ansicolor v0.0.0-20151119151921-a422bbe96644 //
indirect
+ github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 //
indirect
+ github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
+ github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 // indirect
+ github.com/skeema/knownhosts v1.2.0 // indirect
+ github.com/syndtr/goleveldb v1.0.0 // indirect
+ github.com/tidwall/gjson v1.17.0 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/urfave/cli v1.22.5 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ github.com/xorm-io/builder v0.3.13 // indirect
+ go.uber.org/atomic v1.7.0 // indirect
+ go.uber.org/multierr v1.6.0 // indirect
+ go.uber.org/zap v1.18.1 // indirect
+ golang.org/x/crypto v0.19.0 // indirect
+ golang.org/x/mod v0.14.0 // indirect
+ golang.org/x/oauth2 v0.13.0 // indirect
+ golang.org/x/sync v0.6.0 // indirect
+ golang.org/x/tools v0.15.0 // indirect
+ google.golang.org/appengine v1.6.7 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+ gopkg.in/ini.v1 v1.62.0 // indirect
+ gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
+ gopkg.in/square/go-jose.v2 v2.6.0 // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ lukechampine.com/uint128 v1.1.1 // indirect
+ modernc.org/cc/v3 v3.36.0 // indirect
+ modernc.org/ccgo/v3 v3.16.8 // indirect
+ modernc.org/libc v1.16.19 // indirect
+ modernc.org/mathutil v1.4.1 // indirect
+ modernc.org/memory v1.1.1 // indirect
+ modernc.org/opt v0.1.1 // indirect
+ modernc.org/strutil v1.1.1 // indirect
+ modernc.org/token v1.0.0 // indirect
+ rsc.io/binaryregexp v0.2.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 317b523..1716cad 100644
--- a/go.sum
+++ b/go.sum
@@ -664,6 +664,7 @@ github.com/census-instrumentation/opencensus-proto
v0.3.0/go.mod h1:f6KPmirojxKA
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod
h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cespare/xxhash v1.1.0
h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod
h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.1.1
h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod
h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0
h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod
h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -830,6 +831,7 @@ github.com/golang/protobuf v1.5.2/go.mod
h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
github.com/golang/protobuf v1.5.3
h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod
h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod
h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db
h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod
h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.3/go.mod
h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
@@ -915,6 +917,7 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod
h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
github.com/hashicorp/golang-lru v0.5.1/go.mod
h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4
h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod
h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod
h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hsluoyz/modsecurity-go v0.0.7
h1:W5ChaDrm4kM/UhHxoD2zyxQ+6s5kSj6cVftDFgdFzBM=
github.com/hsluoyz/modsecurity-go v0.0.7/go.mod
h1:hi81ySzwvlQFd5pip9c3uwXHDAW9ayxwLbt8ufxRkdY=
@@ -986,6 +989,7 @@ github.com/magefile/mage v1.15.0
h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod
h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
github.com/matryer/is v1.2.0/go.mod
h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
+github.com/mattn/go-isatty v0.0.12
h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod
h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16
h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod
h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -1016,6 +1020,7 @@ github.com/nxadm/tail v1.4.8
h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod
h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod
h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod
h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
github.com/onsi/ginkgo v1.12.0/go.mod
h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
github.com/onsi/ginkgo v1.12.1/go.mod
h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
@@ -1088,6 +1093,7 @@ github.com/prometheus/client_golang v1.7.0/go.mod
h1:PY5Wy2awLA44sXw4AOSfFBetzPP
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod
h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod
h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod
h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0
h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
github.com/prometheus/client_model v0.2.0/go.mod
h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0
h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod
h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
@@ -1158,6 +1164,7 @@ github.com/stretchr/testify v1.5.1/go.mod
h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod
h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod
h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod
h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0
h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod
h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1
h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod
h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -1283,6 +1290,7 @@ golang.org/x/lint
v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de
h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod
h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod
h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -1968,6 +1976,7 @@ gopkg.in/check.v1
v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod
h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod
h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
@@ -2002,10 +2011,12 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod
h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt
honnef.co/go/tools v0.0.1-2020.1.3/go.mod
h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod
h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.1.3/go.mod
h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
+lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU=
lukechampine.com/uint128 v1.1.1/go.mod
h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod
h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.31.5-0.20210308123301-7a3e9dab9009/go.mod
h1:0R6jl1aZlIl2avnYfbfHBS1QB6/f+16mihBObaBC878=
+modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo=
modernc.org/cc/v3 v3.36.0/go.mod
h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.2/go.mod
h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
@@ -2015,6 +2026,7 @@ modernc.org/ccgo/v3
v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9Y
modernc.org/ccgo/v3 v3.9.0/go.mod
h1:nQbgkn8mwzPdp4mm6BT6+p85ugQ7FrGgIcYaE7nSrpY=
modernc.org/ccgo/v3 v3.16.4/go.mod
h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.6/go.mod
h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
+modernc.org/ccgo/v3 v3.16.8 h1:G0QNlTqI5uVgczBWfGKs7B++EPwCfXPWGD2MdeKloDs=
modernc.org/ccgo/v3 v3.16.8/go.mod
h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
modernc.org/ccgo/v3 v3.16.9/go.mod
h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
@@ -2032,6 +2044,7 @@ modernc.org/libc v1.8.0/go.mod
h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
modernc.org/libc v1.16.17/go.mod
h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
+modernc.org/libc v1.16.19 h1:S8flPn5ZeXx6iw/8yNa986hwTQDrY8RXU7tObZuAozo=
modernc.org/libc v1.16.19/go.mod
h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
@@ -2039,14 +2052,17 @@ modernc.org/libc v1.17.1/go.mod
h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
modernc.org/mathutil v1.0.0/go.mod
h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k=
modernc.org/mathutil v1.1.1/go.mod
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.2.2/go.mod
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
modernc.org/mathutil v1.4.1/go.mod
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod
h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.0.4/go.mod
h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc=
+modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU=
modernc.org/memory v1.1.1/go.mod
h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.0/go.mod
h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
modernc.org/memory v1.2.1/go.mod
h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
@@ -2059,6 +2075,7 @@ modernc.org/sqlite v1.18.1
h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
modernc.org/sqlite v1.18.1/go.mod
h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/strutil v1.0.0/go.mod
h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs=
modernc.org/strutil v1.1.0/go.mod
h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs=
+modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
modernc.org/strutil v1.1.1/go.mod
h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod
h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
diff --git a/internal/hermes/inspect.go b/internal/hermes/inspect.go
new file mode 100644
index 0000000..88ce619
--- /dev/null
+++ b/internal/hermes/inspect.go
@@ -0,0 +1,165 @@
+// Copyright 2025 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 hermes validates Hermes Agent installations.
+package hermes
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+const (
+ AgentID = "hermes-agent"
+ DisplayName = "Hermes Agent"
+ ExecName = "hermes"
+ ProjectDir = "hermes-agent"
+ ProjectName = "hermes-agent"
+
+ maxProjectFileSize = 1024 * 1024
+ maxParentDepth = 6
+)
+
+type Project struct {
+ Root string
+ Version string
+}
+
+// InspectLauncher validates a launcher and locates its Hermes checkout.
+func InspectLauncher(launcher string, knownRoots ...string) (Project, error) {
+ info, err := os.Stat(launcher)
+ if err != nil || !info.Mode().IsRegular() {
+ return Project{}, fmt.Errorf("%s is not a regular Hermes
launcher", launcher)
+ }
+
+ seen := map[string]bool{}
+ for _, candidate := range knownRoots {
+ if candidate == "" {
+ continue
+ }
+ root := filepath.Clean(candidate)
+ seen[root] = true
+ if version, ok := projectVersion(root); ok {
+ return Project{Root: root, Version: version}, nil
+ }
+ }
+
+ candidates := []string{filepath.Dir(launcher)}
+ if resolved, resolveErr := filepath.EvalSymlinks(launcher); resolveErr
== nil {
+ candidates = append(candidates, filepath.Dir(resolved))
+ }
+
+ for _, candidate := range candidates {
+ for depth := 0; candidate != "" && depth <= maxParentDepth;
depth++ {
+ root := filepath.Clean(candidate)
+ if seen[root] {
+ break
+ }
+ seen[root] = true
+ if version, ok := projectVersion(root); ok {
+ return Project{Root: root, Version: version},
nil
+ }
+ parent := filepath.Dir(root)
+ if parent == root {
+ break
+ }
+ candidate = parent
+ }
+ }
+ return Project{}, fmt.Errorf("%s is not a validated Hermes Agent
launcher", launcher)
+}
+
+func projectVersion(root string) (string, bool) {
+ path := filepath.Join(root, "pyproject.toml")
+ info, err := os.Stat(path)
+ if err != nil || !info.Mode().IsRegular() || info.Size() >
maxProjectFileSize {
+ return "", false
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", false
+ }
+
+ inProject := false
+ name, version := "", ""
+ for _, raw := range strings.Split(string(data), "\n") {
+ line := strings.TrimSpace(raw)
+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]")
{
+ inProject = line == "[project]"
+ continue
+ }
+ if !inProject {
+ continue
+ }
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ switch strings.TrimSpace(key) {
+ case "name":
+ name = tomlString(value)
+ case "version":
+ version = tomlString(value)
+ }
+ }
+ return version, name == ProjectName && version != ""
+}
+
+func tomlString(value string) string {
+ value = strings.TrimSpace(value)
+ if len(value) < 2 || value[0] != '"' && value[0] != '\'' {
+ return ""
+ }
+ quote := value[0]
+ for index := 1; index < len(value); index++ {
+ if quote == '"' && value[index] == '\\' {
+ index++
+ continue
+ }
+ if value[index] != quote {
+ continue
+ }
+ rest := strings.TrimSpace(value[index+1:])
+ if rest == "" || strings.HasPrefix(rest, "#") {
+ return value[1:index]
+ }
+ return ""
+ }
+ return ""
+}
+
+func VersionAtLeast(actual, minimum string) bool {
+ parse := func(value string) [3]int {
+ var result [3]int
+ value = strings.TrimPrefix(strings.TrimSpace(value), "v")
+ for index, part := range strings.SplitN(value, ".",
len(result)) {
+ digits := strings.TrimLeftFunc(part, func(char rune)
bool {
+ return char >= '0' && char <= '9'
+ })
+ digits = part[:len(part)-len(digits)]
+ result[index], _ = strconv.Atoi(digits)
+ }
+ return result
+ }
+ got, want := parse(actual), parse(minimum)
+ for index := range got {
+ if got[index] != want[index] {
+ return got[index] > want[index]
+ }
+ }
+ return true
+}
diff --git a/localserver/listener_darwin.go b/localserver/listener_darwin.go
new file mode 100644
index 0000000..f75b5e5
--- /dev/null
+++ b/localserver/listener_darwin.go
@@ -0,0 +1,71 @@
+// Copyright 2025 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.
+
+//go:build darwin
+
+package localserver
+
+import (
+ "context"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ lsofPath = "/usr/sbin/lsof"
+ psPath = "/bin/ps"
+ listenerCommandBudget = 3 * time.Second
+)
+
+// Listeners resolves listening processes through lsof and ps.
+func Listeners(ctx context.Context, port int) []Process {
+ // -t prints bare PIDs, one per line, of the listening sockets alone.
+ out, ok := listenerCommand(ctx, lsofPath, "-nP", "-t",
"-iTCP:"+strconv.Itoa(port), "-sTCP:LISTEN")
+ if !ok {
+ return nil
+ }
+
+ var result []Process
+ seen := map[int]bool{}
+ for _, line := range strings.Fields(out) {
+ if ctx.Err() != nil {
+ return result
+ }
+ pid, err := strconv.Atoi(line)
+ if err != nil || pid <= 0 || seen[pid] {
+ continue
+ }
+ seen[pid] = true
+
+ // On macOS "comm" is the executable's full path, not just its
name.
+ path, ok := listenerCommand(ctx, psPath, "-o", "comm=", "-p",
line)
+ path = strings.TrimSpace(path)
+ if !ok || !filepath.IsAbs(path) {
+ continue
+ }
+ owner, _ := listenerCommand(ctx, psPath, "-o", "user=", "-p",
line)
+ result = append(result, Process{Pid: pid, Path: path, Owner:
strings.TrimSpace(owner)})
+ }
+ return result
+}
+
+func listenerCommand(ctx context.Context, path string, args ...string)
(string, bool) {
+ ctx, cancel := context.WithTimeout(ctx, listenerCommandBudget)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, path, args...).Output()
+ return string(out), err == nil
+}
diff --git a/localserver/listener_linux.go b/localserver/listener_linux.go
new file mode 100644
index 0000000..47364bf
--- /dev/null
+++ b/localserver/listener_linux.go
@@ -0,0 +1,141 @@
+// Copyright 2025 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.
+
+//go:build linux
+
+package localserver
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+)
+
+// tcpListen is the TCP_LISTEN state as /proc/net/tcp writes it.
+const tcpListen = "0A"
+
+// Listeners resolves listening socket inodes through procfs.
+func Listeners(ctx context.Context, port int) []Process {
+ inodes := listeningSocketInodes(ctx, port)
+ if len(inodes) == 0 {
+ return nil
+ }
+
+ var result []Process
+ for _, pid := range pidsHoldingInodes(ctx, inodes) {
+ if ctx.Err() != nil {
+ return result
+ }
+ path, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
+ if err != nil {
+ continue
+ }
+ result = append(result, Process{
+ Pid: pid, Path: path, Owner: processOwner(pid),
+ })
+ }
+ return result
+}
+
+// processOwner names the account a process runs as: /proc/<pid> is owned by
it.
+func processOwner(pid int) string {
+ info, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
+ if err != nil {
+ return ""
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return ""
+ }
+ id := strconv.FormatUint(uint64(stat.Uid), 10)
+ account, err := user.LookupId(id)
+ if err != nil {
+ return id
+ }
+ return account.Username
+}
+
+func listeningSocketInodes(ctx context.Context, port int) map[string]bool {
+ wantPort := fmt.Sprintf("%04X", port)
+ inodes := map[string]bool{}
+ for _, path := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
+ if ctx.Err() != nil {
+ return inodes
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ continue
+ }
+ scanner := bufio.NewScanner(file)
+ scanner.Scan() // header
+ for scanner.Scan() {
+ if ctx.Err() != nil {
+ break
+ }
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 10 || !strings.EqualFold(fields[3],
tcpListen) {
+ continue
+ }
+ local := strings.Split(fields[1], ":")
+ if len(local) != 2 || !strings.EqualFold(local[1],
wantPort) {
+ continue
+ }
+ inodes[fields[9]] = true
+ }
+ file.Close()
+ }
+ return inodes
+}
+
+func pidsHoldingInodes(ctx context.Context, inodes map[string]bool) []int {
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ return nil
+ }
+
+ var pids []int
+ for _, entry := range entries {
+ if ctx.Err() != nil {
+ return pids
+ }
+ pid, err := strconv.Atoi(entry.Name())
+ if err != nil {
+ continue
+ }
+ fdDir := filepath.Join("/proc", entry.Name(), "fd")
+ descriptors, err := os.ReadDir(fdDir)
+ if err != nil {
+ continue
+ }
+ for _, descriptor := range descriptors {
+ link, err := os.Readlink(filepath.Join(fdDir,
descriptor.Name()))
+ if err != nil {
+ continue
+ }
+ inode, ok := strings.CutPrefix(link, "socket:[")
+ if !ok || !inodes[strings.TrimSuffix(inode, "]")] {
+ continue
+ }
+ pids = append(pids, pid)
+ break
+ }
+ }
+ return pids
+}
diff --git a/localserver/listener_windows.go b/localserver/listener_windows.go
new file mode 100644
index 0000000..3dbc3af
--- /dev/null
+++ b/localserver/listener_windows.go
@@ -0,0 +1,178 @@
+// Copyright 2025 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.
+
+//go:build windows
+
+package localserver
+
+import (
+ "context"
+ "encoding/binary"
+ "syscall"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+var (
+ iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
+ procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable")
+)
+
+const (
+ // TCP_TABLE_OWNER_PID_LISTENER: listening sockets with their owning
PID.
+ tcpTableOwnerPidListener = 3
+ // Both tables are a DWORD entry count followed by fixed-size rows.
+ tcpTableHeaderSize = 4
+ tcpRowSize = 24
+ tcpRowPortIndex = 8
+ tcpRowPidIndex = 20
+ tcp6RowSize = 56
+ tcp6RowPortIndex = 20
+ tcp6RowPidIndex = 52
+ tcpTableAttempts = 4
+ initialTcpTable = 32 * 1024
+ maxProcessPathLen = windows.MAX_LONG_PATH
+)
+
+// Listeners resolves listening processes through Windows TCP tables.
+func Listeners(ctx context.Context, port int) []Process {
+ var result []Process
+ seen := map[uint32]bool{}
+ for _, pid := range listeningPids(ctx, port) {
+ if ctx.Err() != nil {
+ return result
+ }
+ if pid == 0 || seen[pid] {
+ continue
+ }
+ seen[pid] = true
+ if process, ok := describeProcess(ctx, pid); ok {
+ result = append(result, process)
+ }
+ }
+ return result
+}
+
+func listeningPids(ctx context.Context, port int) []uint32 {
+ var pids []uint32
+ for _, table := range []struct {
+ family uint32
+ rowSize int
+ portIndex int
+ pidIndex int
+ }{
+ {windows.AF_INET, tcpRowSize, tcpRowPortIndex, tcpRowPidIndex},
+ {windows.AF_INET6, tcp6RowSize, tcp6RowPortIndex,
tcp6RowPidIndex},
+ } {
+ if ctx.Err() != nil {
+ return pids
+ }
+ buffer, err := extendedTcpTable(ctx, table.family)
+ if err != nil {
+ continue
+ }
+ pids = append(pids, tablePids(buffer, port, table.rowSize,
table.portIndex, table.pidIndex)...)
+ }
+ return pids
+}
+
+// tablePids returns PIDs for rows matching port.
+func tablePids(table []byte, port, rowSize, portIndex, pidIndex int) []uint32 {
+ if len(table) < tcpTableHeaderSize {
+ return nil
+ }
+ entries := int(binary.LittleEndian.Uint32(table[:tcpTableHeaderSize]))
+
+ var pids []uint32
+ for i := 0; i < entries; i++ {
+ start := tcpTableHeaderSize + i*rowSize
+ if start+rowSize > len(table) {
+ break
+ }
+ row := table[start : start+rowSize]
+ if int(binary.BigEndian.Uint16(row[portIndex:portIndex+2])) !=
port {
+ continue
+ }
+ pids = append(pids,
binary.LittleEndian.Uint32(row[pidIndex:pidIndex+4]))
+ }
+ return pids
+}
+
+func extendedTcpTable(ctx context.Context, family uint32) ([]byte, error) {
+ size := uint32(initialTcpTable)
+ for attempt := 0; attempt < tcpTableAttempts; attempt++ {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ buffer := make([]byte, size)
+ ret, _, _ := procGetExtendedTcpTable.Call(
+ uintptr(unsafe.Pointer(&buffer[0])),
uintptr(unsafe.Pointer(&size)),
+ 0, uintptr(family), tcpTableOwnerPidListener, 0)
+ switch syscall.Errno(ret) {
+ case 0:
+ return buffer, nil
+ case windows.ERROR_INSUFFICIENT_BUFFER:
+ // size now holds the length the table needs; take
another turn.
+ continue
+ default:
+ return nil, syscall.Errno(ret)
+ }
+ }
+ return nil, windows.ERROR_INSUFFICIENT_BUFFER
+}
+
+func describeProcess(ctx context.Context, pid uint32) (Process, bool) {
+ if ctx.Err() != nil {
+ return Process{}, false
+ }
+ handle, err :=
windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
+ if err != nil {
+ return Process{}, false
+ }
+ defer windows.CloseHandle(handle)
+
+ buffer := make([]uint16, maxProcessPathLen)
+ size := uint32(len(buffer))
+ if err := windows.QueryFullProcessImageName(handle, 0, &buffer[0],
&size); err != nil {
+ return Process{}, false
+ }
+ return Process{
+ Pid: int(pid),
+ Path: windows.UTF16ToString(buffer[:size]),
+ Owner: processOwner(handle),
+ }, true
+}
+
+// processOwner returns the process account as DOMAIN\user when available.
+func processOwner(handle windows.Handle) string {
+ var token windows.Token
+ if err := windows.OpenProcessToken(handle, windows.TOKEN_QUERY,
&token); err != nil {
+ return ""
+ }
+ defer token.Close()
+
+ tokenUser, err := token.GetTokenUser()
+ if err != nil {
+ return ""
+ }
+ account, domain, _, err := tokenUser.User.Sid.LookupAccount("")
+ if err != nil {
+ return ""
+ }
+ if domain == "" {
+ return account
+ }
+ return domain + `\` + account
+}
diff --git a/localserver/localserver.go b/localserver/localserver.go
new file mode 100644
index 0000000..0d4139c
--- /dev/null
+++ b/localserver/localserver.go
@@ -0,0 +1,146 @@
+// Copyright 2025 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 localserver identifies programs on configured loopback ports.
+package localserver
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ dialTimeout = 300 * time.Millisecond
+ requestTimeout = 2 * time.Second
+ maxResponseBody = 64 * 1024
+)
+
+// probeHosts restricts probes to the local machine.
+var probeHosts = []string{"127.0.0.1", "::1"}
+
+// Server describes how to confirm and query a local HTTP service.
+type Server struct {
+ Ports []int `json:"ports,omitempty"`
+ ProbePath string `json:"probePath,omitempty"`
+ ProbeMarkers []string `json:"probeMarkers,omitempty"`
+
+ VersionPath string `json:"versionPath,omitempty"`
+ VersionFields []string `json:"versionFields,omitempty"`
+}
+
+// Process is a process holding a listening TCP port.
+type Process struct {
+ Pid int
+ Path string
+ Owner string
+}
+
+// Confirm verifies the configured service on a loopback port.
+func (s *Server) Confirm(ctx context.Context, port int) (base string, ok bool)
{
+ if s == nil || s.ProbePath == "" || len(s.ProbeMarkers) == 0 {
+ return "", false
+ }
+ for _, host := range probeHosts {
+ if ctx.Err() != nil {
+ return "", false
+ }
+ address := net.JoinHostPort(host, strconv.Itoa(port))
+ dialer := net.Dialer{Timeout: dialTimeout}
+ conn, err := dialer.DialContext(ctx, "tcp", address)
+ if err != nil {
+ continue
+ }
+ conn.Close()
+ base = "http://" + address
+ if s.probe(ctx, base) {
+ return base, true
+ }
+ }
+ return "", false
+}
+
+func (s *Server) probe(ctx context.Context, base string) bool {
+ answer, ok := get(ctx, base+s.ProbePath)
+ if !ok {
+ return false
+ }
+
+ haystack := strings.ToLower(answer.header + string(answer.body))
+ for _, marker := range s.ProbeMarkers {
+ if marker != "" && strings.Contains(haystack,
strings.ToLower(marker)) {
+ return true
+ }
+ }
+ return false
+}
+
+// Version reads the configured JSON version field from a confirmed service.
+func (s *Server) Version(ctx context.Context, base string) string {
+ if s == nil || s.VersionPath == "" || len(s.VersionFields) == 0 {
+ return ""
+ }
+ answer, ok := get(ctx, base+s.VersionPath)
+ if !ok {
+ return ""
+ }
+
+ var payload any
+ if json.Unmarshal(answer.body, &payload) != nil {
+ return ""
+ }
+ for _, field := range s.VersionFields {
+ object, ok := payload.(map[string]any)
+ if !ok {
+ return ""
+ }
+ payload = object[field]
+ }
+ version, _ := payload.(string)
+ return version
+}
+
+type answer struct {
+ header string
+ body []byte
+}
+
+func get(ctx context.Context, url string) (answer, bool) {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, url,
nil)
+ if err != nil {
+ return answer{}, false
+ }
+ client := &http.Client{
+ Timeout: requestTimeout,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse },
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return answer{}, false
+ }
+ defer response.Body.Close()
+
+ var header strings.Builder
+ _ = response.Header.Write(&header)
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBody))
+ if err != nil {
+ return answer{}, false
+ }
+ return answer{header: header.String(), body: body}, true
+}
diff --git a/routers/router.go b/routers/router.go
index d42c2c3..f110960 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -60,6 +60,7 @@ func initAPI() {
beego.Router("/api/update-cert-domain-expire",
&controllers.ApiController{}, "POST:UpdateCertDomainExpire")
beego.Router("/api/get-applications", &controllers.ApiController{},
"GET:GetApplications")
+ beego.Router("/api/get-agents", &controllers.ApiController{},
"GET:GetAgents")
beego.Router("/api/get-records", &controllers.ApiController{},
"GET:GetRecords")
beego.Router("/api/get-record", &controllers.ApiController{},
"GET:GetRecord")
diff --git a/web/src/AgentsPage.js b/web/src/AgentsPage.js
new file mode 100644
index 0000000..8f2db39
--- /dev/null
+++ b/web/src/AgentsPage.js
@@ -0,0 +1,130 @@
+// Copyright 2025 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.
+
+import React, {useCallback, useEffect, useState} from "react";
+import {Alert, Button, Result, Space, Table, Tag, Typography} from "antd";
+import {ReloadOutlined, RobotOutlined} from "@ant-design/icons";
+import i18next from "i18next";
+import * as AgentBackend from "./backend/AgentBackend";
+import * as Setting from "./Setting";
+import AgentIcon from "./components/AgentIcon";
+
+const {Text, Title} = Typography;
+
+const rowKey = record => `${record.owner}:${record.path}`;
+
+export default function AgentsPage({account}) {
+ const [agents, setAgents] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const isAdmin = Setting.isAdminUser(account);
+
+ const scan = useCallback((forceRefresh = false) => {
+ if (!isAdmin) {
+ return;
+ }
+
+ setLoading(true);
+ setError("");
+ AgentBackend.getAgents(forceRefresh).then(res => {
+ if (res.status === "ok") {
+ setAgents(res.data || []);
+ } else {
+ setError(res.msg || i18next.t("agent:Failed to scan agents"));
+ }
+ }).catch(err => {
+ setError(err.message || String(err));
+ }).then(() => {
+ setLoading(false);
+ });
+ }, [isAdmin]);
+
+ useEffect(() => {
+ scan();
+ }, [scan]);
+
+ if (!isAdmin) {
+ return (
+ <Result
+ status="403"
+ title="403 Unauthorized"
+ subTitle={i18next.t("general:Sorry, you do not have permission to
access this page or logged in status invalid.")}
+ extra={<a href="/"><Button type="primary">{i18next.t("general:Back
Home")}</Button></a>}
+ />
+ );
+ }
+
+ const columns = [
+ {
+ title: i18next.t("agent:Agent"),
+ dataIndex: "name",
+ key: "name",
+ render: (value, record) => (
+ <Space size={8}>
+ <AgentIcon
+ agent={record.agentId || value}
+ fallback={<RobotOutlined style={{fontSize: 18, color: "#8c8c8c"}}
/>}
+ />
+ {value}
+ </Space>
+ ),
+ },
+ {
+ title: i18next.t("agent:Version"),
+ dataIndex: "version",
+ key: "version",
+ render: value => value || i18next.t("agent:Unknown"),
+ },
+ {
+ title: i18next.t("agent:Install Method"),
+ dataIndex: "installMethod",
+ key: "installMethod",
+ render: value => <Tag>{value || "-"}</Tag>,
+ },
+ {
+ title: i18next.t("general:Owner"),
+ dataIndex: "owner",
+ key: "owner",
+ },
+ {
+ title: i18next.t("general:Path"),
+ dataIndex: "path",
+ key: "path",
+ render: value => <Text code>{value}</Text>,
+ },
+ ];
+
+ return (
+ <div style={{padding: "24px"}}>
+ <Space style={{display: "flex", justifyContent: "space-between",
marginBottom: 16}}>
+ <Title level={3} style={{margin:
0}}>{i18next.t("agent:Agents")}</Title>
+ <Button icon={<ReloadOutlined />} loading={loading} onClick={() =>
scan(true)}>
+ {i18next.t("agent:Scan")}
+ </Button>
+ </Space>
+ {error && <Alert type="error" message={error} style={{marginBottom: 16}}
/>}
+ <Table
+ rowKey={rowKey}
+ dataSource={agents}
+ columns={columns}
+ loading={loading}
+ pagination={false}
+ locale={{emptyText: i18next.t("agent:No supported agents found")}}
+ size="small"
+ bordered
+ scroll={{x: true}}
+ />
+ </div>
+ );
+}
diff --git a/web/src/App.js b/web/src/App.js
index 3055064..1fda6c1 100644
--- a/web/src/App.js
+++ b/web/src/App.js
@@ -15,7 +15,7 @@
import React, {Component} from "react";
import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom";
import {Avatar, BackTop, Drawer, Dropdown, Layout, Menu, Tooltip} from "antd";
-import {DeploymentUnitOutlined, DownOutlined, GithubOutlined, LogoutOutlined,
SettingOutlined, ShareAltOutlined} from "@ant-design/icons";
+import {DeploymentUnitOutlined, DownOutlined, GithubOutlined, LogoutOutlined,
RobotOutlined, SettingOutlined, ShareAltOutlined} from "@ant-design/icons";
import "./App.less";
import * as Setting from "./Setting";
import * as AccountBackend from "./backend/AccountBackend";
@@ -37,6 +37,7 @@ import RecordListPage from "./RecordListPage";
import RecordEditPage from "./RecordEditPage";
import i18next from "i18next";
import DashboardPage from "./DashboardPage";
+import AgentsPage from "./AgentsPage";
import LanguageSelect from "./LanguageSelect";
import {withTranslation} from "react-i18next";
// import SelectLanguageBox from "./SelectLanguageBox";
@@ -81,6 +82,8 @@ class App extends Component {
this.setState({selectedMenuKey: "/"});
} else if (uri.includes("/dashboard")) {
this.setState({selectedMenuKey: "/dashboard"});
+ } else if (uri.includes("/agents")) {
+ this.setState({selectedMenuKey: "/agents"});
} else if (uri.includes("/nodes")) {
this.setState({selectedMenuKey: "/nodes"});
} else if (uri.includes("/sites")) {
@@ -286,6 +289,15 @@ class App extends Component {
</Link>
</Menu.Item>
);
+ if (Setting.isAdminUser(this.state.account)) {
+ res.push(
+ <Menu.Item key="/agents" icon={<RobotOutlined />}>
+ <Link to="/agents">
+ {i18next.t("agent:Agents")}
+ </Link>
+ </Menu.Item>
+ );
+ }
res.push(
<Menu.Item key="/nodes">
<Link to="/nodes">
@@ -384,6 +396,7 @@ class App extends Component {
<Route exact path="/home" render={(props) => <HomePage
account={this.state.account} {...props} />} />
<Route exact path="/" render={(props) => <Redirect to="/sites" />} />
<Route exact path="/signin" render={(props) =>
this.renderHomeIfSignedIn(<SigninPage {...props} />)} />
+ <Route exact path="/agents" render={(props) =>
this.renderSigninIfNotSignedIn(<AgentsPage account={this.state.account}
{...props} />)} />
<Route exact path="/nodes" render={(props) =>
this.renderSigninIfNotSignedIn(<NodeListPage account={this.state.account}
{...props} />)} />
<Route exact path="/nodes/:owner/:nodeName" render={(props) =>
this.renderSigninIfNotSignedIn(<NodeEditPage account={this.state.account}
{...props} />)} />
<Route exact path="/sites" render={(props) =>
this.renderSigninIfNotSignedIn(<SiteListPage account={this.state.account}
{...props} />)} />
diff --git a/web/src/backend/AgentBackend.js b/web/src/backend/AgentBackend.js
new file mode 100644
index 0000000..7bc3a7b
--- /dev/null
+++ b/web/src/backend/AgentBackend.js
@@ -0,0 +1,23 @@
+// Copyright 2025 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.
+
+import * as Setting from "../Setting";
+
+export function getAgents(forceRefresh = false) {
+ const query = forceRefresh ? "?refresh=true" : "";
+ return fetch(`${Setting.ServerUrl}/api/get-agents${query}`, {
+ method: "GET",
+ credentials: "include",
+ }).then(res => res.json());
+}
diff --git a/web/src/components/AgentIcon.js b/web/src/components/AgentIcon.js
new file mode 100644
index 0000000..81aa48f
--- /dev/null
+++ b/web/src/components/AgentIcon.js
@@ -0,0 +1,56 @@
+// Copyright 2025 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.
+
+import React, {useState} from "react";
+
+const agentSites = {
+ "Claude Code": "claude.com",
+ "Claude Desktop": "claude.ai",
+ "OpenClaw": "openclaw.ai",
+ "Codex": "openai.com",
+ "ChatGPT Desktop (Codex)": "openai.com",
+ "Codex CLI": "openai.com",
+ "Hermes Agent": "hermes-agent.nousresearch.com",
+ "Cursor": "cursor.com",
+ "Cursor Agent": "cursor.com",
+ "Windsurf": "windsurf.com",
+ "OpenAgent": "openagentai.org",
+};
+
+const agentKey = agent => String(agent ||
"").toLowerCase().replace(/[^a-z0-9]/g, "");
+
+const agentSitesByKey = Object.keys(agentSites).reduce((sites, name) => {
+ sites[agentKey(name)] = agentSites[name];
+ return sites;
+}, {});
+
+export default function AgentIcon({agent, fallback = null, size = 20}) {
+ const [broken, setBroken] = useState(false);
+ const site = agentSitesByKey[agentKey(agent)];
+
+ if (!site || broken) {
+ return fallback;
+ }
+
+ return (
+ <img
+ src={`https://www.google.com/s2/favicons?domain=${site}&sz=64`}
+ alt={agent}
+ width={size}
+ height={size}
+ onError={() => setBroken(true)}
+ style={{display: "block", borderRadius: 6}}
+ />
+ );
+}
diff --git a/web/src/locales/de/data.json b/web/src/locales/de/data.json
index f1dd820..15849e6 100644
--- a/web/src/locales/de/data.json
+++ b/web/src/locales/de/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 739b57a..13143b5 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/es/data.json b/web/src/locales/es/data.json
index fb61e9f..75d43b7 100644
--- a/web/src/locales/es/data.json
+++ b/web/src/locales/es/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/fr/data.json b/web/src/locales/fr/data.json
index 3a7d12a..542e10e 100644
--- a/web/src/locales/fr/data.json
+++ b/web/src/locales/fr/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/id/data.json b/web/src/locales/id/data.json
index 84bfbca..b2e4c8c 100644
--- a/web/src/locales/id/data.json
+++ b/web/src/locales/id/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/ja/data.json b/web/src/locales/ja/data.json
index d4c22df..2db0e02 100644
--- a/web/src/locales/ja/data.json
+++ b/web/src/locales/ja/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/ko/data.json b/web/src/locales/ko/data.json
index eb13620..64d1fe6 100644
--- a/web/src/locales/ko/data.json
+++ b/web/src/locales/ko/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/ru/data.json b/web/src/locales/ru/data.json
index d9e093d..bd26863 100644
--- a/web/src/locales/ru/data.json
+++ b/web/src/locales/ru/data.json
@@ -5,6 +5,16 @@
"Sign Out": "Sign Out",
"Sign Up": "Sign Up"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agents",
+ "Failed to scan agents": "Failed to scan agents",
+ "Install Method": "Install Method",
+ "No supported agents found": "No supported agents found",
+ "Scan": "Scan",
+ "Unknown": "Unknown",
+ "Version": "Version"
+ },
"cert": {
"Access key": "Access key",
"Access secret": "Access secret",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 35c83f0..2dc6603 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -5,6 +5,16 @@
"Sign Out": "退出",
"Sign Up": "注册"
},
+ "agent": {
+ "Agent": "Agent",
+ "Agents": "Agent 列表",
+ "Failed to scan agents": "探测 Agent 失败",
+ "Install Method": "安装方式",
+ "No supported agents found": "未发现支持的 Agent",
+ "Scan": "探测",
+ "Unknown": "未知",
+ "Version": "版本"
+ },
"cert": {
"Access key": "访问密钥",
"Access secret": "访问密钥",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]