You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/05 16:34:53 UTC

(casbin-gateway) branch master updated (60da300 -> 4b38f27)

This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


    from 60da300  feat(connections): grow the catalog to 36 apps across seven categories
     new c805917  fix(connections): let a connection outlive the connector it came from
     new 4b38f27  feat(agents): hold several Codex sign-ins and swap between them

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 agent/account.go                                   |  15 +-
 agentauth/agentauth.go                             | 183 ++++++++
 agentauth/login.go                                 | 242 ++++++++++
 .../process_other.go => agentauth/login_other.go   |   5 +-
 .../run_windows.go => agentauth/login_windows.go   |   6 +-
 controllers/agent.go                               |   5 +
 controllers/agent_account.go                       | 351 ++++++++++++++
 controllers/connector.go                           |  26 +-
 object/agent_account.go                            | 268 +++++++++++
 object/connection.go                               |  32 +-
 object/connection_install.go                       |  27 +-
 object/ormer.go                                    |   7 +
 routers/router.go                                  |   8 +
 web/src/backend/AgentBackend.ts                    |  56 +++
 web/src/components/AgentAccounts.tsx               | 504 +++++++++++++++++++++
 web/src/components/AgentGridCard.tsx               |   7 +
 web/src/lib/agents.ts                              | 214 +++++++++
 web/src/locales/en/data.json                       |  25 +
 web/src/locales/zh/data.json                       |  25 +
 web/src/pages/AgentDetailPage.tsx                  |   3 +
 web/src/pages/ConnectionsPage.tsx                  |  98 +++-
 web/src/pages/HomePage.tsx                         |   7 +
 web/src/types.ts                                   |  59 +++
 23 files changed, 2153 insertions(+), 20 deletions(-)
 create mode 100644 agentauth/agentauth.go
 create mode 100644 agentauth/login.go
 copy desktop/process_other.go => agentauth/login_other.go (86%)
 copy agentinstall/run_windows.go => agentauth/login_windows.go (82%)
 create mode 100644 controllers/agent_account.go
 create mode 100644 object/agent_account.go
 create mode 100644 web/src/components/AgentAccounts.tsx


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org


(casbin-gateway) 02/02: feat(agents): hold several Codex sign-ins and swap between them

Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 4b38f27d90d5bb3181f5848f63a25f00e766fe29
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sun Sep 6 00:34:42 2026 +0800

    feat(agents): hold several Codex sign-ins and swap between them
---
 agent/account.go                     |  15 +-
 agentauth/agentauth.go               | 183 +++++++++++++
 agentauth/login.go                   | 242 +++++++++++++++++
 agentauth/login_other.go             |  22 ++
 agentauth/login_windows.go           |  28 ++
 controllers/agent.go                 |   5 +
 controllers/agent_account.go         | 351 ++++++++++++++++++++++++
 object/agent_account.go              | 268 +++++++++++++++++++
 object/ormer.go                      |   7 +
 routers/router.go                    |   8 +
 web/src/backend/AgentBackend.ts      |  56 ++++
 web/src/components/AgentAccounts.tsx | 504 +++++++++++++++++++++++++++++++++++
 web/src/components/AgentGridCard.tsx |   7 +
 web/src/lib/agents.ts                | 214 +++++++++++++++
 web/src/locales/en/data.json         |  21 ++
 web/src/locales/zh/data.json         |  21 ++
 web/src/pages/AgentDetailPage.tsx    |   3 +
 web/src/pages/HomePage.tsx           |   7 +
 web/src/types.ts                     |  47 ++++
 19 files changed, 2008 insertions(+), 1 deletion(-)

diff --git a/agent/account.go b/agent/account.go
index a977c2c..acfe77f 100644
--- a/agent/account.go
+++ b/agent/account.go
@@ -120,10 +120,23 @@ func readAccountIn(kind, dir string) *Account {
 	}
 }
 
