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 cb172cd  feat(agent): route a finished sign-in to the instance waiting 
for it
cb172cd is described below

commit cb172cd8b2736365ee365550571eb4d4f16cbf93
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 3 22:18:22 2026 +0800

    feat(agent): route a finished sign-in to the instance waiting for it
---
 agent/fingerprint.go                   |   4 +
 agent/fingerprints/claude-desktop.json |   1 +
 agent/instance.go                      |  12 +
 agentlink/link.go                      | 486 +++++++++++++++++++++++++++++++++
 agentlink/link_other.go                |  35 +++
 agentlink/link_windows.go              | 160 +++++++++++
 controllers/agent_instance.go          |  95 +++++++
 desktop/main.go                        |  15 +
 main.go                                |  10 +
 routers/router.go                      |   1 +
 web/src/backend/AgentBackend.ts        |  12 +
 web/src/components/AgentInstances.tsx  |  46 +++-
 web/src/lib/agents.ts                  |  39 ++-
 web/src/locales/en/data.json           |   3 +
 web/src/locales/zh/data.json           |   3 +
 web/src/types.ts                       |   4 +
 16 files changed, 924 insertions(+), 2 deletions(-)

diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 74854b8..a10bd02 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -43,6 +43,10 @@ type Fingerprint struct {
        InstanceArg string `json:"instanceArg,omitempty"`
        InstanceEnv string `json:"instanceEnv,omitempty"`
 
+       // 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"`
+
        StateDir            string              `json:"stateDir,omitempty"`
        NpmPackage          string              `json:"npmPackage,omitempty"`
        ExtraUnixNpmDirs    []string            
`json:"extraUnixNpmDirs,omitempty"`
diff --git a/agent/fingerprints/claude-desktop.json 
b/agent/fingerprints/claude-desktop.json
index 2d69483..294f671 100644
--- a/agent/fingerprints/claude-desktop.json
+++ b/agent/fingerprints/claude-desktop.json
@@ -6,6 +6,7 @@
   "desktop": true,
   "sandboxed": true,
   "instanceArg": "--user-data-dir",
+  "linkScheme": "claude",
   "msixFamily": "Claude_pzs8sxrjxfjjc",
   "desktopInstallerDir": "AnthropicClaude"
 }
diff --git a/agent/instance.go b/agent/instance.go
index 1d3711a..8cbba0a 100644
--- a/agent/instance.go
+++ b/agent/instance.go
@@ -57,6 +57,18 @@ func SupportsInstances(agentId string) bool {
        return isolationOf(agentId).supported()
 }
 
+// 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.
+func LinkSchemeOf(agentId string) string {
+       for i := range fingerprints {
+               if fingerprints[i].ID == agentId {
+                       return fingerprints[i].LinkScheme
+               }
+       }
+       return ""
+}
+
 // InstancesRoot is where every instance of every agent keeps its state: the
 // home of the account Gateway runs as, which is also the account an instance 
is
 // started under. An installation owned by another account - a machine-wide one
