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 bcce5fd fix(agent): route a browser sign-in back to the instance that
started it
bcce5fd is described below
commit bcce5fdbab832504d240bed85dd78abf53fca5b9
Author: Yang Luo <[email protected]>
AuthorDate: Fri Oct 2 19:59:17 2026 +0800
fix(agent): route a browser sign-in back to the instance that started it
---
agent/account_darwin.go | 6 ++
agent/account_unix.go | 6 ++
agent/account_windows.go | 6 ++
agent/fingerprint.go | 5 ++
agent/fingerprints/claude-desktop.json | 2 +
agent/instance.go | 32 ++++++++
controllers/agent_instance_signin.go | 138 +++++++++++++++++++++++++++++++++
main.go | 3 +
8 files changed, 198 insertions(+)
diff --git a/agent/account_darwin.go b/agent/account_darwin.go
index a4ed5a3..688f03b 100644
--- a/agent/account_darwin.go
+++ b/agent/account_darwin.go
@@ -22,3 +22,9 @@ import "path/filepath"
func claudeDesktopDataDir(home string) string {
return filepath.Join(home, "Library", "Application Support", "Claude")
}
+
+// claudeDesktopLogDir is empty here: Gateway routes no links on this host, so
+// nothing watches the logs.
+func claudeDesktopLogDir(string) string {
+ return ""
+}
diff --git a/agent/account_unix.go b/agent/account_unix.go
index 41d13d5..22baca0 100644
--- a/agent/account_unix.go
+++ b/agent/account_unix.go
@@ -22,3 +22,9 @@ import "path/filepath"
func claudeDesktopDataDir(home string) string {
return filepath.Join(home, ".config", "Claude")
}
+
+// claudeDesktopLogDir is empty here: Gateway routes no links on this host, so
+// nothing watches the logs.
+func claudeDesktopLogDir(string) string {
+ return ""
+}
diff --git a/agent/account_windows.go b/agent/account_windows.go
index ad52e2c..708b74d 100644
--- a/agent/account_windows.go
+++ b/agent/account_windows.go
@@ -23,3 +23,9 @@ import "path/filepath"
func claudeDesktopDataDir(home string) string {
return filepath.Join(home, "AppData", "Roaming", "Claude")
}
+
+// claudeDesktopLogDir is where the installation's own copy writes its logs,
+// under the local profile rather than beside its state.
+func claudeDesktopLogDir(home string) string {
+ return filepath.Join(home, "AppData", "Local", "Claude", "Logs")
+}
diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 12cfe12..e755882 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -51,6 +51,11 @@ type Fingerprint struct {
// LinkScheme is the URL scheme the agent registers for its own links,
which
// is how a browser hands a finished sign-in back to it.
LinkScheme string `json:"linkScheme,omitempty"`
+ // SignInLog is the log, under an instance's state directory, where the
+ // agent notes that it sent a sign-in to the browser, and SignInMarker
is
+ // the text of that line.
+ SignInLog string `json:"signInLog,omitempty"`
+ SignInMarker string `json:"signInMarker,omitempty"`
StateDir string `json:"stateDir,omitempty"`
NpmPackage string `json:"npmPackage,omitempty"`
diff --git a/agent/fingerprints/claude-desktop.json
b/agent/fingerprints/claude-desktop.json
index 7223a7b..289f02e 100644
--- a/agent/fingerprints/claude-desktop.json
+++ b/agent/fingerprints/claude-desktop.json
@@ -7,6 +7,8 @@
"sandboxed": true,
"instanceArg": "--user-data-dir",
"linkScheme": "claude",
+ "signInLog": "logs/main.log",
+ "signInMarker": "[Auth] Using system browser for:",
"wingetPackage": "Anthropic.Claude",
"msixFamily": "Claude_pzs8sxrjxfjjc",
"desktopInstallerDir": "AnthropicClaude"
diff --git a/agent/instance.go b/agent/instance.go
index 9ca2788..d03bb17 100644
--- a/agent/instance.go
+++ b/agent/instance.go
@@ -57,6 +57,38 @@ func SupportsInstances(agentId string) bool {
return isolationOf(agentId).supported()
}
+// SignInLogOf is the log an instance of this agent notes a browser sign-in in,
+// relative to its state directory, and the text it notes it with. Both are
+// empty for an agent Gateway cannot watch this way.
+func SignInLogOf(agentId string) (string, string) {
+ for i := range fingerprints {
+ if fingerprints[i].ID == agentId {
+ return fingerprints[i].SignInLog,
fingerprints[i].SignInMarker
+ }
+ }
+ return "", ""
+}
+
+// DefaultSignInLogsOf is where the installation's own copy may note a browser
+// sign-in: the log under its state directory, and for Claude Desktop also the
+// one under the local profile, which is where current versions write it.
+func DefaultSignInLogsOf(agentId string, owner string) []string {
+ logName, _ := SignInLogOf(agentId)
+ dir := DefaultInstanceDir(agentId, owner)
+ if logName == "" || dir == "" {
+ return nil
+ }
+
+ logs := []string{filepath.Join(dir, filepath.FromSlash(logName))}
+ if accountKind(agentId) == "claude-desktop" {
+ home, err := agenthome.Resolve(owner)
+ if logDir := claudeDesktopLogDir(home); err == nil && logDir !=
"" {
+ logs = append(logs, filepath.Join(logDir,
filepath.Base(logName)))
+ }
+ }
+ return logs
+}
+
// LinkSchemeOf is the URL scheme an agent registers for its own links, which
is
// what a browser hands a finished sign-in back through. Empty for an agent
that
// has none, or none Gateway knows about.
diff --git a/controllers/agent_instance_signin.go
b/controllers/agent_instance_signin.go
new file mode 100644
index 0000000..7658cf9
--- /dev/null
+++ b/controllers/agent_instance_signin.go
@@ -0,0 +1,138 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/agentlink"
+ "github.com/apache/casbin-gateway/object"
+ "github.com/beego/beego"
+)
+
+// signInPollInterval is well inside the seconds a browser sign-in takes to
+// come back as a link.
+const signInPollInterval = time.Second
+
+// maxSignInLogRead caps what one poll reads of a log that grew a lot at once.
+const maxSignInLogRead = 1 << 20
+
+// WatchAgentSignIns hands an agent's next link to whichever copy just sent a
+// sign-in to the browser. A missing account at start is not enough to go by:
+// a copy whose sign-in went stale still records the old account, and the link
+// it signs in again with would open the first copy instead.
+func WatchAgentSignIns() {
+ if !agentlink.Supported() {
+ return
+ }
+
+ go func() {
+ offsets := map[string]int64{}
+ for {
+ time.Sleep(signInPollInterval)
+ pollAgentSignIns(offsets)
+ }
+ }()
+}
+
+func pollAgentSignIns(offsets map[string]int64) {
+ // The first copy signing in gives the scheme back: the agent's own
command
+ // already opens that copy, and a capture left from an abandoned
sign-in in
+ // another one would take its link.
+ for _, installation := range agent.Cached() {
+ scheme := agent.LinkSchemeOf(installation.AgentId)
+ _, marker := agent.SignInLogOf(installation.AgentId)
+ if scheme == "" || marker == "" {
+ continue
+ }
+ for _, path := range
agent.DefaultSignInLogsOf(installation.AgentId, installation.Owner) {
+ if !logGained(path, []byte(marker), offsets) {
+ continue
+ }
+ if err := agentlink.Release(scheme); err != nil {
+ beego.Error("the sign-in link of the first",
installation.AgentId, "cannot be routed to it:", err)
+ }
+ }
+ }
+
+ instances, err := object.GetAgentInstances("")
+ if err != nil {
+ return
+ }
+
+ for _, instance := range instances {
+ logName, marker := agent.SignInLogOf(instance.AgentId)
+ if logName == "" || marker == "" ||
agent.LinkSchemeOf(instance.AgentId) == "" {
+ continue
+ }
+ path := filepath.Join(instance.DataDir,
filepath.FromSlash(logName))
+ if !logGained(path, []byte(marker), offsets) {
+ continue
+ }
+
+ if err := captureSignIn(instance); err != nil {
+ beego.Error("the sign-in link of", instance.Name,
"cannot be routed to it:", err)
+ }
+ }
+}
+
+func captureSignIn(instance *object.AgentInstance) error {
+ installation, err := findInstallation(instance.AgentId, instance.Path,
instance.HostUser)
+ if err != nil {
+ return err
+ }
+ target, err := instanceTarget(installation, instance)
+ if err != nil {
+ return err
+ }
+ return captureLink(instance, target)
+}
+
+// logGained reports whether marker was written to a log since the last poll.
+// What a log held when Gateway first looked is old news; one that did not
exist
+// yet is read from its start once it does.
+func logGained(path string, marker []byte, offsets map[string]int64) bool {
+ info, err := os.Stat(path)
+ if err != nil {
+ offsets[path] = 0
+ return false
+ }
+ size := info.Size()
+ offset, seen := offsets[path]
+ offsets[path] = size
+ if !seen || size == offset {
+ return false
+ }
+ // A rotated log starts over.
+ if size < offset {
+ offset = 0
+ }
+ if size-offset > maxSignInLogRead {
+ offset = size - maxSignInLogRead
+ }
+
+ file, err := os.Open(path)
+ if err != nil {
+ return false
+ }
+ defer file.Close()
+ data := make([]byte, size-offset)
+ n, _ := file.ReadAt(data, offset)
+ return bytes.Contains(data[:n], marker)
+}
diff --git a/main.go b/main.go
index b0108fa..672fe4c 100644
--- a/main.go
+++ b/main.go
@@ -146,6 +146,9 @@ func main() {
// here, so none of them needs a public address.
object.InitImChannels()
defer imbridge.Stop()
+ // A copy of an agent that signs in through the browser gets the link
back,
+ // rather than the copy the agent's own scheme opens.
+ controllers.WatchAgentSignIns()
// Monitoring is on by default, so the agents already on this host are
// patched without anyone opening the UI. The scan walks the disk,
hence the
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]