+// AccountOfCredential names the sign-in held in an agent's own credential file,
+// given the contents rather than a directory: a copy saved somewhere else is
+// read by the same claims as the one in place.
+func AccountOfCredential(agentId string, data []byte) *Account {
+	if accountKind(agentId) != "codex" {
+		return nil
+	}
+	return codexAccountOf(data)
+}
+
 // readCodexAccount decodes the profile carried in the JWT that Codex and the
 // ChatGPT desktop app store after a ChatGPT sign-in.
 func readCodexAccount(dir string) *Account {
-	data := readCapped(filepath.Join(dir, "auth.json"))
+	return codexAccountOf(readCapped(filepath.Join(dir, "auth.json")))
+}
+
+func codexAccountOf(data []byte) *Account {
 	if data == nil {
 		return nil
 	}
diff --git a/agentauth/agentauth.go b/agentauth/agentauth.go
new file mode 100644
index 0000000..0371803
--- /dev/null
+++ b/agentauth/agentauth.go
@@ -0,0 +1,183 @@
+// 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 agentauth saves and puts back the file an agent keeps its sign-in in,
+// so one machine holds a subscription account and an API key at the same time
+// and moves between them without signing in again.
+//
+// Codex keeps both in the same ~/.codex/auth.json and its own sign-in
+// overwrites whichever was there, which is what makes the swap worth storing.
+package agentauth
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/apache/casbin-gateway/agent"
+	"github.com/apache/casbin-gateway/agentmonitor"
+)
+
+// The two ways an agent is signed in: an account with a plan behind it, and a
+// key billed by usage.
+const (
+	KindSubscription = "subscription"
+	KindApiKey       = "apikey"
+)
+
+// maxCredentialSize caps a read of the credential file.
+const maxCredentialSize = 1 << 20
+
+// Credential is one sign-in, kept as the whole file the agent reads it from so
+// that putting it back leaves the agent exactly as the sign-in left it.
+type Credential struct {
+	Kind  string `json:"kind"`
+	Data  string `json:"-"`
+	Email string `json:"email,omitempty"`
+	Name  string `json:"name,omitempty"`
+	Plan  string `json:"plan,omitempty"`
+}
+
+// Label is what one credential is called when it has not been named by hand.
+func (credential Credential) Label() string {
+	if credential.Email != "" {
+		return credential.Email
+	}
+	if credential.Name != "" {
+		return credential.Name
+	}
+	return credential.Kind
+}
+
+// Supports reports whether Gateway knows where this agent keeps its sign-in.
+func Supports(agentId string) bool {
+	return agentId == "codex" || agentId == "codex-cli"
+}
+
+// HomeOf is the directory holding the sign-in of one installation. The CLI and
+// the ChatGPT desktop app share it, so a swap moves both.
+func HomeOf(agentId string, path string, owner string) (string, error) {
+	if !Supports(agentId) {
+		return "", fmt.Errorf("gateway does not know where %s keeps its sign-in", agentId)
+	}
+	return agentmonitor.ResolveCodexHome(path, owner)
+}
+
+// Read is the sign-in in place, nil when the agent has none.
+func Read(agentId string, home string) (*Credential, error) {
+	data, err := os.ReadFile(filepath.Join(home, credentialFile))
+	if os.IsNotExist(err) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	if len(data) > maxCredentialSize {
+		return nil, fmt.Errorf("%s is larger than a sign-in file should be", credentialFile)
+	}
+	return Of(agentId, data), nil
+}
+
+// Of reads one credential file, nil when it holds no sign-in at all.
+func Of(agentId string, data []byte) *Credential {
+	kind := kindOf(data)
+	if kind == "" {
+		return nil
+	}
+	credential := &Credential{Kind: kind, Data: string(data)}
+	if account := agent.AccountOfCredential(agentId, data); account != nil {
+		credential.Email = account.Email
+		credential.Name = account.Name
+		credential.Plan = account.Plan
+	}
+	return credential
+}
+
+// Write puts one saved sign-in back where the agent reads it, replacing
+// whatever is there. The caller saves what it replaces first: this is the only
+// copy of it.
+func Write(home string, data string) error {
+	if strings.TrimSpace(data) == "" {
+		return errors.New("this account has no sign-in stored to put back")
+	}
+	if err := os.MkdirAll(home, 0o700); err != nil {
+		return err
+	}
+
+	path := filepath.Join(home, credentialFile)
+	temp := path + ".casbin-gateway.tmp"
+	if err := os.WriteFile(temp, []byte(data), 0o600); err != nil {
+		return err
+	}
+	if err := os.Rename(temp, path); err != nil {
+		os.Remove(temp)
+		return err
+	}
+	return nil
+}
+
+// ApiKeyCredential is the credential file Codex writes for a key rather than a
+// sign-in, so a key is stored and swapped in like any other account.
+func ApiKeyCredential(agentId string, key string) (Credential, error) {
+	key = strings.TrimSpace(key)
+	if key == "" {
+		return Credential{}, errors.New("the API key is empty")
+	}
+	if !Supports(agentId) {
+		return Credential{}, fmt.Errorf("gateway does not know where %s keeps its sign-in", agentId)
+	}
+
+	// tokens and last_refresh are written as null rather than left out: that is
+	// the file Codex writes for a key, and it is what clears a sign-in that was
+	// there before.
+	data, err := json.MarshalIndent(codexAuth{ApiKey: key}, "", "  ")
+	if err != nil {
+		return Credential{}, err
+	}
+	return Credential{Kind: KindApiKey, Data: string(data)}, nil
+}
+
+// credentialFile is what Codex keeps its sign-in in, under its home.
+const credentialFile = "auth.json"
+
+type codexAuth struct {
+	ApiKey      string `json:"OPENAI_API_KEY"`
+	Tokens      any    `json:"tokens"`
+	LastRefresh any    `json:"last_refresh"`
+}
+
+// kindOf tells a signed-in account from a key, empty for a file holding
+// neither.
+func kindOf(data []byte) string {
+	var auth struct {
+		ApiKey string `json:"OPENAI_API_KEY"`
+		Tokens *struct {
+			AccessToken  string `json:"access_token"`
+			RefreshToken string `json:"refresh_token"`
+		} `json:"tokens"`
+	}
+	if json.Unmarshal(data, &auth) != nil {
+		return ""
+	}
+	if auth.Tokens != nil && (auth.Tokens.AccessToken != "" || auth.Tokens.RefreshToken != "") {
+		return KindSubscription
+	}
+	if auth.ApiKey != "" {
+		return KindApiKey
+	}
+	return ""
+}
diff --git a/agentauth/login.go b/agentauth/login.go
new file mode 100644
index 0000000..4bf6cdf
--- /dev/null
+++ b/agentauth/login.go
@@ -0,0 +1,242 @@
+// 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 agentauth
+
+import (
+	"bufio"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/apache/casbin-gateway/util"
+)
+
+const (
+	// loginTimeout bounds one sign-in. The agent's login server sits waiting for
+	// a browser that may never come back.
+	loginTimeout = 10 * time.Minute
+	// urlWait is how long the start holds its answer for the address the browser
+	// has to open. The agent opens it itself as well, so the wait is short.
+	urlWait = 10 * time.Second
+	// maxLoginOutput is the tail of the agent's own output kept for the page.
+	maxLoginOutput = 8 * 1024
+)
+
+// Session is one sign-in Gateway started, running or finished. A browser
+// sign-in takes as long as whoever is at the machine, so the page polls this
+// rather than holding a request open.
+type Session struct {
+	Id      string `json:"id"`
+	AgentId string `json:"agentId"`
+	// Url is the address the sign-in has to be finished at. The agent opens it
+	// itself; this is for the machine where it could not.
+	Url     string `json:"url,omitempty"`
+	Running bool   `json:"running"`
+	// Ok is the outcome of a finished sign-in, false while one runs.
+	Ok bool `json:"ok"`
+	// Account names who signed in, once one has.
+	Account   string `json:"account,omitempty"`
+	Error     string `json:"error,omitempty"`
+	Output    string `json:"output,omitempty"`
+	StartTime string `json:"startTime"`
+	EndTime   string `json:"endTime,omitempty"`
+}
+
+type session struct {
+	sync.Mutex
+	Session
+	url chan string
+}
+
+var sessions = struct {
+	sync.Mutex
+	byId map[string]*session
+}{byId: map[string]*session{}}
+
+// urlPattern picks the address out of what the agent prints. The sign-in server
+// it starts is announced over plain http on this machine; the one to open is
+// the https address of the provider.
+var urlPattern = regexp.MustCompile(`https://[^\s"']+`)
+
+// StartLogin runs the agent's own sign-in against a directory of its own, so
+// the account it brings back is captured without disturbing the one in use.
+// save stores the finished credential; it runs on the background goroutine,
+// once, and its error is what the session reports.
+func StartLogin(agentId string, executable string, save func(Credential) error) (Session, error) {
+	if !Supports(agentId) {
+		return Session{}, fmt.Errorf("gateway cannot sign %s in", agentId)
+	}
+	if executable == "" {
+		return Session{}, errors.New("gateway found no Codex program to sign in with")
+	}
+
+	sessions.Lock()
+	for _, running := range sessions.byId {
+		// The agent's sign-in listens on a fixed port, so a second one would
+		// fail on the first one's listener rather than sign anybody in.
+		if running.snapshot().Running {
+			sessions.Unlock()
+			return Session{}, errors.New("a sign-in is already waiting for the browser")
+		}
+	}
+	started := &session{
+		Session: Session{
+			Id:        util.GenerateToken(16),
+			AgentId:   agentId,
+			Running:   true,
+			StartTime: util.GetCurrentTime(),
+		},
+		url: make(chan string, 1),
+	}
+	sessions.byId[started.Id] = started
+	sessions.Unlock()
+
+	go started.run(executable, save)
+
+	// The address is what the page needs to show, so the start waits for the
+	// agent to print it rather than answering with a session that says nothing.
+	select {
+	case url := <-started.url:
+		started.Lock()
+		started.Url = url
+		started.Unlock()
+	case <-time.After(urlWait):
+	}
+	return started.snapshot(), nil
+}
+
+// LoginSession is one sign-in as the page last left it.
+func LoginSession(id string) (Session, bool) {
+	sessions.Lock()
+	found, ok := sessions.byId[id]
+	sessions.Unlock()
+	if !ok {
+		return Session{}, false
+	}
+	return found.snapshot(), true
+}
+
+func (s *session) snapshot() Session {
+	s.Lock()
+	defer s.Unlock()
+	return s.Session
+}
+
+func (s *session) run(executable string, save func(Credential) error) {
+	home, err := os.MkdirTemp("", "casbin-gateway-signin-")
+	if err != nil {
+		s.finish(nil, err)
+		return
+	}
+	defer os.RemoveAll(home)
+
+	ctx, cancel := context.WithTimeout(context.Background(), loginTimeout)
+	defer cancel()
+
+	command := exec.CommandContext(ctx, executable, "login")
+	command.Env = append(os.Environ(), "CODEX_HOME="+home)
+	command.Dir = home
+	hideWindow(command)
+
+	output, err := command.StdoutPipe()
+	if err != nil {
+		s.finish(nil, err)
+		return
+	}
+	command.Stderr = command.Stdout
+	if err := command.Start(); err != nil {
+		s.finish(nil, err)
+		return
+	}
+
+	printed := s.readOutput(output)
+	waitErr := command.Wait()
+	if ctx.Err() != nil {
+		s.finish(nil, errors.New("the sign-in was not finished in the browser in time"))
+		return
+	}
+
+	credential, err := Read(s.AgentId, home)
+	if err != nil {
+		s.finish(nil, err)
+		return
+	}
+	if credential == nil {
+		if waitErr != nil {
+			s.finish(nil, fmt.Errorf("%s: %s", waitErr, lastLine(printed)))
+			return
+		}
+		s.finish(nil, errors.New("the sign-in left no account behind"))
+		return
+	}
+	s.finish(credential, save(*credential))
+}
+
+// readOutput follows what the agent prints until it closes the stream, handing
+// the first address it names to whoever is waiting for it.
+func (s *session) readOutput(stream io.Reader) string {
+	tail := &strings.Builder{}
+	found := false
+	scanner := bufio.NewScanner(stream)
+	scanner.Buffer(make([]byte, 0, 64*1024), 1<<20)
+	for scanner.Scan() {
+		line := scanner.Text()
+		tail.WriteString(line + "\n")
+		if !found {
+			if url := urlPattern.FindString(line); url != "" {
+				found = true
+				s.url <- url
+			}
+		}
+		s.Lock()
+		s.Output = keepTail(tail.String())
+		s.Unlock()
+	}
+	return tail.String()
+}
+
+func (s *session) finish(credential *Credential, err error) {
+	s.Lock()
+	defer s.Unlock()
+	s.Running = false
+	s.EndTime = util.GetCurrentTime()
+	if err != nil {
+		s.Error = err.Error()
+		return
+	}
+	s.Ok = true
+	if credential != nil {
+		s.Account = credential.Label()
+	}
+}
+
+func keepTail(text string) string {
+	if len(text) <= maxLoginOutput {
+		return text
+	}
+	return text[len(text)-maxLoginOutput:]
+}
+
+func lastLine(text string) string {
+	lines := strings.Split(strings.TrimSpace(text), "\n")
+	return strings.TrimSpace(lines[len(lines)-1])
+}
diff --git a/agentauth/login_other.go b/agentauth/login_other.go
new file mode 100644
index 0000000..5b79310
--- /dev/null
+++ b/agentauth/login_other.go
@@ -0,0 +1,22 @@
+// 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 agentauth
+
+import "os/exec"
+
+// hideWindow has nothing to hide anywhere but Windows.
+func hideWindow(*exec.Cmd) {}
diff --git a/agentauth/login_windows.go b/agentauth/login_windows.go
new file mode 100644
index 0000000..69b49dc
--- /dev/null
+++ b/agentauth/login_windows.go
@@ -0,0 +1,28 @@
+// 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 agentauth
+
+import (
+	"os/exec"
+	"syscall"
+)
+
+// hideWindow keeps the console the agent would open to itself: the sign-in
+// happens in a browser and what it prints is read from the pipe.
+func hideWindow(cmd *exec.Cmd) {
+	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 1acbf68..2b9cf93 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -25,6 +25,7 @@ import (
 	"time"
 
 	"github.com/apache/casbin-gateway/agent"
+	"github.com/apache/casbin-gateway/agentauth"
 	"github.com/apache/casbin-gateway/agenthistory"
 	"github.com/apache/casbin-gateway/agentinstall"
 	"github.com/apache/casbin-gateway/agentmonitor"
@@ -61,6 +62,9 @@ type discoveredAgent struct {
 	// SupportsInstances says whether this agent can be run more than once at a
 	// time, each copy signed in to an account of its own.
 	SupportsInstances bool `json:"supportsInstances"`
+	// SupportsAccounts says whether Gateway knows where this agent keeps its
+	// sign-in, which is what lets it hold more than one and swap between them.
+	SupportsAccounts bool `json:"supportsAccounts"`
 }
 
 // GetAgents scans known installation locations and returns the AI agents
@@ -107,6 +111,7 @@ func (c *ApiController) GetAgents() {
 			Uninstall:      agentinstall.UninstallPlan(installation),
 
 			SupportsInstances: agent.SupportsInstances(installation.AgentId),
+			SupportsAccounts:  agentauth.Supports(installation.AgentId),
 		}
 		baseUrl, ok := baseUrls[installation.AgentId]
 		if !ok {
diff --git a/controllers/agent_account.go b/controllers/agent_account.go
new file mode 100644
index 0000000..170fd5c
--- /dev/null
+++ b/controllers/agent_account.go
@@ -0,0 +1,351 @@
+// 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 (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/apache/casbin-gateway/agent"
+	"github.com/apache/casbin-gateway/agentauth"
+	"github.com/apache/casbin-gateway/agentpatch"
+	"github.com/apache/casbin-gateway/object"
+)
+
+// agentAccountView is one stored sign-in as the page lists it, with whether it
+// is the one the agent is using now. The credential itself never leaves here.
+type agentAccountView struct {
+	*object.AgentAccount
+	Current bool `json:"current"`
+}
+
+// agentAccountsView is the accounts of one agent and what it is signed in to,
+// which may be an account Gateway has not been given a copy of yet.
+type agentAccountsView struct {
+	Accounts []agentAccountView `json:"accounts"`
+	// Current is the sign-in in place, nil when the agent has none.
+	Current *agentauth.Credential `json:"current,omitempty"`
+	// Stored marks a sign-in in place that Gateway already holds a copy of, so
+	// the page knows whether switching away from it would lose it.
+	Stored bool `json:"stored"`
+	// Home is the directory the sign-in is read from.
+	Home string `json:"home,omitempty"`
+	// SignIn is the program a browser sign-in would be run with, empty when
+	// there is none on this machine.
+	SignIn string `json:"signIn,omitempty"`
+}
+
+// GetAgentAccounts lists the sign-ins stored for one agent beside the one it is
+// using now.
+func (c *ApiController) GetAgentAccounts() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	agentId := c.Input().Get("agent")
+	accounts, err := object.GetAgentAccounts(agentId)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	view := agentAccountsView{Accounts: make([]agentAccountView, 0, len(accounts))}
+	// The path names an installation, which is what says whose home the sign-in
+	// in place is read from. A listing without one is the stored rows alone.
+	if path := c.Input().Get("path"); path != "" && agentauth.Supports(agentId) {
+		installation, err := findInstallation(agentId, path, c.Input().Get("owner"))
+		if err != nil {
+			c.ResponseError(err.Error())
+			return
+		}
+		if view.Home, err = agentauth.HomeOf(agentId, installation.Path, installation.Owner); err != nil {
+			c.ResponseError(err.Error())
+			return
+		}
+		if view.Current, err = agentauth.Read(agentId, view.Home); err != nil {
+			c.ResponseError(err.Error())
+			return
+		}
+		if program, err := codexLoginProgram(installation); err == nil {
+			view.SignIn = program
+		}
+	}
+
+	for _, account := range accounts {
+		current := view.Current != nil && account.Matches(*view.Current)
+		view.Stored = view.Stored || current
+		view.Accounts = append(view.Accounts, agentAccountView{AgentAccount: account, Current: current})
+	}
+	c.ResponseOk(view)
+}
+
+// agentAccountForm is what the account calls take: the installation, and
+// whichever of the stored name, the label and the key the call needs.
+type agentAccountForm struct {
+	agentpatch.Target
+	Name        string `json:"name"`
+	DisplayName string `json:"displayName"`
+	ApiKey      string `json:"apiKey"`
+}
+
+// SaveAgentAccount keeps a copy of the sign-in the agent is using now, so that
+// switching away from it can be undone without signing in again.
+func (c *ApiController) SaveAgentAccount() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	form, home, ok := c.readAgentAccountForm()
+	if !ok {
+		return
+	}
+	credential, err := agentauth.Read(form.AgentId, home)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	if credential == nil {
+		c.ResponseError(fmt.Sprintf("%s is not signed in to anything to save", form.AgentId))
+		return
+	}
+
+	account, err := object.SaveAgentAccount(form.AgentId, *credential, form.DisplayName)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(agentAccountView{AgentAccount: account, Current: true})
+}
+
+// AddAgentAccount stores an API key as an account of its own, so the key and a
+// subscription sign-in are swapped the same way.
+func (c *ApiController) AddAgentAccount() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	form, _, ok := c.readAgentAccountForm()
+	if !ok {
+		return
+	}
+	credential, err := agentauth.ApiKeyCredential(form.AgentId, form.ApiKey)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	account, err := object.SaveAgentAccount(form.AgentId, credential, form.DisplayName)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(agentAccountView{AgentAccount: account})
+}
+
+// SwitchAgentAccount puts one stored sign-in back into the agent. What is there is
+// saved first: the agent keeps one credential file, and this overwrites it.
+func (c *ApiController) SwitchAgentAccount() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	form, home, ok := c.readAgentAccountForm()
+	if !ok {
+		return
+	}
+	account, err := object.GetAgentAccount(form.Name)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	if account == nil {
+		c.ResponseError(fmt.Sprintf("no agent account is stored under this name: %s", form.Name))
+		return
+	}
+	if account.AgentId != form.AgentId {
+		c.ResponseError(fmt.Sprintf("%s is signed in to %s, not to this agent", form.Name, account.AgentId))
+		return
+	}
+
+	replaced, err := agentauth.Read(form.AgentId, home)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	if replaced != nil {
+		if _, err := object.SaveAgentAccount(form.AgentId, *replaced, ""); err != nil {
+			c.ResponseError(err.Error())
+			return
+		}
+	}
+
+	if err := agentauth.Write(home, account.Credential); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	if err := object.TouchAgentAccount(account.Name); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(agentAccountView{AgentAccount: account, Current: true})
+}
+
+// UpdateAgentAccount renames one stored account in the lists.
+func (c *ApiController) UpdateAgentAccount() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	var form agentAccountForm
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	if err := object.SetAgentAccountDisplayName(form.Name, strings.TrimSpace(form.DisplayName)); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk()
+}
+
+// DeleteAgentAccount forgets one stored sign-in. The agent keeps whatever it is
+// using: this drops Gateway's copy, it does not sign anybody out.
+func (c *ApiController) DeleteAgentAccount() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	var form agentAccountForm
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	if err := object.DeleteAgentAccount(form.Name); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk()
+}
+
+// SignInAgentAccount starts a browser sign-in and stores the account it brings
+// back. It runs against a directory of its own, so the account in the agent is
+// untouched until it is switched to.
+func (c *ApiController) SignInAgentAccount() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	installation, ok := c.readAgentInstallation()
+	if !ok {
+		return
+	}
+	if !agentauth.Supports(installation.AgentId) {
+		c.ResponseError(fmt.Sprintf("gateway cannot sign %s in", installation.AgentId))
+		return
+	}
+	program, err := codexLoginProgram(installation)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	agentId := installation.AgentId
+	session, err := agentauth.StartLogin(agentId, program, func(credential agentauth.Credential) error {
+		_, err := object.SaveAgentAccount(agentId, credential, "")
+		return err
+	})
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(session)
+}
+
+// GetAgentSignin reports how a sign-in that was started is getting on. The page
+// polls it: a browser sign-in takes as long as whoever is at the machine.
+func (c *ApiController) GetAgentSignin() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	session, ok := agentauth.LoginSession(c.Input().Get("id"))
+	if !ok {
+		c.ResponseError("no sign-in was started under this id")
+		return
+	}
+	c.ResponseOk(session)
+}
+
+// readAgentAccountForm resolves the body against the installations a scan found
+// and answers with the directory its sign-in is read from. Writing there is
+// writing into somebody's home, so an unverified body would name any of them.
+func (c *ApiController) readAgentAccountForm() (agentAccountForm, string, bool) {
+	var form agentAccountForm
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+		c.ResponseError(err.Error())
+		return form, "", false
+	}
+
+	installation, err := findInstallation(form.AgentId, form.Path, form.Owner)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return form, "", false
+	}
+	home, err := agentauth.HomeOf(installation.AgentId, installation.Path, installation.Owner)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return form, "", false
+	}
+	form.AgentId = installation.AgentId
+	return form, home, true
+}
+
+// codexLoginProgram is the Codex program a sign-in is run with. The desktop app
+// is opened by its own window rather than by a command, so the CLI beside it -
+// which the same scan finds, and which reads the same ~/.codex - is what signs
+// in for both.
+func codexLoginProgram(installation agent.Installation) (string, error) {
+	if program := codexProgramOf(installation); program != "" {
+		return program, nil
+	}
+
+	installations, err := agent.Scan(false)
+	if err != nil {
+		return "", err
+	}
+	for _, other := range installations {
+		if !agentauth.Supports(other.AgentId) {
+			continue
+		}
+		if program := codexProgramOf(other); program != "" {
+			return program, nil
+		}
+	}
+	return "", errors.New("gateway found no Codex program on this machine to sign in with")
+}
+
+func codexProgramOf(installation agent.Installation) string {
+	executable := agent.LaunchOf(installation).Executable
+	if executable == "" {
+		return ""
+	}
+	if strings.HasPrefix(strings.ToLower(filepath.Base(executable)), "codex") {
+		return executable
+	}
+	return ""
+}
diff --git a/object/agent_account.go b/object/agent_account.go
new file mode 100644
index 0000000..92d007f
--- /dev/null
+++ b/object/agent_account.go
@@ -0,0 +1,268 @@
+// 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 object
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/apache/casbin-gateway/agentauth"
+	"github.com/apache/casbin-gateway/util"
+	"github.com/xorm-io/core"
+)
+
+// AgentAccount is one sign-in of an agent, kept aside so the agent can be moved
+// between accounts. An agent overwrites its own credential file when it signs
+// in, so a subscription account and an API key can only be held at once by
+// storing both here.
+//
+// Credential is the whole file the agent reads its sign-in from. It carries a
+// refresh token, so it is stored the way a provider key is.
+type AgentAccount struct {
+	Owner       string `xorm:"varchar(100) notnull pk" json:"owner"`
+	Name        string `xorm:"varchar(300) notnull pk" json:"name"`
+	CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+	UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+
+	AgentId string `xorm:"varchar(100) index" json:"agentId"`
+	// Kind is a subscription account or an API key.
+	Kind        string `xorm:"varchar(50)" json:"kind"`
+	DisplayName string `xorm:"varchar(200)" json:"displayName"`
+	Email       string `xorm:"varchar(200)" json:"email"`
+	Plan        string `xorm:"varchar(100)" json:"plan"`
+	// Fingerprint is what tells the account in place from the ones stored,
+	// without holding the credential itself up to the page.
+	Fingerprint string `xorm:"varchar(100) index" json:"fingerprint"`
+	Credential  string `xorm:"mediumtext" json:"-"`
+	// LastUsedTime is when this one was last written into the agent.
+	LastUsedTime string `xorm:"varchar(100)" json:"lastUsedTime"`
+}
+
+func (account *AgentAccount) GetId() string {
+	return fmt.Sprintf("%s/%s", account.Owner, account.Name)
+}
+
+// agentAccountAad binds the stored credential to its own row, so a value copied
+// into another account no longer decrypts.
+func agentAccountAad(account *AgentAccount) string {
+	return account.GetId() + "/credential"
+}
+
+// AgentAccountFingerprint names one credential by its contents, which is how a
+// sign-in found in the agent is recognised as one already stored.
+func AgentAccountFingerprint(credential string) string {
+	sum := sha256.Sum256([]byte(strings.TrimSpace(credential)))
+	return hex.EncodeToString(sum[:16])
+}
+
+// Matches reports whether this row holds the sign-in given: the same credential,
+// or the same account after the agent renewed its tokens in place, which
+// rewrites the file without signing anybody else in.
+func (account *AgentAccount) Matches(credential agentauth.Credential) bool {
+	if account.Fingerprint == AgentAccountFingerprint(credential.Data) {
+		return true
+	}
+	// A key is its own account, so only a signed-in one is recognised again by
+	// who it belongs to.
+	return credential.Kind == agentauth.KindSubscription &&
+		account.Kind == credential.Kind && account.Email != "" && account.Email == credential.Email
+}
+
+// GetAgentAccounts returns the accounts stored for one agent, or for every
+// agent when agentId is empty, oldest first so the list does not reshuffle.
+func GetAgentAccounts(agentId string) ([]*AgentAccount, error) {
+	accounts := []*AgentAccount{}
+	session := ormer.Engine.Where("owner = ?", AgentOwner)
+	if agentId != "" {
+		session = session.And("agent_id = ?", agentId)
+	}
+	if err := session.Asc("created_time").Find(&accounts); err != nil {
+		return nil, err
+	}
+	return accounts, nil
+}
+
+// GetAgentAccount returns one stored account with its credential decrypted,
+// which is what a swap writes back into the agent.
+func GetAgentAccount(name string) (*AgentAccount, error) {
+	account := &AgentAccount{Owner: AgentOwner, Name: name}
+	existed, err := ormer.Engine.Get(account)
+	if err != nil {
+		return nil, err
+	}
+	if !existed {
+		return nil, nil
+	}
+
+	credential, err := util.DecryptWithKey(apiKeyEncryptionSecret(), account.Credential, agentAccountAad(account))
+	if err != nil {
+		return nil, err
+	}
+	account.Credential = credential
+	return account, nil
+}
+
+// SaveAgentAccount stores one sign-in, replacing the row holding the same
+// credential. Signing the same account in again renews its tokens rather than
+// adding a second entry for it.
+func SaveAgentAccount(agentId string, credential agentauth.Credential, displayName string) (*AgentAccount, error) {
+	if agentId == "" || credential.Data == "" {
+		return nil, errors.New("the agent and the sign-in are required")
+	}
+
+	account := &AgentAccount{
+		Owner:       AgentOwner,
+		AgentId:     agentId,
+		Kind:        credential.Kind,
+		DisplayName: strings.TrimSpace(displayName),
+		Email:       credential.Email,
+		Plan:        credential.Plan,
+		Fingerprint: AgentAccountFingerprint(credential.Data),
+		Credential:  credential.Data,
+		UpdatedTime: util.GetCurrentTime(),
+	}
+	if account.DisplayName == "" {
+		account.DisplayName = credential.Label()
+	}
+
+	stored, err := findAgentAccount(agentId, credential)
+	if err != nil {
+		return nil, err
+	}
+	if stored != nil {
+		account.Name = stored.Name
+		account.CreatedTime = stored.CreatedTime
+		account.LastUsedTime = stored.LastUsedTime
+		if stored.DisplayName != "" && strings.TrimSpace(displayName) == "" {
+			account.DisplayName = stored.DisplayName
+		}
+	} else {
+		account.Name, err = freeAgentAccountName(agentId, credential)
+		if err != nil {
+			return nil, err
+		}
+		account.CreatedTime = account.UpdatedTime
+	}
+
+	// The row is written encrypted; the caller is handed back the plain one it
+	// passed in, since that is what a swap has to write into the agent.
+	row := *account
+	if row.Credential, err = util.EncryptWithKey(apiKeyEncryptionSecret(), row.Credential, agentAccountAad(&row)); err != nil {
+		return nil, err
+	}
+	if stored != nil {
+		_, err = ormer.Engine.ID(core.PK{AgentOwner, row.Name}).AllCols().Update(&row)
+	} else {
+		_, err = ormer.Engine.Insert(&row)
+	}
+	if err != nil {
+		return nil, err
+	}
+	return account, nil
+}
+
+// findAgentAccount is the stored row holding this sign-in already: the same
+// credential, or the same account signed in again.
+func findAgentAccount(agentId string, credential agentauth.Credential) (*AgentAccount, error) {
+	accounts, err := GetAgentAccounts(agentId)
+	if err != nil {
+		return nil, err
+	}
+	for _, account := range accounts {
+		if account.Matches(credential) {
+			return account, nil
+		}
+	}
+	return nil, nil
+}
+
+// DeleteAgentAccount forgets one stored sign-in. What the agent is using right
+// now is left alone: this drops the copy, not the session.
+func DeleteAgentAccount(name string) error {
+	_, err := ormer.Engine.Delete(&AgentAccount{Owner: AgentOwner, Name: name})
+	return err
+}
+
+// SetAgentAccountDisplayName renames one stored account in the lists.
+func SetAgentAccountDisplayName(name string, displayName string) error {
+	stored, err := GetAgentAccount(name)
+	if err != nil {
+		return err
+	}
+	if stored == nil {
+		return fmt.Errorf("no agent account is stored under this name: %s", name)
+	}
+
+	// Cols() is what writes an empty label: xorm skips zero values otherwise.
+	_, err = ormer.Engine.ID(core.PK{AgentOwner, name}).
+		Cols("display_name", "updated_time").
+		Update(&AgentAccount{DisplayName: displayName, UpdatedTime: util.GetCurrentTime()})
+	return err
+}
+
+// TouchAgentAccount records that this account is the one now in the agent.
+func TouchAgentAccount(name string) error {
+	now := util.GetCurrentTime()
+	_, err := ormer.Engine.ID(core.PK{AgentOwner, name}).
+		Cols("last_used_time", "updated_time").
+		Update(&AgentAccount{LastUsedTime: now, UpdatedTime: now})
+	return err
+}
+
+// freeAgentAccountName is the stored name of a new account: the agent it
+// belongs to and a slug of whoever it signs in, numbered when that is taken.
+func freeAgentAccountName(agentId string, credential agentauth.Credential) (string, error) {
+	base := agentId + "/" + agentAccountSlug(credential)
+	for suffix := 0; suffix < 100; suffix++ {
+		name := base
+		if suffix > 0 {
+			name = fmt.Sprintf("%s-%d", base, suffix+1)
+		}
+		stored := &AgentAccount{Owner: AgentOwner, Name: name}
+		existed, err := ormer.Engine.Get(stored)
+		if err != nil {
+			return "", err
+		}
+		if !existed {
+			return name, nil
+		}
+	}
+	return "", errors.New("too many accounts are stored for this agent")
+}
+
+// agentAccountSlug is a label reduced to what is safe in a stored name.
+func agentAccountSlug(credential agentauth.Credential) string {
+	slug := strings.Map(func(r rune) rune {
+		switch {
+		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '@':
+			return r
+		case r >= 'A' && r <= 'Z':
+			return r + ('a' - 'A')
+		default:
+			return '-'
+		}
+	}, credential.Label())
+	slug = strings.Trim(slug, "-")
+	if len(slug) > 64 {
+		slug = slug[:64]
+	}
+	if slug == "" {
+		return credential.Kind
+	}
+	return slug
+}
diff --git a/object/ormer.go b/object/ormer.go
index 4a83fe2..683bd6c 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -276,6 +276,13 @@ func (a *Ormer) createTable() {
 		panic(err)
 	}
 
