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 <[email protected]>
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: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to