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]

Reply via email to