+	// The sign-ins saved aside for an agent, so a subscription account and an
+	// API key are both held while the agent itself keeps only one.
+	err = a.Engine.Sync2(new(AgentAccount))
+	if err != nil {
+		panic(err)
+	}
+
 	// What each agent is allowed to ask for, as the switches of its Permissions
 	// card are set. The casbin policy is compiled from these rows.
 	err = a.Engine.Sync2(new(AgentPermission))
diff --git a/routers/router.go b/routers/router.go
index 4669d6c..9795283 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -88,6 +88,14 @@ func initAPI() {
 	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-accounts", &controllers.ApiController{}, "GET:GetAgentAccounts")
+	beego.Router("/api/save-agent-account", &controllers.ApiController{}, "POST:SaveAgentAccount")
+	beego.Router("/api/add-agent-account", &controllers.ApiController{}, "POST:AddAgentAccount")
+	beego.Router("/api/switch-agent-account", &controllers.ApiController{}, "POST:SwitchAgentAccount")
+	beego.Router("/api/update-agent-account", &controllers.ApiController{}, "POST:UpdateAgentAccount")
+	beego.Router("/api/delete-agent-account", &controllers.ApiController{}, "POST:DeleteAgentAccount")
+	beego.Router("/api/sign-in-agent-account", &controllers.ApiController{}, "POST:SignInAgentAccount")
+	beego.Router("/api/get-agent-signin", &controllers.ApiController{}, "GET:GetAgentSignin")
 	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 e59ef25..4762044 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -26,8 +26,11 @@ import type {
   AgentTranscript,
   AgentUpdate,
   AgentUsage,
+  AgentSignin,
   AgentVersionCatalog,
   BrowseListing,
+  SavedAccount,
+  SavedAccounts,
 } from "@/types";
 
 export interface PatchTarget {
@@ -182,6 +185,59 @@ export function captureAgentInstanceLink(name: string, capture: boolean) {
   });
 }
 