diff --git a/agentlink/link.go b/agentlink/link.go
new file mode 100644
index 0000000..aa5e8ad
--- /dev/null
+++ b/agentlink/link.go
@@ -0,0 +1,486 @@
+// 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 agentlink sends one link in an agent's own URL scheme to one copy of
+// that agent.
+//
+// A sign-in finished in a browser comes back as a link the agent registered
+// itself for - claude://... - and the command behind that registration names 
no
+// state directory. The copy it starts therefore joins the one running on the
+// default state directory, which is where the sign-in lands, so a second copy
+// could never be signed in at all. While a copy is waiting to be signed in,
+// Gateway holds the scheme, hands the one link that follows to that copy, and
+// puts the agent's own command back.
+package agentlink
+
+import (
+       "bytes"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+// Subcommand is how this executable is invoked as the handler of one link, by
+// both the server and the desktop launcher.
+const Subcommand = "open-agent-link"
+
+// holdInterval is how often a live capture checks that the scheme is still
+// Gateway's: an agent registers itself again as it starts, which is exactly
+// when a capture is taken.
+const holdInterval = time.Second
+
+// maxUrlLength caps what is accepted as a link, which arrives from a browser.
+const maxUrlLength = 4096
+
+// linksDir holds one file per captured scheme, beside the instance state.
+var linksDir = filepath.Join(".casbin-gateway", "agent-links")
+
+// ErrUnsupported is returned where Gateway cannot take a URL scheme over.
+var ErrUnsupported = errors.New("gateway can only route an agent's own links 
on Windows")
+
+// Target is the copy of an agent a captured link is handed to: the command 
that
+// instance is started with.
+type Target struct {
+       Executable string   `json:"executable"`
+       Args       []string `json:"args"`
+}
+
+// Claim is one scheme Gateway holds while it waits for a sign-in link.
+type Claim struct {
+       Scheme string `json:"scheme"`
+       // Instance is the stored name of the copy the link is meant for, which 
is
+       // what the pages match a capture to.
+       Instance string    `json:"instance"`
+       Target   Target    `json:"target"`
+       Expires  time.Time `json:"expires"`
+       // Restore is the command the agent had registered, put back as soon as 
the
+       // link has been routed or the wait has run out.
+       Restore string `json:"restore"`
+}
+
+var (
+       mutex sync.Mutex
+       // held counts the captures taken per scheme, so the goroutine holding 
one
+       // can tell that a later capture has replaced it.
+       held = map[string]int{}
+)
+
+// Supported reports whether Gateway can route an agent's own links on this
+// host.
+func Supported() bool {
+       return schemesSupported()
+}
+
+// Capture points a scheme at Gateway and records the copy the next link 
belongs
+// to. The command the agent had registered is kept, and put back once the link
+// has arrived.
+func Capture(scheme string, instance string, target Target, ttl time.Duration) 
(Claim, error) {
+       scheme = strings.ToLower(strings.TrimSpace(scheme))
+       if err := checkScheme(scheme); err != nil {
+               return Claim{}, err
+       }
+       if target.Executable == "" {
+               return Claim{}, errors.New("no launcher was found for this 
instance")
+       }
+       if !Supported() {
+               return Claim{}, ErrUnsupported
+       }
+
+       mutex.Lock()
+       defer mutex.Unlock()
+
+       registered, err := readHandler(scheme)
+       if err != nil {
+               return Claim{}, err
+       }
+       claim := Claim{
+               Scheme:   scheme,
+               Instance: instance,
+               Target:   target,
+               Expires:  time.Now().Add(ttl),
+               Restore:  registered,
+       }
+       switch {
+       case isOurs(registered):
+               // Gateway already holds this scheme, so what it replaced is 
recorded
+               // only in the capture it was taken for.
+               previous, ok := readClaim(scheme)
+               if !ok || previous.Restore == "" {
+                       return Claim{}, fmt.Errorf("gateway holds %s:// with 
nothing recorded to put back", scheme)
+               }
+               claim.Restore = previous.Restore
+       case registered == "":
+               return Claim{}, fmt.Errorf("nothing on this account opens %s:// 
links", scheme)
+       }
+
+       if err := writeClaim(claim); err != nil {
+               return Claim{}, err
+       }
+       command, err := handlerCommand()
+       if err != nil {
+               _ = removeClaim(scheme)
+               return Claim{}, err
+       }
+       if err := writeHandler(scheme, command); err != nil {
+               _ = removeClaim(scheme)
+               return Claim{}, err
+       }
+       hold(scheme)
+       return claim, nil
+}
+
+// Release ends a capture early and gives the scheme back to the agent.
+func Release(scheme string) error {
+       scheme = strings.ToLower(strings.TrimSpace(scheme))
+       if err := checkScheme(scheme); err != nil {
+               return err
+       }
+
+       mutex.Lock()
+       defer mutex.Unlock()
+
+       claim, ok := readClaim(scheme)
+       if !ok {
+               delete(held, scheme)
+               return nil
+       }
+       // A capture that is over must not be taken again by the goroutine 
holding
+       // it, which the generation bump is what stops.
+       held[scheme]++
+       release(scheme, claim)
+       return nil
+}
+
+// Pending is the capture waiting on one scheme, if it has not run out.
+func Pending(scheme string) (Claim, bool) {
+       claim, ok := readClaim(strings.ToLower(strings.TrimSpace(scheme)))
+       if !ok || time.Now().After(claim.Expires) {
+               return Claim{}, false
+       }
+       return claim, true
+}
+
+// Restore picks a still-pending capture back up after Gateway restarts, and
+// gives back every other scheme a Gateway that did not get to finish one left
+// registered to itself. It runs at startup, where this process is the only
+// Gateway there is: the registry keeps whatever the last process wrote, but 
the
+// goroutine that kept reasserting it died with that process.
+func Restore() {
+       dir, err := linksPath()
+       if err != nil {
+               return
+       }
+       entries, err := os.ReadDir(dir)
+       if err != nil {
+               return
+       }
+
+       mutex.Lock()
+       defer mutex.Unlock()
+       for _, entry := range entries {
+               scheme := strings.TrimSuffix(entry.Name(), ".json")
+               if entry.IsDir() || scheme == entry.Name() {
+                       continue
+               }
+               claim, ok := readClaim(scheme)
+               if !ok {
+                       continue
+               }
+               if time.Now().After(claim.Expires) {
+                       release(scheme, claim)
+                       continue
+               }
+               if err := resume(scheme, claim); err != nil {
+                       release(scheme, claim)
+               }
+       }
+}
+
+// resume puts the capture recorded in claim back the way Capture left it -
+// registered to Gateway and watched by a hold goroutine - so a restart while a
+// sign-in is still pending does not lose it. It is called with the mutex held.
+func resume(scheme string, claim Claim) error {
+       if !Supported() {
+               return ErrUnsupported
+       }
+       registered, err := readHandler(scheme)
+       if err != nil {
+               return err
+       }
+       if !isOurs(registered) {
+               if registered != "" {
+                       claim.Restore = registered
+                       if err := writeClaim(claim); err != nil {
+                               return err
+                       }
+               }
+               command, err := handlerCommand()
+               if err != nil {
+                       return err
+               }
+               if err := writeHandler(scheme, command); err != nil {
+                       return err
+               }
+       }
+       hold(scheme)
+       return nil
+}
+
+// HandleIfInvoked routes one link and exits, when this process was started as
+// the handler of a captured scheme rather than as Gateway itself.
+func HandleIfInvoked() {
+       if len(os.Args) < 2 || os.Args[1] != Subcommand {
+               return
+       }
+       detachConsole()
+
+       link := ""
+       if len(os.Args) > 2 {
+               link = os.Args[2]
+       }
+       if err := Open(link); err != nil {
+               // Nobody is watching a process a browser started, so a link 
that could
+               // not be routed leaves its reason beside the captures instead.
+               reportOpenError(link, err)
+       }
+       os.Exit(0)
+}
+
+// Open hands one link to the copy waiting for it, or, where none is, to the
+// command the agent had registered - which is what a link opened before 
Gateway
+// captured anything.
+func Open(link string) error {
+       scheme, err := schemeOf(link)
+       if err != nil {
+               return err
+       }
+       claim, ok := readClaim(scheme)
+       if !ok {
+               return fmt.Errorf("no %s:// link was expected here", scheme)
+       }
+
+       // One link per capture: the command the agent registered goes back 
before
+       // this one is opened, whether or not it is the link that was waited 
for.
+       mutex.Lock()
+       release(scheme, claim)
+       mutex.Unlock()
+
+       if time.Now().After(claim.Expires) {
+               return openWith(claim.Restore, link)
+       }
+       return start(claim.Target.Executable, append(append([]string{}, 
claim.Target.Args...), link))
+}
+
+// hold keeps a captured scheme registered to Gateway for as long as the 
capture
+// lasts, since the agent takes the registration back whenever it starts. It is
+// called with the mutex held.
+func hold(scheme string) {
+       held[scheme]++
+       generation := held[scheme]
+
+       go func() {
+               for {
+                       time.Sleep(holdInterval)
+
+                       mutex.Lock()
+                       if held[scheme] != generation {
+                               mutex.Unlock()
+                               return
+                       }
+                       done := reassert(scheme)
+                       mutex.Unlock()
+                       if done {
+                               return
+                       }
+               }
+       }()
+}
+
+// reassert puts Gateway's command back where the agent has overwritten it, and
+// reports whether the capture is over. It is called with the mutex held.
+func reassert(scheme string) bool {
+       claim, ok := readClaim(scheme)
+       if !ok {
+               // The handler routed the link and gave the scheme back already.
+               delete(held, scheme)
+               return true
+       }
+       if time.Now().After(claim.Expires) {
+               release(scheme, claim)
+               return true
+       }
+
+       registered, err := readHandler(scheme)
+       if err != nil || isOurs(registered) {
+               return false
+       }
+       // The agent registered itself again, and an updated one does that from 
a
+       // new path, so that is what a restore has to put back from here on.
+       if registered != "" && registered != claim.Restore {
+               claim.Restore = registered
+               _ = writeClaim(claim)
+       }
+       if command, err := handlerCommand(); err == nil {
+               _ = writeHandler(scheme, command)
+       }
+       return false
+}
+
+// release gives one scheme back to the agent and forgets the capture. It is
+// called with the mutex held.
+func release(scheme string, claim Claim) {
+       delete(held, scheme)
+       if registered, err := readHandler(scheme); err == nil && 
isOurs(registered) && claim.Restore != "" {
+               // The capture is the only record of the command being put 
back, so it
+               // is kept until that has actually happened.
+               if err := writeHandler(scheme, claim.Restore); err != nil {
+                       return
+               }
+       }
+       _ = removeClaim(scheme)
+}
+
+// isOurs reports whether a registered command runs Gateway's own handler.
+func isOurs(command string) bool {
+       return strings.Contains(strings.ToLower(command), Subcommand)
+}
+
+// schemeOf is the scheme of a link, checked as what it is: an argument a
+// browser handed to a command line.
+func schemeOf(link string) (string, error) {
+       if link == "" {
+               return "", errors.New("no link was passed")
+       }
+       if len(link) > maxUrlLength {
+               return "", errors.New("this link is longer than a link can be")
+       }
+       for _, r := range link {
+               if r <= ' ' || r == '"' || r == 0x7f {
+                       return "", errors.New("this link holds characters a 
link cannot hold")
+               }
+       }
+
+       index := strings.Index(link, "://")
+       if index <= 0 {
+               return "", fmt.Errorf("%q is not a link in an agent's own 
scheme", link)
+       }
+       scheme := strings.ToLower(link[:index])
+       if err := checkScheme(scheme); err != nil {
+               return "", err
+       }
+       return scheme, nil
+}
+
+// checkScheme keeps a scheme to what is safe as one file name and as one
+// registry key.
+func checkScheme(scheme string) error {
+       if scheme == "" {
+               return errors.New("the URL scheme is empty")
+       }
+       if len(scheme) > 32 {
+               return errors.New("the URL scheme is longer than 32 characters")
+       }
+       for i, r := range scheme {
+               switch {
+               case r >= 'a' && r <= 'z':
+               case i > 0 && (r >= '0' && r <= '9' || r == '+' || r == '-' || 
r == '.'):
+               default:
+                       return fmt.Errorf("%q is not a URL scheme", scheme)
+               }
+       }
+       return nil
+}
+
+func linksPath() (string, error) {
+       home, err := agenthome.Resolve("")
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, linksDir), nil
+}
+
+func claimPath(scheme string) (string, error) {
+       if err := checkScheme(scheme); err != nil {
+               return "", err
+       }
+       dir, err := linksPath()
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(dir, scheme+".json"), nil
+}
+
+func readClaim(scheme string) (Claim, bool) {
+       path, err := claimPath(scheme)
+       if err != nil {
+               return Claim{}, false
+       }
+       data, err := os.ReadFile(path)
+       if err != nil {
+               return Claim{}, false
+       }
+
+       claim := Claim{}
+       if json.Unmarshal(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")), 
&claim) != nil || claim.Scheme != scheme {
+               return Claim{}, false
+       }
+       return claim, true
+}
+
+func writeClaim(claim Claim) error {
+       path, err := claimPath(claim.Scheme)
+       if err != nil {
+               return err
+       }
+       if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+               return err
+       }
+       data, err := json.MarshalIndent(claim, "", "  ")
+       if err != nil {
+               return err
+       }
+       return os.WriteFile(path, data, 0o600)
+}
+
+func removeClaim(scheme string) error {
+       path, err := claimPath(scheme)
+       if err != nil {
+               return err
+       }
+       if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+               return err
+       }
+       return nil
+}
+
+// reportOpenError leaves the last link this host could not route where the
+// operator can find it: the handler has no console and no log of its own.
+func reportOpenError(link string, cause error) {
+       dir, err := linksPath()
+       if err != nil {
+               return
+       }
+       if err := os.MkdirAll(dir, 0o700); err != nil {
+               return
+       }
+       note := fmt.Sprintf("%s\n%s\n%v\n", time.Now().Format(time.RFC3339), 
link, cause)
+       _ = os.WriteFile(filepath.Join(dir, "last-error.txt"), []byte(note), 
0o600)
+}
diff --git a/agentlink/link_other.go b/agentlink/link_other.go
new file mode 100644
index 0000000..9a1fe6f
--- /dev/null
+++ b/agentlink/link_other.go
@@ -0,0 +1,35 @@
+// 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.
+
+//go:build !windows
+
+package agentlink
+
+// A URL scheme is claimed here by the application bundle or the desktop entry
+// that owns it, neither of which Gateway can hand one link to and give back, 
so
+// nothing is captured on these hosts.
+
+func schemesSupported() bool { return false }
+
+func readHandler(string) (string, error) { return "", ErrUnsupported }
+
+func writeHandler(string, string) error { return ErrUnsupported }
+
+func handlerCommand() (string, error) { return "", ErrUnsupported }
+
+func start(string, []string) error { return ErrUnsupported }
+
+func openWith(string, string) error { return ErrUnsupported }
+
+func detachConsole() {}
diff --git a/agentlink/link_windows.go b/agentlink/link_windows.go
new file mode 100644
index 0000000..66e5962
--- /dev/null
+++ b/agentlink/link_windows.go
@@ -0,0 +1,160 @@
+// 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.
+
+//go:build windows
+
+package agentlink
+
+import (
+       "errors"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "strings"
+       "syscall"
+
+       "golang.org/x/sys/windows/registry"
+)
+
+// desktopLauncher is the windowed executable beside the server, preferred as
+// the handler because it has no console to flash when a link opens.
+const desktopLauncher = "casbin-gateway-desktop.exe"
+
+var (
+       kernel32 = syscall.NewLazyDLL("kernel32.dll")
+       user32   = syscall.NewLazyDLL("user32.dll")
+
+       procFreeConsole      = kernel32.NewProc("FreeConsole")
+       procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow")
+       procShowWindow       = user32.NewProc("ShowWindow")
+)
+
+func schemesSupported() bool { return true }
+
+// schemeKey is where Windows records the command one URL scheme opens with, 
for
+// this account alone.
+func schemeKey(scheme string) string {
+       return `Software\Classes\` + scheme + `\shell\open\command`
+}
+
+func readHandler(scheme string) (string, error) {
+       key, err := registry.OpenKey(registry.CURRENT_USER, schemeKey(scheme), 
registry.QUERY_VALUE)
+       if err != nil {
+               if errors.Is(err, registry.ErrNotExist) {
+                       return "", nil
+               }
+               return "", err
+       }
+       defer key.Close()
+
+       value, _, err := key.GetStringValue("")
+       if errors.Is(err, registry.ErrNotExist) {
+               return "", nil
+       }
+       return value, err
+}
+
+func writeHandler(scheme string, command string) error {
+       key, _, err := registry.CreateKey(registry.CURRENT_USER, 
schemeKey(scheme), registry.SET_VALUE)
+       if err != nil {
+               return err
+       }
+       defer key.Close()
+
+       return key.SetStringValue("", command)
+}
+
+// handlerCommand is what Windows runs for a captured scheme.
+func handlerCommand() (string, error) {
+       executable, err := os.Executable()
+       if err != nil {
+               return "", err
+       }
+       if launcher := filepath.Join(filepath.Dir(executable), 
desktopLauncher); isFile(launcher) {
+               executable = launcher
+       }
+       return `"` + executable + `" ` + Subcommand + ` "%1"`, nil
+}
+
+// start runs one copy of an agent with the link appended to the command it is
+// started with. The command line is written literally, which is what keeps the
+// link one argument.
+func start(executable string, args []string) error {
+       line := `"` + executable + `"`
+       for _, argument := range args {
+               line += ` "` + argument + `"`
+       }
+
+       return run(executable, line)
+}
+
+// openWith runs the command the agent had registered, as Windows would have
+// run it: the link takes the place of the %1 in it.
+func openWith(command string, link string) error {
+       executable := commandExecutable(command)
+       if executable == "" {
+               return errors.New("no command is recorded for this scheme")
+       }
+       return run(executable, strings.ReplaceAll(command, "%1", link))
+}
+
+// run starts one command line, written literally so that a link stays one
+// argument. A script is not a program CreateProcess can start, so the
+// interpreter that runs one is started instead, with the command quoted a
+// second time for it.
+func run(executable string, line string) error {
+       switch strings.ToLower(filepath.Ext(executable)) {
+       case ".cmd", ".bat":
+               executable = filepath.Join(os.Getenv("SystemRoot"), "System32", 
"cmd.exe")
+               line = `"` + executable + `" /s /c "` + line + `"`
+       }
+
+       cmd := exec.Command(executable)
+       cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: line}
+       if home, err := os.UserHomeDir(); err == nil {
+               cmd.Dir = home
+       }
+       return cmd.Start()
+}
+
+// commandExecutable is the program a registered command line runs.
+func commandExecutable(command string) string {
+       command = strings.TrimSpace(command)
+       if strings.HasPrefix(command, `"`) {
+               if end := strings.Index(command[1:], `"`); end > 0 {
+                       return command[1 : end+1]
+               }
+               return ""
+       }
+       if space := strings.IndexAny(command, " \t"); space > 0 {
+               return command[:space]
+       }
+       return command
+}
+
+// detachConsole drops the console a handler process would otherwise flash. The
+// released server is a console program, and a link opens it without a 
terminal.
+func detachConsole() {
+       hwnd, _, _ := procGetConsoleWindow.Call()
+       if hwnd == 0 {
+               return
+       }
+       _, _, _ = procShowWindow.Call(hwnd, 0) // SW_HIDE
+       _, _, _ = procFreeConsole.Call()
+}
+
+func isFile(path string) bool {
+       info, err := os.Stat(path)
+       return err == nil && info.Mode().IsRegular()
+}
diff --git a/controllers/agent_instance.go b/controllers/agent_instance.go
index 7270970..b0ad090 100644
--- a/controllers/agent_instance.go
+++ b/controllers/agent_instance.go
@@ -19,17 +19,25 @@ import (
        "errors"
        "fmt"
        "strconv"
+       "time"
 
        "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agentlink"
        "github.com/apache/casbin-gateway/agentpatch"
        "github.com/apache/casbin-gateway/agentprocess"
        "github.com/apache/casbin-gateway/object"
+       "github.com/beego/beego"
 )
 
 // maxInstances caps the names Gateway will hand out on its own, so a page that
 // keeps asking cannot fill the disk with empty profiles.
 const maxInstances = 50
 
+// linkCaptureTtl is how long a copy waiting to be signed in holds the URL 
scheme
+// of its agent: long enough for a sign-in in a browser, short enough that a 
link
+// arriving later still opens whichever copy the agent registered itself for.
+const linkCaptureTtl = 15 * time.Minute
+
 // agentInstanceView is one instance as the pages show it: what was stored, the
 // account its own state is signed in to, and whether it is running right now.
 type agentInstanceView struct {
@@ -38,6 +46,10 @@ type agentInstanceView struct {
        // Desktop tells the UI what a start would open: the app itself, or a 
console
        // window for a CLI.
        Desktop bool `json:"desktop"`
+       // CanCapture is whether Gateway can route this agent's own links here 
at
+       // all, and Capturing whether the next one will open this copy.
+       CanCapture bool `json:"canCapture"`
+       Capturing  bool `json:"capturing"`
        agentprocess.Status
 }
 
@@ -232,9 +244,86 @@ func (c *ApiController) StartAgentInstance() {
                c.ResponseError(err.Error())
                return
        }
+       // A sign-in started in this copy comes back as a link in the agent's 
own
+       // scheme, which would otherwise open the copy on the default state
+       // directory. A copy with no account in it is one that is about to sign 
in.
+       // The start is not worth failing over it: the copy runs either way, 
and the
+       // pages show whether the link was captured.
+       if agentlink.Supported() && agent.AccountOfInstance(instance.AgentId, 
instance.DataDir) == nil {
+               if err := captureLink(instance, target); err != nil {
+                       beego.Error("the sign-in link of", instance.Name, 
"cannot be routed to it:", err)
+               }
+       }
+       c.ResponseOk(instanceView(instance))
+}
+
+// CaptureAgentInstanceLink points the URL scheme of an agent at one copy, so
+// that the next sign-in a browser hands back opens that one. A copy started
+// without an account takes the scheme on its own; this is how a copy that is
+// already running, or already signed in, takes it too.
+func (c *ApiController) CaptureAgentInstanceLink() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       instance, ok := c.readAgentInstance()
+       if !ok {
+               return
+       }
+       var form struct {
+               Capture bool `json:"capture"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       scheme := agent.LinkSchemeOf(instance.AgentId)
+       if scheme == "" {
+               c.ResponseError(fmt.Sprintf("gateway knows no sign-in link of 
%s to route", instance.AgentId))
+               return
+       }
+       if !form.Capture {
+               if err := agentlink.Release(scheme); err != nil {
+                       c.ResponseError(err.Error())
+                       return
+               }
+               c.ResponseOk(instanceView(instance))
+               return
+       }
+
+       installation, err := findInstallation(instance.AgentId, instance.Path, 
instance.HostUser)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       target, err := instanceTarget(installation, instance)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if err := captureLink(instance, target); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
        c.ResponseOk(instanceView(instance))
 }
 
+// captureLink hands the next link in the agent's own scheme to one copy, which
+// is opened with the same command the instance is started with.
+func captureLink(instance *object.AgentInstance, target agentprocess.Target) 
error {
+       scheme := agent.LinkSchemeOf(instance.AgentId)
+       if scheme == "" {
+               return fmt.Errorf("gateway knows no sign-in link of %s to 
route", instance.AgentId)
+       }
+
+       _, err := agentlink.Capture(scheme, instance.Name, agentlink.Target{
+               Executable: target.Executable,
+               Args:       target.Args,
+       }, linkCaptureTtl)
+       return err
+}
+
 // StopAgentInstance ends the processes of one instance, leaving the others as
 // they are.
 func (c *ApiController) StopAgentInstance() {
@@ -293,6 +382,12 @@ func instanceView(instance *object.AgentInstance) 
*agentInstanceView {
                Status:        agentprocess.Status{Pids: []int{}},
        }
 
+       if scheme := agent.LinkSchemeOf(instance.AgentId); scheme != "" && 
agentlink.Supported() {
+               view.CanCapture = true
+               claim, pending := agentlink.Pending(scheme)
+               view.Capturing = pending && claim.Instance == instance.Name
+       }
+
        installation, err := findInstallation(instance.AgentId, instance.Path, 
instance.HostUser)
        if err == nil {
                var target agentprocess.Target
diff --git a/desktop/main.go b/desktop/main.go
index 0695abf..d3fedba 100644
--- a/desktop/main.go
+++ b/desktop/main.go
@@ -26,6 +26,7 @@ package main
 import (
        "fmt"
        "os"
+       "os/exec"
 )
 
 func main() {
@@ -55,6 +56,20 @@ func main() {
                return
        }
 
+       // "open-agent-link <url>" is what a URL scheme Gateway captured opens 
with.
+       // The server routes the link; it is started from here because this
+       // executable is windowed and flashes no console on the way.
+       if len(os.Args) > 2 && os.Args[1] == "open-agent-link" {
+               cmd := exec.Command(serverPath(), os.Args[1], os.Args[2])
+               cmd.Dir = gatewayHome()
+               hideConsole(cmd)
+               if err := cmd.Run(); err != nil {
+                       fmt.Fprintln(os.Stderr, "casbin-gateway-desktop:", err)
+                       os.Exit(1)
+               }
+               return
+       }
+
        if len(os.Args) > 1 && os.Args[1] == "window" {
                url := ""
                if len(os.Args) > 2 {
diff --git a/main.go b/main.go
index f0b73b1..26bd251 100644
--- a/main.go
+++ b/main.go
@@ -22,6 +22,7 @@ import (
        "time"
 
        "github.com/apache/casbin-gateway/agenthook"
+       "github.com/apache/casbin-gateway/agentlink"
        "github.com/apache/casbin-gateway/agentmonitor"
        "github.com/apache/casbin-gateway/agentpatch"
        "github.com/apache/casbin-gateway/casdoor"
@@ -47,6 +48,10 @@ func main() {
        agenthook.ServeIfInvoked()
        mcpserver.ServeIfInvoked()
 
+       // A link in the URL scheme of an agent opens this executable while 
Gateway
+       // is routing one to a particular copy. It is a launch, not a service.
+       agentlink.HandleIfInvoked()
+
        // "version" prints the build and exits. An update runs it on what it 
just
        // downloaded, before that executable replaces this one.
        if version.RunCommand(os.Args) {
@@ -64,6 +69,11 @@ func main() {
        version.CleanupBackup()
        version.Configure(daemonLogPath)
 
+       // A URL scheme is only Gateway while it waits for one link. A Gateway 
that
+       // did not get to hand one over left the scheme registered to itself, 
and
+       // this is the only process that can now give it back.
+       agentlink.Restore()
+
        object.InitFlag()
        object.InitAdapter()
        object.CreateTables()
diff --git a/routers/router.go b/routers/router.go
index 1e2edd2..651aecd 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -61,6 +61,7 @@ func initAPI() {
        beego.Router("/api/delete-agent-instance", 
&controllers.ApiController{}, "POST:DeleteAgentInstance")
        beego.Router("/api/start-agent-instance", &controllers.ApiController{}, 
"POST:StartAgentInstance")
        beego.Router("/api/stop-agent-instance", &controllers.ApiController{}, 
"POST:StopAgentInstance")
+       beego.Router("/api/capture-agent-instance-link", 
&controllers.ApiController{}, "POST:CaptureAgentInstanceLink")
        beego.Router("/api/get-agent-records", &controllers.ApiController{}, 
"GET:GetAgentRecords")
        beego.Router("/api/get-agent-sessions", &controllers.ApiController{}, 
"GET:GetAgentSessions")
        beego.Router("/api/get-agent-session", &controllers.ApiController{}, 
"GET:GetAgentSession")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 2547d5d..4b7b08c 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -114,6 +114,18 @@ export function stopAgentInstance(name: string) {
   return request<AgentInstance>("/api/stop-agent-instance", "POST", {name: 
name});
 }
 
+/**
+ * Points the URL scheme of the agent at one instance, so that the sign-in a
+ * browser hands back opens that copy rather than the one the agent registered
+ * itself for.
+ */
+export function captureAgentInstanceLink(name: string, capture: boolean) {
+  return request<AgentInstance>("/api/capture-agent-instance-link", "POST", {
+    name: name,
+    capture: capture,
+  });
+}
+
 export interface AgentRouting {
   provider: string;
   fallbacks: string[];
diff --git a/web/src/components/AgentInstances.tsx 
b/web/src/components/AgentInstances.tsx
index 1332164..34836f5 100644
--- a/web/src/components/AgentInstances.tsx
+++ b/web/src/components/AgentInstances.tsx
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
-import {Play, Plus, RefreshCw, Square, Trash2, UserPlus, Users} from 
"lucide-react";
+import {LogIn, Play, Plus, RefreshCw, Square, Trash2, UserPlus, Users} from 
"lucide-react";
 import i18next from "i18next";
 
 import {accountLabel} from "@/components/AgentGridCard";
@@ -143,6 +143,45 @@ function InstanceRunButton({
   );
 }
 
+/**
+ * Takes the sign-in link for one copy. A sign-in finished in a browser comes
+ * back through the agent's own URL scheme, which opens whichever copy the 
agent
+ * registered itself for - the first one, where the sign-in is of no use. While
+ * this is on, the next one opens this copy instead.
+ */
+function InstanceCaptureButton({
+  instance,
+  busy,
+  onToggle,
+}: {
+  instance: AgentInstance;
+  busy: boolean;
+  onToggle: (instance: AgentInstance) => void;
+}) {
+  if (!instance.canCapture) {
+    return null;
+  }
+
+  const waiting = instance.capturing === true;
+  const label = i18next.t(waiting ? "agent:Waiting for the sign-in link" : 
"agent:Capture sign-in");
+  return (
+    <SimpleTooltip title={waiting ? label : i18next.t("agent:Capture sign-in 
hint")}>
+      <span>
+        <Button
+          size="icon"
+          variant="ghost"
+          className={cn("size-6", waiting && "text-success")}
+          loading={busy}
+          aria-label={label}
+          onClick={() => onToggle(instance)}
+        >
+          <LogIn />
+        </Button>
+      </span>
+    </SimpleTooltip>
+  );
+}
+
 /**
  * The extra copies of one agent. Each is started against a state directory of
  * its own, so each signs in separately and the two run side by side without
@@ -217,6 +256,11 @@ export function AgentInstances({agent, enabled = true}: 
{agent: Agent; enabled?:
             busy={controls.busyName === record.name}
             onToggle={controls.toggleRunning}
           />
+          <InstanceCaptureButton
+            instance={record}
+            busy={controls.busyName === record.name}
+            onToggle={controls.toggleCapture}
+          />
           <ConfirmDialog
             title={`${i18next.t("agent:Remove instance")}: 
${instanceLabel(record)}?`}
             description={i18next.t("agent:Remove instance hint")}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 2010d3c..84c4d4f 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -764,7 +764,44 @@ export function useAgentInstances(agentId = "", enabled = 
true) {
     [load],
   );
 
-  return {instances, loading, busyName, reload: load, add, rename, remove, 
toggleRunning};
+  /**
+   * Takes the URL scheme of the agent for one copy, or gives it back. A copy
+   * started without an account takes it on its own; this is for one that is
+   * already running, or that is signing in again.
+   */
+  const toggleCapture = React.useCallback(
+    (instance: AgentInstance) => {
+      const capture = !instance.capturing;
+
+      setBusyName(instance.name);
+      AgentBackend.captureAgentInstanceLink(instance.name, capture)
+        .then(res => {
+          if (res.status === "ok") {
+            if (capture) {
+              Setting.showMessage("success", i18next.t("agent:Waiting for the 
sign-in link"));
+            }
+          } else {
+            Setting.showMessage("error", res.msg);
+          }
+          load();
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyName(""));
+    },
+    [load],
+  );
+
+  return {
+    instances,
+    loading,
+    busyName,
+    reload: load,
+    add,
+    rename,
+    remove,
+    toggleRunning,
+    toggleCapture,
+  };
 }
 
 /** What one hook call returns, as the cards and the tables take it. */
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 3e86c64..36e5290 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -44,6 +44,8 @@
     "Built-in hint": "This agent is on the model it comes with. Gateway 
forwards nothing for it and only watches what it does.",
     "Built-in model": "Built-in model",
     "Built-in model restored": "Put back on its own model",
+    "Capture sign-in": "Capture sign-in",
+    "Capture sign-in hint": "Open the next sign-in link the browser hands back 
with this copy. Without it the link opens whichever copy the agent registered 
itself for, and this one can never be signed in.",
     "Claude Code config hint": "The same variables can live in the \"env\" 
block of ~/.claude/settings.json, so every session picks them up without 
exporting anything.",
     "Claude Desktop sandbox hint": "Claude Desktop runs its Cowork and Code 
sessions in a sandbox of its own, where 127.0.0.1 is that sandbox rather than 
this host. The URL above names this machine on its own network instead, and 
Gateway answers there as well as on loopback.",
     "Client auth token hint": "This provider forwards the agent's own 
credentials, so no token is set: the agent keeps the sign-in it already has.",
@@ -181,6 +183,7 @@
     "Version": "Version",
     "View Records": "View Records",
     "View all": "View all",
+    "Waiting for the sign-in link": "Waiting for the sign-in link",
     "Write configuration": "Write configuration",
     "Write confirm hint": "Gateway writes the selected provider into this 
agent's own configuration file, replacing the file in one step. Restart any 
session that is already running.",
     "Write the configuration of {agent}?": "Write the configuration of 
{agent}?",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 5ffad97..efa582c 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -44,6 +44,8 @@
     "Built-in hint": "该 Agent 正在使用自带的模型,网关不为它转发请求,只做观测。",
     "Built-in model": "自带模型",
     "Built-in model restored": "已恢复为自带模型",
+    "Capture sign-in": "接管登录",
+    "Capture sign-in hint": "让浏览器返回的下一个登录链接打开这个副本。否则链接只会打开 Agent 
自己注册的那个副本,这个副本永远登录不上。",
     "Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env 
段,这样不用每次 export,所有会话都会生效。",
     "Claude Desktop sandbox hint": "Claude Desktop 的 Cowork 和 Code 
会话跑在它自己的沙箱里,那里的 127.0.0.1 是沙箱本身,不是这台主机。所以上面的地址用的是本机在局域网上的地址,Gateway 
除回环外也会在该地址上监听。",
     "Client auth token hint": "该供应商转发 Agent 自己的凭据,所以不设置 token,Agent 
继续用它已有的登录。",
@@ -181,6 +183,7 @@
     "Version": "版本",
     "View Records": "查看活动",
     "View all": "查看全部",
+    "Waiting for the sign-in link": "正在等待登录链接",
     "Write configuration": "写入配置",
     "Write confirm hint": "Gateway 会把所选供应商以该 Agent 
自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
     "Write the configuration of {agent}?": "写入 {agent} 的配置?",
diff --git a/web/src/types.ts b/web/src/types.ts
index c404514..84d0991 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -187,6 +187,10 @@ export interface AgentInstance {
   pids: number[];
   canStart: boolean;
   detail?: string;
+  /** Whether Gateway can route this agent's own sign-in links on this host. */
+  canCapture?: boolean;
+  /** Whether the next sign-in link will open this copy. */
+  capturing?: boolean;
 }
 
 /**


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

Reply via email to