+/**
+ * The sign-ins Gateway keeps aside for one agent, beside the one the agent is
+ * using now. The installation says whose home that one is read from.
+ */
+export function getAgentAccounts(target: PatchTarget) {
+  return request<SavedAccounts>(
+    `/api/get-agent-accounts${query({agent: target.agentId, path: target.path, owner: target.owner})}`,
+  );
+}
+
+/** Keeps a copy of the sign-in the agent is using, so a swap can be undone. */
+export function saveAgentAccount(target: PatchTarget, displayName = "") {
+  return request<SavedAccount>("/api/save-agent-account", "POST", {
+    ...target,
+    displayName: displayName,
+  });
+}
+
+/** Stores an API key as an account of its own, swapped like any other. */
+export function addAgentAccount(target: PatchTarget, apiKey: string, displayName = "") {
+  return request<SavedAccount>("/api/add-agent-account", "POST", {
+    ...target,
+    apiKey: apiKey,
+    displayName: displayName,
+  });
+}
+
+/** Puts one stored sign-in back into the agent, saving what it replaces. */
+export function switchAgentAccount(target: PatchTarget, name: string) {
+  return request<SavedAccount>("/api/switch-agent-account", "POST", {...target, name: name});
+}
+
+export function renameAgentAccount(name: string, displayName: string) {
+  return request<string>("/api/update-agent-account", "POST", {
+    name: name,
+    displayName: displayName,
+  });
+}
+
+/** Forgets Gateway's copy of one sign-in; the agent keeps what it is using. */
+export function deleteAgentAccount(name: string) {
+  return request<string>("/api/delete-agent-account", "POST", {name: name});
+}
+
+/** Starts a browser sign-in and stores the account it brings back. */
+export function signInAgentAccount(target: PatchTarget) {
+  return request<AgentSignin>("/api/sign-in-agent-account", "POST", target);
+}
+
+export function getAgentSignin(id: string) {
+  return request<AgentSignin>(`/api/get-agent-signin${query({id: id})}`);
+}
+
 export interface AgentRouting {
   provider: string;
   fallbacks: string[];
diff --git a/web/src/components/AgentAccounts.tsx b/web/src/components/AgentAccounts.tsx
new file mode 100644
index 0000000..91f6a89
--- /dev/null
+++ b/web/src/components/AgentAccounts.tsx
@@ -0,0 +1,504 @@
+// 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.
+
+import * as React from "react";
+import {ExternalLink, KeyRound, LogIn, RefreshCw, Save, Trash2, UserCheck, UserRound} from "lucide-react";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {CodeText} from "@/components/shared/misc";
+import {Alert, AlertDescription} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {Input} from "@/components/ui/input";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {AiDots} from "@/components/shared/loading";
+import {
+  accountKindLabel,
+  accountName,
+  accountsOf,
+  agentKey,
+  useAgentAccounts,
+  type AgentAccountControls,
+} from "@/lib/agents";
+import type {Agent, SavedAccount} from "@/types";
+
+/** The mark for one kind of sign-in, so a key is told from an account at a glance. */
+function KindIcon({kind, className}: {kind: string; className?: string}) {
+  return kind === "apikey" ? (
+    <KeyRound className={className} />
+  ) : (
+    <UserRound className={className} />
+  );
+}
+
+/** Editable label of one stored account, so a second account of the same plan
+ *  is told from the first without reading its email. */
+function AccountName({
+  account,
+  onRename,
+}: {
+  account: SavedAccount;
+  onRename: (account: SavedAccount, displayName: string) => void;
+}) {
+  const [value, setValue] = React.useState(account.displayName);
+
+  React.useEffect(() => setValue(account.displayName), [account.displayName]);
+
+  return (
+    <Input
+      value={value}
+      placeholder={account.email || account.name}
+      aria-label={i18next.t("general:Name")}
+      className="hover:border-input h-7 border-transparent px-1 font-medium shadow-none"
+      onChange={event => setValue(event.target.value)}
+      onBlur={() => {
+        const named = value.trim();
+        if (named !== account.displayName) {
+          onRename(account, named);
+        }
+      }}
+      onKeyDown={event => {
+        if (event.key === "Enter") {
+          event.currentTarget.blur();
+        } else if (event.key === "Escape") {
+          setValue(account.displayName);
+        }
+      }}
+    />
+  );
+}
+
+/** Stores an API key as an account of its own, so the key and a subscription
+ *  account are swapped the same way. */
+function AddApiKeyDialog({
+  agent,
+  controls,
+}: {
+  agent: Agent;
+  controls: AgentAccountControls;
+}) {
+  const [open, setOpen] = React.useState(false);
+  const [apiKey, setApiKey] = React.useState("");
+  const [displayName, setDisplayName] = React.useState("");
+
+  React.useEffect(() => {
+    if (open) {
+      setApiKey("");
+      setDisplayName("");
+    }
+  }, [open]);
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button variant="outline" size="sm">
+          <KeyRound />
+          {i18next.t("agent:Add API key")}
+        </Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>{i18next.t("agent:Add API key")}</DialogTitle>
+          <DialogDescription>{i18next.t("agent:Add API key hint")}</DialogDescription>
+        </DialogHeader>
+        <div className="space-y-3">
+          <Input
+            value={apiKey}
+            type="password"
+            autoComplete="off"
+            placeholder="sk-..."
+            aria-label={i18next.t("agent:API key")}
+            onChange={event => setApiKey(event.target.value)}
+          />
+          <Input
+            value={displayName}
+            placeholder={i18next.t("general:Name")}
+            aria-label={i18next.t("general:Name")}
+            onChange={event => setDisplayName(event.target.value)}
+          />
+        </div>
+        <DialogFooter>
+          <Button variant="outline" onClick={() => setOpen(false)}>
+            {i18next.t("general:Cancel")}
+          </Button>
+          <Button
+            disabled={apiKey.trim() === ""}
+            loading={controls.busyKey === `${agentKey(agent)}:add`}
+            onClick={() => controls.add(agent, apiKey, displayName).then(() => setOpen(false))}
+          >
+            {i18next.t("general:Save")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/**
+ * The browser sign-in, while it waits. The agent's own login is run against a
+ * directory of its own, so the account it brings back is stored without
+ * touching the one the agent is using; the swap is a separate click.
+ *
+ * One of these is rendered per page, since one sign-in runs at a time.
+ */
+export function AgentSigninDialog({controls}: {controls: AgentAccountControls}) {
+  const session = controls.session;
+  if (!session) {
+    return null;
+  }
+
+  const finished = !session.running;
+  return (
+    <Dialog open={true} onOpenChange={() => controls.closeSession(session.ok)}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>{i18next.t("agent:Sign in")}</DialogTitle>
+          <DialogDescription>
+            {session.running
+              ? i18next.t("agent:Finish the sign-in in the browser")
+              : session.ok
+                ? `${i18next.t("agent:Account saved")}: ${session.account ?? ""}`
+                : i18next.t("agent:The sign-in did not finish")}
+          </DialogDescription>
+        </DialogHeader>
+
+        {session.running ? (
+          <div className="space-y-3">
+            <AiDots />
+            {session.url ? (
+              <div className="space-y-2">
+                <CodeText copyable>{session.url}</CodeText>
+                <Button variant="outline" size="sm" asChild>
+                  <a href={session.url} target="_blank" rel="noreferrer">
+                    <ExternalLink />
+                    {i18next.t("agent:Open the sign-in page")}
+                  </a>
+                </Button>
+              </div>
+            ) : null}
+          </div>
+        ) : null}
+
+        {finished && session.error ? (
+          <Alert variant="destructive">
+            <AlertDescription>{session.error}</AlertDescription>
+          </Alert>
+        ) : null}
+
+        <DialogFooter>
+          <Button
+            variant={finished ? "default" : "outline"}
+            onClick={() => controls.closeSession(session.ok)}
+          >
+            {i18next.t(finished ? "general:Close" : "general:Cancel")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/**
+ * The sign-ins Gateway keeps for one agent. Codex writes one credential file
+ * and its own sign-in overwrites whatever was there, so a subscription account
+ * and an API key are only held at once by keeping copies here - and swapping
+ * between them is then a click rather than another sign-in.
+ */
+export function AgentAccounts({agent, enabled = true}: {agent: Agent; enabled?: boolean}) {
+  // The card owns one agent, so it owns one hook; the home page hands the same
+  // controls to a row of cards instead.
+  const agents = React.useMemo(() => [agent], [agent]);
+  const controls = useAgentAccounts(agents, enabled && agent.supportsAccounts === true);
+
+  if (!agent.supportsAccounts) {
+    return null;
+  }
+
+  const listing = accountsOf(controls, agent);
+  const columns: Column<SavedAccount>[] = [
+    {
+      title: i18next.t("general:Name"),
+      key: "displayName",
+      dataIndex: "displayName",
+      render: (_value, record) => (
+        <AccountName
+          account={record}
+          onRename={(account, displayName) => controls.rename(account, displayName)}
+        />
+      ),
+    },
+    {
+      title: i18next.t("agent:Account"),
+      key: "email",
+      render: (_value, record) => (
+        <div className="flex flex-col">
+          <span className="flex items-center gap-2">
+            <Badge variant="muted">
+              <KindIcon kind={record.kind} className="size-3" />
+              {accountKindLabel(record.kind)}
+            </Badge>
+            {record.plan ? <span className="capitalize">{record.plan}</span> : null}
+          </span>
+          {record.email ? (
+            <code className="text-muted-foreground text-xs">{record.email}</code>
+          ) : null}
+        </div>
+      ),
+    },
+    {
+      title: i18next.t("agent:Status"),
+      key: "current",
+      render: (_value, record) =>
+        record.current ? (
+          <Badge variant="success">{i18next.t("agent:In use")}</Badge>
+        ) : (
+          <span className="text-muted-foreground text-xs">
+            {Setting.getFormattedDate(record.lastUsedTime) || i18next.t("agent:Never used")}
+          </span>
+        ),
+    },
+    {
+      title: i18next.t("general:Action"),
+      key: "action",
+      render: (_value, record) => (
+        <div className="flex items-center gap-2">
+          <Button
+            size="sm"
+            variant="outline"
+            disabled={record.current === true}
+            loading={controls.busyKey === record.name}
+            onClick={() => controls.switchTo(agent, record)}
+          >
+            <UserCheck />
+            {i18next.t("agent:Use")}
+          </Button>
+          <ConfirmDialog
+            title={`${i18next.t("agent:Remove account")}: ${accountName(record)}?`}
+            description={i18next.t("agent:Remove account hint")}
+            confirmText={i18next.t("agent:Remove account")}
+            variant="destructive"
+            onConfirm={() => controls.remove(record)}
+          >
+            <Button size="icon" variant="ghost" className="size-6">
+              <Trash2 />
+            </Button>
+          </ConfirmDialog>
+        </div>
+      ),
+    },
+  ];
+
+  // A sign-in in place that Gateway has no copy of is the one a swap would lose,
+  // so it is offered for saving before anything else is switched to.
+  const unsaved = listing.current != null && !listing.stored;
+
+  return (
+    <div className="space-y-3">
+      {unsaved ? (
+        <Alert variant="warning">
+          <AlertDescription className="flex flex-wrap items-center justify-between gap-2">
+            <span>
+              {i18next
+                .t("agent:Unsaved sign-in hint")
+                .replace(
+                  "{account}",
+                  listing.current?.email || accountKindLabel(listing.current?.kind ?? ""),
+                )}
+            </span>
+            <Button
+              size="sm"
+              loading={controls.busyKey === `${agentKey(agent)}:save`}
+              onClick={() => controls.saveCurrent(agent)}
+            >
+              <Save />
+              {i18next.t("agent:Save the sign-in in use")}
+            </Button>
+          </AlertDescription>
+        </Alert>
+      ) : null}
+
+      <DataTable
+        title={i18next.t("agent:Accounts")}
+        description={i18next.t("agent:Accounts hint")}
+        columns={columns}
+        dataSource={listing.accounts}
+        rowKey={account => account.name}
+        loading={controls.loading}
+        pageSize={0}
+        emptyText={i18next.t("agent:No accounts saved yet")}
+        toolbar={
+          <div className="flex items-center gap-2">
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => controls.reload()}
+              loading={controls.loading}
+            >
+              <RefreshCw />
+              {i18next.t("general:Refresh")}
+            </Button>
+            <AddApiKeyDialog agent={agent} controls={controls} />
+            <Button
+              size="sm"
+              loading={controls.busyKey === `${agentKey(agent)}:signin`}
+              onClick={() => controls.signIn(agent)}
+            >
+              <LogIn />
+              {i18next.t("agent:Sign in")}
+            </Button>
+          </div>
+        }
+      />
+
+      <AgentSigninDialog controls={controls} />
+    </div>
+  );
+}
+
+/** The value the picker carries for a sign-in Gateway holds no copy of. */
+const unsavedAccount = "-";
+
+/**
+ * The account of one agent as a grid card shows it: the sign-in it is on, and
+ * the picker that moves it to another one Gateway holds. The card has no room
+ * for the rest, so saving, renaming and removing live on the detail page.
+ */
+export function AgentCardAccounts({
+  agent,
+  controls,
+}: {
+  agent: Agent;
+  controls: AgentAccountControls;
+}) {
+  if (!agent.supportsAccounts) {
+    return null;
+  }
+
+  const listing = accountsOf(controls, agent);
+  // Nothing is signed in and nothing is stored: there is no account to show,
+  // and the sign-in that would make one is on the detail page.
+  if (listing.current == null && listing.accounts.length === 0) {
+    return null;
+  }
+
+  const current = listing.accounts.find(account => account.current);
+  const busy = controls.busyKey !== "";
+
+  return (
+    <div className="space-y-1.5">
+      <div className="flex min-w-0 items-center justify-between gap-2">
+        <span className="text-muted-foreground shrink-0 text-[11px]">
+          {i18next.t("agent:Account")}
+        </span>
+        {listing.current ? (
+          <span className="text-muted-foreground truncate text-[11px]">
+            {accountKindLabel(listing.current.kind)}
+          </span>
+        ) : null}
+      </div>
+
+      <div className="flex min-w-0 items-center gap-1.5">
+        <Select
+          value={current ? current.name : unsavedAccount}
+          disabled={busy}
+          onValueChange={value => {
+            const picked = listing.accounts.find(account => account.name === value);
+            if (picked && !picked.current) {
+              controls.switchTo(agent, picked);
+            }
+          }}
+        >
+          {/* The box shows the picked item's own children, so it is told to fit
+              them: the note beside each name is dropped, since on a card this
+              narrow it would leave the email nothing to be read in. */}
+          <SelectTrigger
+            size="sm"
+            className="min-w-0 flex-1 text-xs *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:flex-1 *:data-[slot=select-value]:overflow-hidden [&_[data-note]]:hidden"
+          >
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent>
+            {/* The one in place, when Gateway has no copy of it: it cannot be
+                switched back to, so it is shown and left unselectable. */}
+            {current ? null : (
+              <SelectItem value={unsavedAccount}>
+                <KindIcon kind={listing.current?.kind ?? ""} className="size-4" />
+                <span className="min-w-0 flex-1 truncate">
+                  {listing.current?.email || accountKindLabel(listing.current?.kind ?? "")}
+                </span>
+                <span data-note className="text-warning ml-2 shrink-0 text-xs">
+                  {i18next.t("agent:Not saved")}
+                </span>
+              </SelectItem>
+            )}
+            {listing.accounts.map(account => (
+              <SelectItem key={account.name} value={account.name}>
+                <KindIcon kind={account.kind} className="size-4" />
+                <span className="min-w-0 flex-1 truncate">{accountName(account)}</span>
+                <span data-note className="text-muted-foreground ml-2 shrink-0 text-xs capitalize">
+                  {account.plan || accountKindLabel(account.kind)}
+                </span>
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+
+        <SimpleTooltip title={i18next.t("agent:Sign in")}>
+          <span>
+            <Button
+              size="icon"
+              variant="ghost"
+              className="size-7 shrink-0"
+              aria-label={i18next.t("agent:Sign in")}
+              loading={controls.busyKey === `${agentKey(agent)}:signin`}
+              onClick={() => controls.signIn(agent)}
+            >
+              <LogIn />
+            </Button>
+          </span>
+        </SimpleTooltip>
+      </div>
+
+      {current ? null : (
+        <button
+          type="button"
+          className="text-warning hover:underline text-[11px]"
+          disabled={busy}
+          onClick={() => controls.saveCurrent(agent)}
+        >
+          {i18next.t("agent:Save the sign-in in use")}
+        </button>
+      )}
+    </div>
+  );
+}
diff --git a/web/src/components/AgentGridCard.tsx b/web/src/components/AgentGridCard.tsx
index eef44d0..d67080e 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -18,6 +18,7 @@ import {Bot, ChevronRight, UserRound} from "lucide-react";
 import i18next from "i18next";
 
 import {AgentIcon} from "@/components/AgentIcon";
+import {AgentCardAccounts} from "@/components/AgentAccounts";
 import {AgentCardInstances} from "@/components/AgentInstances";
 import {AgentUsageTrend, hasUsageTrend} from "@/components/AgentUsageTrend";
 import {AgentUpdateBadge} from "@/components/AgentVersionDialog";
@@ -42,6 +43,7 @@ import {
   agentDetailPath,
   builtinProvider,
   directMode,
+  type AgentAccountControls,
   type AgentInstanceControls,
 } from "@/lib/agents";
 import {providerIdOf, providerProtocol} from "@/lib/providers";
@@ -166,6 +168,7 @@ export function AgentGridCard({
   status,
   update,
   instances,
+  accounts,
   recording,
   busy,
   runBusy,
@@ -191,6 +194,8 @@ export function AgentGridCard({
   update?: AgentUpdate;
   /** The extra copies of this agent, for the agents that can run more than one. */
   instances?: AgentInstanceControls;
+  /** The sign-ins kept for this agent, for the agents that hold more than one. */
+  accounts?: AgentAccountControls;
   /** False while llmRecordMode is off, when a zero would be a lie. */
   recording: boolean;
   busy: boolean;
@@ -307,6 +312,8 @@ export function AgentGridCard({
             </SelectContent>
           </Select>
 
+          {accounts ? <AgentCardAccounts agent={agent} controls={accounts} /> : null}
+
           <Row label={i18next.t("agent:Model")}>
             {lastModel ? (
               <SimpleTooltip
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 3cc9b55..cb4af5a 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -26,10 +26,13 @@ import type {
   AgentInstance,
   AgentRuntime,
   AgentSession,
+  AgentSignin,
   AgentUpdate,
   AgentUsage,
   AgentUsageStat,
   Provider,
+  SavedAccount,
+  SavedAccounts,
 } from "@/types";
 
 /** How long a started app is given before its process is looked for again. */
@@ -976,3 +979,214 @@ export type AgentInstanceControls = ReturnType<typeof useAgentInstances>;
 export function instancesOf(instances: AgentInstance[], agent: Agent) {
   return instances.filter(instance => instance.agentId === agent.agentId);
 }
+
+
+/** How often a sign-in waiting for the browser is asked how it is getting on. */
+const signinPollMs = 2000;
+
+/** An agent's sign-ins as they arrive, empty until the listing does. */
+const noAccounts: SavedAccounts = {accounts: [], stored: false};
+
+/**
+ * useAgentAccounts owns the sign-ins Gateway keeps for the agents that have
+ * more than one. An agent holds a single credential file and its own sign-in
+ * overwrites it, so the copies here are what let a subscription account and an
+ * API key be swapped without signing in again.
+ *
+ * The listing is per installation - what is in place is read from that owner's
+ * home - so one call is made for each agent that supports it.
+ */
+export function useAgentAccounts(agents: Agent[], enabled = true) {
+  const [byAgent, setByAgent] = React.useState<Record<string, SavedAccounts>>({});
+  const [loading, setLoading] = React.useState(false);
+  const [busyKey, setBusyKey] = React.useState("");
+  const [session, setSession] = React.useState<AgentSignin | null>(null);
+
+  // The installations are re-read on every scan, so the effect is keyed by
+  // which of them have accounts rather than by the array it arrived in.
+  const targets = React.useMemo(
+    () =>
+      agents
+        .filter(agent => agent.supportsAccounts === true)
+        .map(agent => ({agentId: agent.agentId, path: agent.path, owner: agent.owner})),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [agents.filter(agent => agent.supportsAccounts === true).map(agentKey).join("|")],
+  );
+
+  const load = React.useCallback(() => {
+    if (!enabled || targets.length === 0) {
+      setByAgent({});
+      return;
+    }
+
+    setLoading(true);
+    Promise.all(
+      targets.map(target =>
+        AgentBackend.getAgentAccounts(target)
+          .then(res => (res.status === "ok" && res.data ? res.data : noAccounts))
+          .catch(() => noAccounts),
+      ),
+    )
+      .then(listings => {
+        const next: Record<string, SavedAccounts> = {};
+        targets.forEach((target, index) => {
+          next[agentKey(target)] = listings[index];
+        });
+        setByAgent(next);
+      })
+      .then(() => setLoading(false));
+  }, [enabled, targets]);
+
+  React.useEffect(() => {
+    load();
+  }, [load]);
+
+  // A sign-in is finished in a browser, so the page asks how it went rather
+  // than holding the call open for as long as somebody takes to type.
+  React.useEffect(() => {
+    if (!session?.running) {
+      return;
+    }
+    const timer = setInterval(() => {
+      AgentBackend.getAgentSignin(session.id)
+        .then(res => {
+          if (res.status === "ok" && res.data) {
+            setSession(res.data);
+          }
+        })
+        .catch(() => undefined);
+    }, signinPollMs);
+    return () => clearInterval(timer);
+  }, [session?.id, session?.running]);
+
+  const call = React.useCallback(
+    (key: string, run: () => Promise<{status: string; msg?: string}>, done: string) => {
+      setBusyKey(key);
+      return run()
+        .then(res => {
+          if (res.status === "ok") {
+            if (done !== "") {
+              Setting.showMessage("success", done);
+            }
+            load();
+          } else {
+            Setting.showMessage("error", res.msg ?? "");
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [load],
+  );
+
+  /** Puts one stored sign-in back into the agent, saving what it replaces. */
+  const switchTo = React.useCallback(
+    (agent: Agent, account: SavedAccount) =>
+      call(
+        account.name,
+        () => AgentBackend.switchAgentAccount(targetOf(agent), account.name),
+        `${i18next.t("agent:Account switched")}: ${accountName(account)}`,
+      ),
+    [call],
+  );
+
+  /** Keeps a copy of the sign-in in place, so a swap can be undone. */
+  const saveCurrent = React.useCallback(
+    (agent: Agent) =>
+      call(
+        `${agentKey(agent)}:save`,
+        () => AgentBackend.saveAgentAccount(targetOf(agent)),
+        i18next.t("agent:Account saved"),
+      ),
+    [call],
+  );
+
+  const add = React.useCallback(
+    (agent: Agent, apiKey: string, displayName: string) =>
+      call(
+        `${agentKey(agent)}:add`,
+        () => AgentBackend.addAgentAccount(targetOf(agent), apiKey, displayName),
+        i18next.t("agent:Account saved"),
+      ),
+    [call],
+  );
+
+  const rename = React.useCallback(
+    (account: SavedAccount, displayName: string) =>
+      call(account.name, () => AgentBackend.renameAgentAccount(account.name, displayName), ""),
+    [call],
+  );
+
+  const remove = React.useCallback(
+    (account: SavedAccount) =>
+      call(
+        account.name,
+        () => AgentBackend.deleteAgentAccount(account.name),
+        i18next.t("agent:Account removed"),
+      ),
+    [call],
+  );
+
+  /** Starts the browser sign-in; the dialog follows it from there. */
+  const signIn = React.useCallback((agent: Agent) => {
+    setBusyKey(`${agentKey(agent)}:signin`);
+    AgentBackend.signInAgentAccount(targetOf(agent))
+      .then(res => {
+        if (res.status === "ok" && res.data) {
+          setSession(res.data);
+        } else {
+          Setting.showMessage("error", res.msg ?? "");
+        }
+      })
+      .catch(err => Setting.showMessage("error", err.message || String(err)))
+      .then(() => setBusyKey(""));
+  }, []);
+
+  const closeSession = React.useCallback(
+    (signedIn: boolean) => {
+      setSession(null);
+      if (signedIn) {
+        load();
+      }
+    },
+    [load],
+  );
+
+  return {
+    byAgent,
+    loading,
+    busyKey,
+    session,
+    reload: load,
+    switchTo,
+    saveCurrent,
+    add,
+    rename,
+    remove,
+    signIn,
+    closeSession,
+  };
+}
+
+/** What one hook call returns, as the cards and the table take it. */
+export type AgentAccountControls = ReturnType<typeof useAgentAccounts>;
+
+/** The sign-ins of one installation, out of a listing of them all. */
+export function accountsOf(controls: AgentAccountControls, agent: Agent) {
+  return controls.byAgent[agentKey(agent)] ?? noAccounts;
+}
+
+/** The name of one stored account: what it was called, or whoever it signs in. */
+export function accountName(account: SavedAccount) {
+  return account.displayName || account.email || account.name.split("/").pop() || account.name;
+}
+
+/** The wording for the two ways an agent is signed in. */
+export function accountKindLabel(kind: string) {
+  return i18next.t(kind === "apikey" ? "agent:API key" : "agent:Subscription");
+}
+
+/** The installation as every account call names it. */
+function targetOf(agent: Agent) {
+  return {agentId: agent.agentId, path: agent.path, owner: agent.owner};
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index a22eeea..8050e6b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -18,9 +18,17 @@
     "Username": "Username"
   },
   "agent": {
+    "API key": "API key",
     "Account": "Account",
+    "Account removed": "Account removed",
+    "Account saved": "Account saved",
+    "Account switched": "Account switched",
+    "Accounts": "Accounts",
+    "Accounts hint": "Codex keeps one sign-in at a time and its own login overwrites whatever was there. The copies kept here are swapped in without signing in again, so a subscription account and an API key are both held at once.",
     "Across {count} agents": "Across {count} agents",
     "Active today": "Active today",
+    "Add API key": "Add API key",
+    "Add API key hint": "The key is stored the way the agent stores one, so switching to it signs the agent in with the key instead of the account.",
     "Add instance": "Add instance",
     "Advanced view": "Advanced view",
     "Advanced view hint": "Every installation found, with its processes, path, owner and install method.",
@@ -128,6 +136,7 @@
     "Fetch a URL": "Fetch a URL",
     "Filter": "Filter",
     "Find files by name": "Find files by name",
+    "Finish the sign-in in the browser": "Finish the sign-in in the browser. It runs against a directory of its own, so the account the agent is using now is left alone.",
     "Finished": "Finished",
     "First activity": "First activity",
     "Forget": "Forget",
@@ -183,7 +192,9 @@
     "Monitoring enabled": "Monitoring enabled",
     "Monitoring, no activity yet": "Monitoring, no activity yet",
     "Move or rename a file": "Move or rename a file",
+    "Never used": "Never used",
     "New version": "New version",
+    "No accounts saved yet": "No accounts saved yet - sign in or add an API key to keep one",
     "No agent found": "No agent found",
     "No agent records yet - patch an agent to start collecting them": "No activity yet - turn on monitoring for an agent to start collecting it",
     "No agent sessions yet": "No agent sessions yet",
@@ -202,6 +213,7 @@
     "Not installed hint": "Agents Gateway works with that were not found on this machine. Install one with a package manager this host already has, or open its vendor's page.",
     "Not patched": "Not monitored",
     "Not recorded": "not recorded",
+    "Not saved": "Not saved",
     "Not signed in": "Not signed in",
     "Not signed in hint": "Start this instance and sign in; the account shows up here afterwards.",
     "Not supported": "Not supported yet",
@@ -213,6 +225,7 @@
     "Onboarding see it work": "See it work",
     "Onboarding see it work detail": "Watch requests land in LLM Records once traffic flows through.",
     "Only the ones I pick": "Only the ones I pick",
+    "Open the sign-in page": "Open the sign-in page",
     "Patch": "Turn on monitoring",
     "Patch Status": "Monitoring",
     "Patched": "Monitoring",
@@ -245,6 +258,8 @@
     "Records": "Records",
     "Records are stored in the database and survive a Gateway restart": "Records are stored in the database and survive a Gateway restart",
     "Remember something": "Remember something",
+    "Remove account": "Remove account",
+    "Remove account hint": "Gateway forgets its copy of this sign-in. The agent keeps whatever it is signed in to right now.",
     "Remove instance": "Remove instance",
     "Remove instance hint": "Gateway forgets this instance. Its state directory, and the account signed in to it, are left on disk.",
     "Remove the line to clear it": "Remove that line, then start the agent again.",
@@ -262,6 +277,7 @@
     "Running": "Running",
     "Running in a container": "Gateway is running in a container",
     "Running in a container detail": "Agents are found by reading the home directories of the machine Gateway runs on, and in a container that is the container's own filesystem rather than the host's. Install Gateway on the host to see the agents installed there. Providers and LLM Records work either way.",
+    "Save the sign-in in use": "Save this sign-in",
     "Scan": "Rescan",
     "Search inside files": "Search inside files",
     "Search the codebase by meaning": "Search the codebase by meaning",
@@ -276,6 +292,7 @@
     "Set in the system environment variables": "Set in the system environment variables",
     "Set in the user environment variables": "Set in the user environment variables",
     "Shared config permission hint": "This agent shares its configuration file with another agent, and that file routes through Gateway under the other id, so none of these rules are ever reached. Its requests are held to the rules of:",
+    "Sign in": "Sign in",
     "Source": "Source",
     "Start": "Start",
     "Start a sub-agent": "Start a sub-agent",
@@ -288,12 +305,14 @@
     "Stop a running command": "Stop a running command",
     "Stop hint": "Ends every process of this installation, losing whatever it is working on.",
     "Stopped": "Stopped",
+    "Subscription": "Subscription",
     "Switch takes effect at once": "Picking another provider above rewrites this file at once, so switching takes one click.",
     "Switch version": "Switch version",
     "Switching version": "Switching version",
     "Target / Model": "Target / Model",
     "Terminal": "Terminal",
     "The internet": "The internet",
+    "The sign-in did not finish": "The sign-in did not finish",
     "Thinking": "Thinking",
     "This folder could not be read": "This folder could not be read",
     "This folder is empty": "This folder is empty",
@@ -318,6 +337,7 @@
     "Unrestricted": "Unrestricted",
     "Unrestricted hint": "This agent may ask for any model, reach any provider and use every tool.",
     "Unrouted permission hint": "This agent's own configuration does not point at Gateway, so its requests never reach it and none of these rules apply. Bind it to a provider on the Agents page.",
+    "Unsaved sign-in hint": "{account} is signed in here but Gateway has no copy of it, so switching to another account would lose it.",
     "Up": "Up",
     "Up to date": "Up to date",
     "Update manually": "Update manually",
@@ -327,6 +347,7 @@
     "Upgrade {agent}?": "Upgrade {agent}?",
     "Upgrading": "Upgrading",
     "Usage": "Usage",
+    "Use": "Use",
     "Use this program": "Use this program",
     "User": "User",
     "Vendor page": "Vendor page",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index f3fe8f0..0f2238f 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -18,9 +18,17 @@
     "Username": "用户名"
   },
   "agent": {
+    "API key": "API密钥",
     "Account": "账户",
+    "Account removed": "账号已移除",
+    "Account saved": "账号已保存",
+    "Account switched": "账号已切换",
+    "Accounts": "账号",
+    "Accounts hint": "Codex同时只保留一份登录信息,它自己的登录会覆盖原有的那份。这里保存的副本可以直接换回去,无需重新登录,于是订阅账号和API密钥可以同时留在手上。",
     "Across {count} agents": "覆盖{count}个Agent",
     "Active today": "今日活跃",
+    "Add API key": "添加API密钥",
+    "Add API key hint": "该密钥会按Agent自己的存储格式保存,切换过去后,Agent将改用密钥而不是账号登录。",
     "Add instance": "添加实例",
     "Advanced view": "高级视图",
     "Advanced view hint": "扫描到的每个安装,含进程、路径、所有者和安装方式。",
@@ -128,6 +136,7 @@
     "Fetch a URL": "抓取网址",
     "Filter": "筛选",
     "Find files by name": "按文件名查找",
+    "Finish the sign-in in the browser": "请在浏览器里完成登录。登录过程使用独立的目录,因此Agent当前正在用的账号不受影响。",
     "Finished": "已完成",
     "First activity": "首次活动",
     "Forget": "移出列表",
@@ -183,7 +192,9 @@
     "Monitoring enabled": "已开启监控",
     "Monitoring, no activity yet": "监控中,暂无活动",
     "Move or rename a file": "移动或重命名文件",
+    "Never used": "从未使用",
     "New version": "新版本",
+    "No accounts saved yet": "暂无已保存的账号-登录或添加一个API密钥即可保存一份",
     "No agent found": "没有找到Agent客户端",
     "No agent records yet - patch an agent to start collecting them": "还没有活动记录,开启某个Agent客户端的监控后才会开始收集",
     "No agent sessions yet": "还没有会话",
@@ -202,6 +213,7 @@
     "Not installed hint": "本机未找到、但Gateway支持的代理。可直接用本机已有的包管理器安装,或打开厂商页面。",
     "Not patched": "未开启",
     "Not recorded": "未保留",
+    "Not saved": "未保存",
     "Not signed in": "未登录",
     "Not signed in hint": "启动该实例并登录,之后账号会显示在这里。",
     "Not supported": "暂不支持",
@@ -213,6 +225,7 @@
     "Onboarding see it work": "查看效果",
     "Onboarding see it work detail": "流量接入后,可在LLM Records中看到请求记录。",
     "Only the ones I pick": "只允许勾选的",
+    "Open the sign-in page": "打开登录页面",
     "Patch": "开启监控",
     "Patch Status": "监控状态",
     "Patched": "监控中",
@@ -245,6 +258,8 @@
     "Records": "记录数",
     "Records are stored in the database and survive a Gateway restart": "记录保存在数据库中,Gateway重启后依然存在",
     "Remember something": "记住信息",
+    "Remove account": "移除账号",
+    "Remove account hint": "Gateway将删除这份登录信息的副本。Agent当前登录的账号保持不变。",
     "Remove instance": "移除实例",
     "Remove instance hint": "Gateway将不再管理该实例。它的状态目录以及其中登录的账号仍保留在磁盘上。",
     "Remove the line to clear it": "请删除该行,然后重新启动该Agent。",
@@ -262,6 +277,7 @@
     "Running": "运行中",
     "Running in a container": "Gateway正跑在容器里",
     "Running in a container detail": "Agent是靠读取Gateway所在机器的用户主目录发现的,而在容器里那是容器自己的文件系统,不是宿主机的。把Gateway装到宿主机上才能看到装在那里的Agent。Providers和LLM Records在容器里照常可用。",
+    "Save the sign-in in use": "保存当前登录",
     "Scan": "重新扫描",
     "Search inside files": "在文件内容里搜索",
     "Search the codebase by meaning": "按语义搜索代码库",
@@ -276,6 +292,7 @@
     "Set in the system environment variables": "设置在系统环境变量中",
     "Set in the user environment variables": "设置在用户环境变量中",
     "Shared config permission hint": "该Agent客户端与另一个Agent客户端共用同一个配置文件,而文件里写的是那个Agent客户端的网关地址,因此这里的规则永远不会被用到。它的请求按下面这个Agent客户端的规则判定:",
+    "Sign in": "登录",
     "Source": "来源",
     "Start": "启动",
     "Start a sub-agent": "启动子Agent",
@@ -288,12 +305,14 @@
     "Stop a running command": "停止运行中的命令",
     "Stop hint": "结束该安装的所有进程,正在进行的工作会丢失。",
     "Stopped": "未运行",
+    "Subscription": "订阅账号",
     "Switch takes effect at once": "在上面换一个供应商会立即改写这个文件,所以切换只需要点一下。",
     "Switch version": "切换版本",
     "Switching version": "切换版本中",
     "Target / Model": "目标/模型",
     "Terminal": "终端",
     "The internet": "访问网络",
+    "The sign-in did not finish": "登录未完成",
     "Thinking": "思考",
     "This folder could not be read": "无法读取该目录",
     "This folder is empty": "该目录为空",
@@ -318,6 +337,7 @@
     "Unrestricted": "不受限制",
     "Unrestricted hint": "该Agent客户端可以使用任意模型、任意供应商和全部工具。",
     "Unrouted permission hint": "该Agent客户端自己的配置没有指向网关,请求不会经过网关,这里的规则不会生效。请先在「Agent客户端」页面为它绑定一个供应商。",
+    "Unsaved sign-in hint": "当前登录的是{account},但Gateway还没有它的副本,切换到别的账号会导致它丢失。",
     "Up": "上一级",
     "Up to date": "已是最新",
     "Update manually": "手动更新",
@@ -327,6 +347,7 @@
     "Upgrade {agent}?": "升级{agent}?",
     "Upgrading": "升级中",
     "Usage": "用量",
+    "Use": "切换",
     "Use this program": "使用该程序",
     "User": "用户",
     "Vendor page": "官方安装页",
diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx
index a9b80c0..82ec6a6 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -23,6 +23,7 @@ import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
+import {AgentAccounts} from "@/components/AgentAccounts";
 import {AgentInstances} from "@/components/AgentInstances";
 import {accountLabel} from "@/components/AgentGridCard";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
@@ -580,6 +581,8 @@ export default function AgentDetailPage({account}: {account: Account}) {
 
       <PermissionCard agent={agent} providers={providers} />
 
+      <AgentAccounts agent={agent} enabled={isAdmin} />
+
       <AgentInstances agent={agent} enabled={isAdmin} />
 
       {!watching ? (
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index d39bcb3..cfb9754 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -24,6 +24,7 @@ import * as Setting from "@/Setting";
 import {AgentCatalog} from "@/components/AgentCatalog";
 import {CcSwitchBanner} from "@/components/CcSwitchBanner";
 import {AuthenticityOverview} from "@/components/AuthenticityOverview";
+import {AgentSigninDialog} from "@/components/AgentAccounts";
 import {AgentGridCard} from "@/components/AgentGridCard";
 import {OnboardingButton, OnboardingChecklist, useOnboarding} from "@/components/OnboardingChecklist";
 import {EmptyState, ErrorState} from "@/components/shared/empty-state";
@@ -36,6 +37,7 @@ import {Card} from "@/components/ui/card";
 import {
   agentKey,
   runtimeOf,
+  useAgentAccounts,
   useAgentInstall,
   useAgentInstances,
   useAgents,
@@ -96,6 +98,7 @@ export default function HomePage({account}: {account: Account}) {
   // Every agent's extra copies in one listing, so a page of cards costs one
   // request rather than one per card.
   const instances = useAgentInstances("", isAdmin);
+  const accounts = useAgentAccounts(agents, isAdmin);
   const [providers, setProviders] = React.useState<Provider[]>([]);
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
   const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
@@ -309,6 +312,7 @@ export default function HomePage({account}: {account: Account}) {
               status={runtimeOf(runtime, agent)}
               update={updateOf(updates.updates, agent)}
               instances={instances}
+              accounts={accounts}
               recording={recording}
               busy={busyKey === agentKey(agent)}
               runBusy={runBusyKey === agentKey(agent)}
@@ -321,6 +325,9 @@ export default function HomePage({account}: {account: Account}) {
         </div>
       )}
 
+      {/* One sign-in runs at a time, so the whole grid shares one dialog. */}
+      <AgentSigninDialog controls={accounts} />
+
       {/* What is not here yet, so an empty machine has somewhere to go. */}
       <AgentCatalog agents={agents} enabled={scanned} installer={installer} onLocated={() => scan(true)} />
     </PageContainer>
diff --git a/web/src/types.ts b/web/src/types.ts
index d8a7ee0..bb2aae1 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -194,6 +194,53 @@ export interface Agent {
   uninstall?: AgentInstallPlan;
   /** Whether a second copy of this agent can run beside the first. */
   supportsInstances?: boolean;
+  /** Whether Gateway knows where this agent keeps its sign-in, which is what
+   *  lets it hold several and swap between them. */
+  supportsAccounts?: boolean;
+}
+
+/** One sign-in Gateway keeps aside for an agent. The credential itself stays on
+ *  the server; this is what the lists show of it. */
+export interface SavedAccount {
+  /** The stored id, "<agentId>/<slug>", which every call names it by. */
+  name: string;
+  agentId: string;
+  /** "subscription" for an account with a plan behind it, "apikey" for a key. */
+  kind: string;
+  displayName: string;
+  email?: string;
+  plan?: string;
+  createdTime: string;
+  lastUsedTime?: string;
+  /** Whether this is the sign-in the agent is using right now. */
+  current?: boolean;
+}
+
+/** The saved sign-ins of one agent beside the one it is using now. */
+export interface SavedAccounts {
+  accounts: SavedAccount[];
+  /** What the agent is signed in to, absent when it is signed in to nothing. */
+  current?: {kind: string; email?: string; name?: string; plan?: string};
+  /** Whether Gateway already holds a copy of the sign-in in place. */
+  stored: boolean;
+  /** The directory the sign-in is read from. */
+  home?: string;
+  /** The program a browser sign-in would run, empty when there is none here. */
+  signIn?: string;
+}
+
+/** A browser sign-in Gateway started, as the page polls it. */
+export interface AgentSignin {
+  id: string;
+  agentId: string;
+  /** The address the sign-in has to be finished at. */
+  url?: string;
+  running: boolean;
+  ok: boolean;
+  /** Who signed in, once someone has. */
+  account?: string;
+  error?: string;
+  output?: string;
 }
 
 /** One switch of the Permissions page: something an agent may be allowed to do,


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org


(casbin-gateway) 01/02: fix(connections): let a connection outlive the connector it came from

Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit c805917a9596abc4c03eca247c7a2d7eb04cfba5
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sun Sep 6 00:33:06 2026 +0800

    fix(connections): let a connection outlive the connector it came from
---
 controllers/connector.go          | 26 ++++++++++-
 object/connection.go              | 32 +++++++++++--
 object/connection_install.go      | 27 +++++++++--
 web/src/locales/en/data.json      |  4 ++
 web/src/locales/zh/data.json      |  4 ++
 web/src/pages/ConnectionsPage.tsx | 98 +++++++++++++++++++++++++++++++++++++--
 web/src/types.ts                  | 12 +++++
 7 files changed, 189 insertions(+), 14 deletions(-)

diff --git a/controllers/connector.go b/controllers/connector.go
index 2059ff4..bd37c7d 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -64,6 +64,18 @@ type ConnectorCatalog struct {
 	Connectors []*ConnectorEntry  `json:"connectors"`
 	Categories []string           `json:"categories"`
 	Targets    []*ConnectorTarget `json:"targets"`
+	// Orphans are connections whose connector this build no longer has, after a
+	// release dropped one. There is no card to draw for them and nothing left to
+	// edit, but they are still written into agents, so the page has to be able
+	// to show them and take them away.
+	Orphans []*OrphanConnection `json:"orphans"`
+}
+
+// OrphanConnection is one of those, with only what it takes to say which it is
+// and undo it.
+type OrphanConnection struct {
+	Name   string   `json:"name"`
+	Agents []string `json:"agents"`
 }
 
 // GetConnectors lists the whole catalog with each entry's connection state, the
@@ -99,12 +111,24 @@ func (c *ApiController) GetConnectors() {
 		entries = append(entries, entry)
 	}
 
+	orphans := []*OrphanConnection{}
+	for _, connection := range connections {
+		if connection.Orphaned() {
+			orphans = append(orphans, &OrphanConnection{Name: connection.Name, Agents: connection.Agents})
+		}
+	}
+
 	targets, err := connectorTargets()
 	if err != nil {
 		c.ResponseError(err.Error())
 		return
 	}
-	c.ResponseOk(&ConnectorCatalog{Connectors: entries, Categories: connector.Categories(), Targets: targets})
+	c.ResponseOk(&ConnectorCatalog{
+		Connectors: entries,
+		Categories: connector.Categories(),
+		Targets:    targets,
+		Orphans:    orphans,
+	})
 }
 
 // connectorTargets is every installation found on this machine whose MCP
diff --git a/object/connection.go b/object/connection.go
index c0ede83..11fad51 100644
--- a/object/connection.go
+++ b/object/connection.go
@@ -47,6 +47,11 @@ type Connection struct {
 	Credentials map[string]string `xorm:"mediumtext json" json:"credentials"`
 	// Agents is every agent id this connection's MCP server was written into.
 	Agents []string `xorm:"mediumtext json" json:"agents"`
+	// EntryName is what the server is called inside those agents' own
+	// configuration files. It is kept here rather than looked up, because a
+	// connector dropped from the catalogue in a later release would otherwise
+	// leave a connection nobody can take back out of the agents it reached.
+	EntryName string `xorm:"varchar(200)" json:"entryName"`
 	// Endpoint and Executable are the Gateway the written entries name. A
 	// Gateway whose port changed, or that moved on disk, leaves every one of
 	// those entries starting a program that is not there or reporting to a port
@@ -121,16 +126,25 @@ func decryptConnection(connection *Connection) {
 // Masked is a copy safe to send to the browser: every secret field is replaced
 // by ApiKeyMask, so a stored credential never leaves this process.
 func (connection *Connection) Masked() *Connection {
-	found, ok := connector.Get(connection.Name)
-	if !ok {
-		return connection
-	}
-
 	masked := *connection
 	masked.Credentials = map[string]string{}
 	for key, value := range connection.Credentials {
 		masked.Credentials[key] = value
 	}
+
+	found, ok := connector.Get(connection.Name)
+	if !ok {
+		// Nothing left says which of these are secret, so every one of them is
+		// treated as one. A connector dropped from the catalogue must not be
+		// the thing that hands a stored credential to the browser.
+		for key, value := range masked.Credentials {
+			if value != "" {
+				masked.Credentials[key] = ApiKeyMask
+			}
+		}
+		return &masked
+	}
+
 	for _, key := range found.SecretKeys() {
 		if masked.Credentials[key] != "" {
 			masked.Credentials[key] = ApiKeyMask
@@ -139,6 +153,14 @@ func (connection *Connection) Masked() *Connection {
 	return &masked
 }
 
+// Orphaned reports a connection whose connector this build no longer has. It
+// can still be disconnected, which is what EntryName is kept for; nothing else
+// can be done with it.
+func (connection *Connection) Orphaned() bool {
+	_, ok := connector.Get(connection.Name)
+	return !ok
+}
+
 func GetConnections(owner string) ([]*Connection, error) {
 	connections := []*Connection{}
 	err := ormer.Engine.Where("owner = ?", owner).Find(&connections)
diff --git a/object/connection_install.go b/object/connection_install.go
index 03d5cd0..6365e56 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -69,6 +69,7 @@ func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfi
 	planned = append(planned, removeFrom(connection, found.Server.Name, letGo)...)
 
 	connection.Agents = installed(planned, agentIds)
+	connection.EntryName = found.Server.Name
 	// Remembering which Gateway the entries name is what lets a later start
 	// notice that they are stale, see EnsureConnectionsCurrent.
 	if executable, err := agentpatch.GatewayExecutable(); err == nil {
@@ -132,12 +133,8 @@ func UninstallConnection(owner string, name string) ([]*agentconfig.PlanItem, er
 	if err != nil || connection == nil {
 		return nil, err
 	}
-	found, ok := connector.Get(name)
-	if !ok {
-		return nil, fmt.Errorf("no connector named %q", name)
-	}
 
-	planned := removeFrom(connection, found.Server.Name, connection.Agents)
+	planned := removeFrom(connection, entryNameOf(connection), connection.Agents)
 	if err := DeleteConnection(owner, name); err != nil {
 		return nil, err
 	}
@@ -289,6 +286,12 @@ func EnsureConnectionsCurrent() error {
 		if connection.Endpoint == gatewayUrl && connection.Executable == executable {
 			continue
 		}
+		// A connector this build no longer has cannot be written again: there
+		// is no server to write. Saying so once a listing would be noise, so it
+		// is left for the page to show as an orphan the operator can disconnect.
+		if connection.Orphaned() {
+			continue
+		}
 		if _, err := InstallConnection(connection, connection.Agents); err != nil {
 			failed = fmt.Errorf("connection %s: %w", connection.GetId(), err)
 		}
@@ -340,3 +343,17 @@ func recordProbe(connection *Connection, result *mcpproxy.ProbeResult, failure e
 	}
 	return failure
 }
+
+// entryNameOf is what this connection is called inside an agent's own
+// configuration. Rows written before EntryName was recorded fall back to the
+// catalogue, and then to the connection's own name, which is what the entry was
+// called for every connector whose server is named after it.
+func entryNameOf(connection *Connection) string {
+	if connection.EntryName != "" {
+		return connection.EntryName
+	}
+	if found, ok := connector.Get(connection.Name); ok {
+		return found.Server.Name
+	}
+	return connection.Name
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 4ca04c1..a22eeea 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -847,13 +847,17 @@
     "No agent found": "No agent on this machine takes MCP servers Gateway knows how to write.",
     "No such connector": "This Gateway has no connector called {name}.",
     "Not authorized": "Not authorized yet",
+    "Nothing matches": "Nothing here matches {query}.",
     "Office": "Office",
+    "Orphan connection": "{name} is connected, but this version of Gateway no longer has that connector",
+    "Orphan hint": "Its server is still written into {count} agents and its credentials are still stored here. Disconnecting undoes both.",
     "Personal access token": "Personal access token",
     "Pick at least one agent": "Pick at least one agent.",
     "Professional": "Professional services",
     "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.": "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.",
     "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.": "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.",
     "Run test": "Run test",
+    "Search applications": "Search applications",
     "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.",
     "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.": "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.",
     "Storage": "Storage",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 51ca04c..f3fe8f0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -918,13 +918,17 @@
     "No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。",
     "No such connector": "这个Gateway里没有叫{name}的连接器。",
     "Not authorized": "尚未授权",
+    "Nothing matches": "没有匹配「{query}」的应用。",
     "Office": "办公协同",
+    "Orphan connection": "{name}已连接,但当前版本的Gateway里已经没有这个连接器",
+    "Orphan hint": "它的服务仍写在{count}个Agent里,凭据也还存在这里。断开连接会把两者一并清除。",
     "Personal access token": "个人访问令牌",
     "Pick at least one agent": "至少勾选一个Agent。",
     "Professional": "专业服务",
     "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.": "读取和整理页面、数据库与知识库:查找文档、梳理项目状态,并把结论写回页面。",
     "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.": "读写仓库、issue和pull request,检索代码与提交历史,让Agent直接在你的项目上下文里工作。",
     "Run test": "测试",
+    "Search applications": "搜索应用",
     "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "搜索地点、规划驾车与步行路线、查询天气,以及把地址转成坐标。",
     "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.": "在Settings→Developer settings→Personal access tokens生成,只勾选你愿意让Agent拥有的权限。",
     "Storage": "网盘存储",
diff --git a/web/src/pages/ConnectionsPage.tsx b/web/src/pages/ConnectionsPage.tsx
index 9be31ee..59fb756 100644
--- a/web/src/pages/ConnectionsPage.tsx
+++ b/web/src/pages/ConnectionsPage.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {useSearchParams} from "react-router-dom";
-import {ExternalLink, Plug} from "lucide-react";
+import {ExternalLink, Plug, Search, TriangleAlert} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
@@ -29,7 +29,14 @@ import {Card, CardContent} from "@/components/ui/card";
 import {Input} from "@/components/ui/input";
 import {connectorText as text} from "@/lib/connectors";
 import {cn} from "@/lib/utils";
-import type {Account, ConnectorCatalog, ConnectorEntry, ConnectorTarget, ConnectorTool} from "@/types";
+import type {
+  Account,
+  ConnectorCatalog,
+  ConnectorEntry,
+  ConnectorTarget,
+  ConnectorTool,
+  OrphanConnection,
+} from "@/types";
 
 const CREDENTIAL_MASK = "***";
 
@@ -59,6 +66,7 @@ export default function ConnectionsPage({account}: {account: Account}) {
   const [catalog, setCatalog] = React.useState<ConnectorCatalog | null>(null);
   const [loading, setLoading] = React.useState(true);
   const [category, setCategory] = React.useState("");
+  const [query, setQuery] = React.useState("");
   const [editing, setEditing] = React.useState<ConnectorEntry | null>(null);
   // An "add this to Gateway" link lands here naming one application, and what
   // it asks for is this dialog rather than anything written on its behalf.
@@ -106,8 +114,22 @@ export default function ConnectionsPage({account}: {account: Account}) {
   }
 
   const connectors = catalog?.connectors ?? [];
-  const shown = category === "" ? connectors : connectors.filter(entry => entry.category === category);
   const categories = (catalog?.categories ?? []).filter(name => connectors.some(entry => entry.category === name));
+  // The search reads the wording actually on the card, so what somebody types
+  // after seeing it finds it again whichever language the page is in.
+  const needle = query.trim().toLowerCase();
+  const shown = connectors.filter(entry => {
+    if (category !== "" && entry.category !== category) {
+      return false;
+    }
+    if (needle === "") {
+      return true;
+    }
+    return [entry.id, text(entry.displayName), text(entry.description)]
+      .join(" ")
+      .toLowerCase()
+      .includes(needle);
+  });
 
   return (
     <PageContainer>
@@ -121,12 +143,33 @@ export default function ConnectionsPage({account}: {account: Account}) {
         {categories.map(name => (
           <FilterChip key={name} active={category === name} onClick={() => setCategory(name)}>
             {i18next.t(categoryLabels[name] ?? name)}
+            <span className="text-muted-foreground ml-1.5 text-xs">
+              {connectors.filter(entry => entry.category === name).length}
+            </span>
           </FilterChip>
         ))}
+
+        <div className="relative ml-auto w-full sm:w-56">
+          <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
+          <Input
+            value={query}
+            onChange={event => setQuery(event.target.value)}
+            placeholder={i18next.t("connector:Search applications")}
+            className="h-9 pl-8"
+          />
+        </div>
       </div>
 
+      {(catalog?.orphans ?? []).map(orphan => (
+        <OrphanRow key={orphan.name} account={account} orphan={orphan} onDone={reload} />
+      ))}
+
       {loading && connectors.length === 0 ? (
         <p className="text-muted-foreground text-sm">{i18next.t("general:Loading")}</p>
+      ) : shown.length === 0 ? (
+        <p className="text-muted-foreground text-sm">
+          {i18next.t("connector:Nothing matches").replace("{query}", query.trim())}
+        </p>
       ) : (
         <div className="grid gap-4 lg:grid-cols-2">
           {shown.map(entry => (
@@ -151,6 +194,55 @@ export default function ConnectionsPage({account}: {account: Account}) {
   );
 }
 
+/**
+ * A connection whose connector went away with a release. It is not a card: there
+ * is nothing to fill in and nothing to test, only an entry still sitting in some
+ * agents and a credential still stored here, both of which disconnecting undoes.
+ */
+function OrphanRow({
+  account,
+  orphan,
+  onDone,
+}: {
+  account: Account;
+  orphan: OrphanConnection;
+  onDone: () => void;
+}) {
+  const [busy, setBusy] = React.useState(false);
+
+  const remove = () => {
+    setBusy(true);
+    ConnectorBackend.disconnect(account.name, orphan.name)
+      .then(response => {
+        if (response.status === "ok") {
+          Setting.showMessage("success", i18next.t("connector:Disconnected"));
+          onDone();
+        } else {
+          Setting.showMessage("error", response.msg ?? "");
+        }
+      })
+      .catch(error => Setting.showMessage("error", error.message))
+      .finally(() => setBusy(false));
+  };
+
+  return (
+    <div className="border-destructive/40 bg-destructive/5 flex items-center gap-3 rounded-xl border p-4">
+      <TriangleAlert className="text-destructive size-5 shrink-0" />
+      <div className="min-w-0 flex-1 text-sm">
+        <div className="font-medium">
+          {i18next.t("connector:Orphan connection").replace("{name}", orphan.name)}
+        </div>
+        <p className="text-muted-foreground">
+          {i18next.t("connector:Orphan hint").replace("{count}", `${orphan.agents.length}`)}
+        </p>
+      </div>
+      <Button variant="outline" onClick={remove} disabled={busy}>
+        {i18next.t("connector:Disconnect")}
+      </Button>
+    </div>
+  );
+}
+
 function FilterChip({active, onClick, children}: {active: boolean; onClick: () => void; children: React.ReactNode}) {
   return (
     <button
diff --git a/web/src/types.ts b/web/src/types.ts
index 0b77e17..d8a7ee0 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1612,6 +1612,18 @@ export interface ConnectorCatalog {
   connectors: ConnectorEntry[];
   categories: string[];
   targets: ConnectorTarget[];
+  /** Connections whose connector this build no longer has. */
+  orphans: OrphanConnection[];
+}
+
+/**
+ * A connection left behind when a release dropped its connector. Nothing about
+ * it can be edited; it is shown so it can be taken back out of the agents it
+ * was written into.
+ */
+export interface OrphanConnection {
+  name: string;
+  agents: string[];
 }
 
 export interface ConnectRequest {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org