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

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


The following commit(s) were added to refs/heads/master by this push:
     new c7183a0  fix(agentauth): release the sign-in slot when the browser 
never comes back (#228)
c7183a0 is described below

commit c7183a08a277ffc5816eeb35b26546d96fcde7f2
Author: qioo777 <[email protected]>
AuthorDate: Wed Sep 16 00:02:47 2026 +0800

    fix(agentauth): release the sign-in slot when the browser never comes back 
(#228)
---
 agentauth/login.go                   | 220 ++++++++++++++++++++-----
 agentauth/login_other.go             |  24 ++-
 agentauth/login_test.go              | 310 +++++++++++++++++++++++++++++++++++
 agentauth/login_windows.go           |  24 ++-
 controllers/agent_account.go         |  16 ++
 routers/router.go                    |   1 +
 web/src/backend/AgentBackend.ts      |   6 +
 web/src/components/AgentAccounts.tsx |   4 +-
 web/src/lib/agents.ts                |   9 +-
 web/src/types.ts                     |   2 +
 10 files changed, 570 insertions(+), 46 deletions(-)

diff --git a/agentauth/login.go b/agentauth/login.go
index 4bf6cdf..2892a2a 100644
--- a/agentauth/login.go
+++ b/agentauth/login.go
@@ -30,15 +30,30 @@ import (
        "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.
+var (
+       // loginTimeout is the last resort for a sign-in nobody cancelled. A 
page
+       // closed without finishing is not something the agent can see, so this 
is
+       // what ends it - long enough that signing in slowly is not cut short.
        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
+       // stopWait bounds the wait for a stopped agent to let go of its 
output. The
+       // session ends either way: it must not hold the next sign-in behind it.
+       stopWait = 5 * time.Second
+       // finishedTtl is how long a finished sign-in stays readable, so the 
page
+       // polling it still sees how it ended.
+       finishedTtl = 10 * time.Minute
+)
+
+// maxLoginOutput is the tail of the agent's own output kept for the page.
+const maxLoginOutput = 8 * 1024
+
+// How a sign-in ended, when it was not the browser that ended it.
+var (
+       errLoginCancelled = errors.New("the sign-in was cancelled")
+       errLoginTimedOut  = errors.New("the sign-in was not finished in the 
browser in time")
+       errLoginEnded     = errors.New("the sign-in ended before it signed 
anybody in")
 )
 
 // Session is one sign-in Gateway started, running or finished. A browser
@@ -53,6 +68,8 @@ type Session struct {
        Running bool   `json:"running"`
        // Ok is the outcome of a finished sign-in, false while one runs.
        Ok bool `json:"ok"`
+       // Cancelled marks a sign-in somebody ended rather than one that went 
wrong.
+       Cancelled bool `json:"cancelled,omitempty"`
        // Account names who signed in, once one has.
        Account   string `json:"account,omitempty"`
        Error     string `json:"error,omitempty"`
@@ -64,12 +81,24 @@ type Session struct {
 type session struct {
        sync.Mutex
        Session
-       url chan string
+       // endedAt is when the session finished, for forgetting it later.
+       endedAt time.Time
+       // reason is what to report when the agent is stopped rather than left 
to
+       // finish: killing it makes its own exit status meaningless.
+       reason error
+       url    chan string
+       ctx    context.Context
+       // cancel ends the agent and everything it started.
+       cancel context.CancelFunc
+       // done is closed once the session is finished and the agent is gone.
+       done chan struct{}
 }
 
 var sessions = struct {
        sync.Mutex
        byId map[string]*session
+       // active is the sign-in holding the agent's login port, nil when none 
is.
+       active *session
 }{byId: map[string]*session{}}
 
 // urlPattern picks the address out of what the agent prints. The sign-in 
server
@@ -89,15 +118,47 @@ func StartLogin(agentId string, executable string, save 
func(Credential) error)
                return Session{}, errors.New("gateway found no Codex program to 
sign in with")
        }
 
+       started, err := claim(agentId)
+       if err != nil {
+               return Session{}, err
+       }
+
+       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.
+       // A sign-in that is already over has none to wait for.
+       select {
+       case url := <-started.url:
+               started.Lock()
+               started.Url = url
+               started.Unlock()
+       case <-started.done:
+       case <-time.After(urlWait):
+       }
+       return started.snapshot(), nil
+}
+
+// claim takes the one sign-in slot there is. 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 - but only a sign-in that is genuinely still waiting
+// holds the slot, since every other one has been ended and released.
+func claim(agentId string) (*session, error) {
        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")
+       defer sessions.Unlock()
+
+       if active := sessions.active; active != nil {
+               active.Lock()
+               running := active.Running
+               active.Unlock()
+               if running {
+                       return nil, errors.New("a sign-in is already waiting 
for the browser")
                }
+               sessions.active = nil
        }
+       forgetFinishedLocked()
+
+       ctx, cancel := context.WithTimeout(context.Background(), loginTimeout)
        started := &session{
                Session: Session{
                        Id:        util.GenerateToken(16),
@@ -105,23 +166,39 @@ func StartLogin(agentId string, executable string, save 
func(Credential) error)
                        Running:   true,
                        StartTime: util.GetCurrentTime(),
                },
-               url: make(chan string, 1),
+               url:    make(chan string, 1),
+               ctx:    ctx,
+               cancel: cancel,
+               done:   make(chan struct{}),
        }
        sessions.byId[started.Id] = started
-       sessions.Unlock()
+       sessions.active = started
+       return started, nil
+}
 
-       go started.run(executable, save)
+// release frees the slot a finished sign-in held, so the next one can start.
+func release(finished *session) {
+       sessions.Lock()
+       defer sessions.Unlock()
+       if sessions.active == finished {
+               sessions.active = nil
+       }
+       forgetFinishedLocked()
+}
 
-       // 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):
+// forgetFinishedLocked drops the sessions nobody is polling any more.
+func forgetFinishedLocked() {
+       for id, kept := range sessions.byId {
+               if kept == sessions.active {
+                       continue
+               }
+               kept.Lock()
+               stale := !kept.Running && time.Since(kept.endedAt) > finishedTtl
+               kept.Unlock()
+               if stale {
+                       delete(sessions.byId, id)
+               }
        }
-       return started.snapshot(), nil
 }
 
 // LoginSession is one sign-in as the page last left it.
@@ -135,13 +212,59 @@ func LoginSession(id string) (Session, bool) {
        return found.snapshot(), true
 }
 
+// CancelLogin ends a sign-in still waiting for a browser nobody is coming back
+// from, freeing the port the agent holds and the slot the next one needs.
+// Cancelling a finished sign-in leaves how it finished alone.
+func CancelLogin(id string) (Session, error) {
+       sessions.Lock()
+       found, ok := sessions.byId[id]
+       sessions.Unlock()
+       if !ok {
+               return Session{}, errors.New("no sign-in was started under this 
id")
+       }
+       return found.stop(errLoginCancelled), nil
+}
+
+// stop ends the agent and the session with it. The wait is bounded: an agent
+// that does not let go of its output must not hold the next sign-in behind it.
+func (s *session) stop(reason error) Session {
+       s.Lock()
+       running := s.Running
+       if running {
+               s.reason = reason
+       }
+       s.Unlock()
+       if !running {
+               return s.snapshot()
+       }
+
+       s.cancel()
+       select {
+       case <-s.done:
+       case <-time.After(stopWait):
+       }
+       return s.finish(nil, reason)
+}
+
 func (s *session) snapshot() Session {
        s.Lock()
        defer s.Unlock()
        return s.Session
 }
 
+func (s *session) stopReason() error {
+       s.Lock()
+       defer s.Unlock()
+       return s.reason
+}
+
 func (s *session) run(executable string, save func(Credential) error) {
+       // The session is finished whichever way this returns, so the slot is 
freed
+       // even down a path that did not say how it ended.
+       defer close(s.done)
+       defer s.finish(nil, errLoginEnded)
+       defer s.cancel()
+
        home, err := os.MkdirTemp("", "casbin-gateway-signin-")
        if err != nil {
                s.finish(nil, err)
@@ -149,13 +272,13 @@ func (s *session) run(executable string, save 
func(Credential) error) {
        }
        defer os.RemoveAll(home)
 
-       ctx, cancel := context.WithTimeout(context.Background(), loginTimeout)
-       defer cancel()
-
-       command := exec.CommandContext(ctx, executable, "login")
+       command := exec.CommandContext(s.ctx, executable, "login")
        command.Env = append(os.Environ(), "CODEX_HOME="+home)
        command.Dir = home
-       hideWindow(command)
+       // The agent goes as a tree: on Windows it is reached through a shim, 
and
+       // killing that alone leaves the process holding the port behind.
+       command.Cancel = func() error { return terminate(command) }
+       prepare(command)
 
        output, err := command.StdoutPipe()
        if err != nil {
@@ -170,8 +293,15 @@ func (s *session) run(executable string, save 
func(Credential) error) {
 
        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"))
+
+       // A stopped agent's exit status says only that it was stopped, so what
+       // stopped it is reported instead.
+       if reason := s.stopReason(); reason != nil {
+               s.finish(nil, reason)
+               return
+       }
+       if errors.Is(s.ctx.Err(), context.DeadlineExceeded) {
+               s.finish(nil, errLoginTimedOut)
                return
        }
 
@@ -214,19 +344,33 @@ func (s *session) readOutput(stream io.Reader) string {
        return tail.String()
 }
 
-func (s *session) finish(credential *Credential, err error) {
+// finish ends the session, once: the first caller there says how it went, and
+// whoever follows - the goroutine behind a cancel, the safety net behind both 
-
+// leaves that answer alone.
+func (s *session) finish(credential *Credential, err error) Session {
        s.Lock()
-       defer s.Unlock()
+       if !s.Running {
+               done := s.Session
+               s.Unlock()
+               return done
+       }
        s.Running = false
+       s.endedAt = time.Now()
        s.EndTime = util.GetCurrentTime()
        if err != nil {
                s.Error = err.Error()
-               return
-       }
-       s.Ok = true
-       if credential != nil {
-               s.Account = credential.Label()
+               s.Cancelled = errors.Is(err, errLoginCancelled)
+       } else {
+               s.Ok = true
+               if credential != nil {
+                       s.Account = credential.Label()
+               }
        }
+       done := s.Session
+       s.Unlock()
+
+       release(s)
+       return done
 }
 
 func keepTail(text string) string {
diff --git a/agentauth/login_other.go b/agentauth/login_other.go
index 5b79310..a72281f 100644
--- a/agentauth/login_other.go
+++ b/agentauth/login_other.go
@@ -16,7 +16,25 @@
 
 package agentauth
 
-import "os/exec"
+import (
+       "os/exec"
+       "syscall"
+)
 
-// hideWindow has nothing to hide anywhere but Windows.
-func hideWindow(*exec.Cmd) {}
+// prepare has no console to hide anywhere but Windows, and puts the agent in a
+// group of its own so stopping it stops what it started.
+func prepare(cmd *exec.Cmd) {
+       cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+}
+
+// terminate takes the whole group: a shim that runs the real program as a 
child
+// would otherwise leave it holding the port the next sign-in listens on.
+func terminate(cmd *exec.Cmd) error {
+       if cmd.Process == nil {
+               return nil
+       }
+       if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
+               return cmd.Process.Kill()
+       }
+       return nil
+}
diff --git a/agentauth/login_test.go b/agentauth/login_test.go
new file mode 100644
index 0000000..841bdb5
--- /dev/null
+++ b/agentauth/login_test.go
@@ -0,0 +1,310 @@
+// 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 (
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+       "time"
+)
+
+// fakeCodexEnv turns the test binary into a stand-in for the agent's own
+// sign-in, so the lifecycle is exercised against a real child process rather
+// than a mock of one. The value picks which sign-in it acts out.
+const fakeCodexEnv = "CASBIN_GATEWAY_FAKE_CODEX"
+
+const (
+       // fakeWaits is the sign-in this fix is about: the agent prints the 
address,
+       // starts listening, and waits for a browser that never comes back.
+       fakeWaits = "waits"
+       // fakeFails is an agent that cannot sign in at all.
+       fakeFails = "fails"
+       // fakeSucceeds is an agent that signs in and writes the credential.
+       fakeSucceeds = "succeeds"
+)
+
+const fakeUrl = "https://auth.example.com/oauth/authorize?state=test";
+
+func TestMain(m *testing.M) {
+       if behaviour := os.Getenv(fakeCodexEnv); behaviour != "" {
+               fakeCodex(behaviour)
+               return
+       }
+       os.Exit(m.Run())
+}
+
+func fakeCodex(behaviour string) {
+       switch behaviour {
+       case fakeWaits:
+               fmt.Println("Open this URL to sign in: " + fakeUrl)
+               // Long enough that only being stopped ends it.
+               time.Sleep(10 * time.Minute)
+       case fakeFails:
+               fmt.Println("gateway could not reach the sign-in service")
+               os.Exit(1)
+       case fakeSucceeds:
+               fmt.Println("Open this URL to sign in: " + fakeUrl)
+               auth := 
`{"tokens":{"access_token":"a","refresh_token":"r"},"last_refresh":null}`
+               if err := os.WriteFile(filepath.Join(os.Getenv("CODEX_HOME"), 
credentialFile), []byte(auth), 0o600); err != nil {
+                       os.Exit(1)
+               }
+       }
+       os.Exit(0)
+}
+
+// startFake runs one sign-in against the stand-in agent.
+func startFake(t *testing.T, behaviour string, save func(Credential) error) 
(Session, error) {
+       t.Helper()
+       t.Setenv(fakeCodexEnv, behaviour)
+       if save == nil {
+               save = func(Credential) error { return nil }
+       }
+       return StartLogin("codex", os.Args[0], save)
+}
+
+// resetSessions clears what earlier tests left, and stops anything still
+// running so a stand-in agent does not outlive the test that started it.
+func resetSessions(t *testing.T) {
+       t.Helper()
+       t.Cleanup(func() {
+               sessions.Lock()
+               started := make([]*session, 0, len(sessions.byId))
+               for _, kept := range sessions.byId {
+                       started = append(started, kept)
+               }
+               sessions.byId = map[string]*session{}
+               sessions.active = nil
+               sessions.Unlock()
+
+               for _, kept := range started {
+                       kept.stop(errLoginCancelled)
+               }
+       })
+}
+
+// awaitFinished polls one session the way the page does.
+func awaitFinished(t *testing.T, id string, within time.Duration) Session {
+       t.Helper()
+       deadline := time.Now().Add(within)
+       for time.Now().Before(deadline) {
+               session, ok := LoginSession(id)
+               if !ok {
+                       t.Fatalf("session %s was forgotten while it was being 
polled", id)
+               }
+               if !session.Running {
+                       return session
+               }
+               time.Sleep(20 * time.Millisecond)
+       }
+       t.Fatalf("session %s was still running after %s", id, within)
+       return Session{}
+}
+
+// A second sign-in started while one is genuinely waiting for the browser is
+// still refused: the agent listens on a fixed port, so two would collide.
+func TestSecondSignInIsRefusedWhileOneWaits(t *testing.T) {
+       resetSessions(t)
+
+       first, err := startFake(t, fakeWaits, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+       if !first.Running || first.Url != fakeUrl {
+               t.Fatalf("StartLogin() = %+v, want it running with the address 
the agent printed", first)
+       }
+
+       if _, err := startFake(t, fakeWaits, nil); err == nil {
+               t.Fatal("StartLogin() while one waits = nil, want it refused")
+       } else if !strings.Contains(err.Error(), "already waiting") {
+               t.Errorf("StartLogin() while one waits = %v, want it to say one 
is already waiting", err)
+       }
+}
+
+// The reported bug: the browser page is closed without signing in, which the
+// agent cannot see. Cancelling has to end it and let the next one start.
+func TestCancelReleasesTheSignInSlot(t *testing.T) {
+       resetSessions(t)
+
+       started, err := startFake(t, fakeWaits, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+
+       cancelled, err := CancelLogin(started.Id)
+       if err != nil {
+               t.Fatalf("CancelLogin() = %v, want it cancelled", err)
+       }
+       if cancelled.Running || cancelled.Ok || !cancelled.Cancelled {
+               t.Fatalf("CancelLogin() = %+v, want a finished, cancelled 
sign-in", cancelled)
+       }
+       if cancelled.Error != errLoginCancelled.Error() {
+               t.Errorf("CancelLogin().Error = %q, want %q", cancelled.Error, 
errLoginCancelled)
+       }
+       if cancelled.EndTime == "" {
+               t.Error("CancelLogin() left no end time")
+       }
+
+       // What the user does next: sign in again.
+       retried, err := startFake(t, fakeWaits, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() after a cancel = %v, want a started 
sign-in", err)
+       }
+       if !retried.Running {
+               t.Errorf("StartLogin() after a cancel = %+v, want it running", 
retried)
+       }
+}
+
+// Cancelling twice, or cancelling something already finished, leaves the
+// outcome alone rather than rewriting it.
+func TestCancelIsIdempotent(t *testing.T) {
+       resetSessions(t)
+
+       started, err := startFake(t, fakeWaits, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+
+       first, err := CancelLogin(started.Id)
+       if err != nil {
+               t.Fatalf("CancelLogin() = %v, want it cancelled", err)
+       }
+       second, err := CancelLogin(started.Id)
+       if err != nil {
+               t.Fatalf("CancelLogin() a second time = %v, want it accepted", 
err)
+       }
+       if second.EndTime != first.EndTime || second.Error != first.Error {
+               t.Errorf("CancelLogin() a second time = %+v, want the first 
outcome %+v", second, first)
+       }
+}
+
+func TestCancelUnknownSession(t *testing.T) {
+       resetSessions(t)
+
+       if _, err := CancelLogin("not-a-session"); err == nil {
+               t.Fatal("CancelLogin() on an unknown id = nil, want an error")
+       }
+}
+
+// An agent that cannot sign in must not hold the slot behind it either.
+func TestFailedSignInReleasesTheSignInSlot(t *testing.T) {
+       resetSessions(t)
+
+       started, err := startFake(t, fakeFails, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+
+       finished := awaitFinished(t, started.Id, 30*time.Second)
+       if finished.Ok || finished.Error == "" {
+               t.Fatalf("a failed sign-in = %+v, want it reported as failed", 
finished)
+       }
+       if finished.Cancelled {
+               t.Errorf("a failed sign-in = %+v, want it not marked 
cancelled", finished)
+       }
+
+       if _, err := startFake(t, fakeWaits, nil); err != nil {
+               t.Fatalf("StartLogin() after a failure = %v, want a started 
sign-in", err)
+       }
+}
+
+// A sign-in that goes through releases the slot and stores what it brought 
back.
+func TestSuccessfulSignInReleasesTheSignInSlot(t *testing.T) {
+       resetSessions(t)
+
+       saved := make(chan Credential, 1)
+       started, err := startFake(t, fakeSucceeds, func(credential Credential) 
error {
+               saved <- credential
+               return nil
+       })
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+
+       finished := awaitFinished(t, started.Id, 30*time.Second)
+       if !finished.Ok || finished.Error != "" {
+               t.Fatalf("a finished sign-in = %+v, want it reported as ok", 
finished)
+       }
+       select {
+       case credential := <-saved:
+               if credential.Kind != KindSubscription {
+                       t.Errorf("saved credential kind = %q, want %q", 
credential.Kind, KindSubscription)
+               }
+       default:
+               t.Error("a finished sign-in saved no credential")
+       }
+
+       if _, err := startFake(t, fakeWaits, nil); err != nil {
+               t.Fatalf("StartLogin() after a success = %v, want a started 
sign-in", err)
+       }
+}
+
+// Nobody has to cancel for an abandoned sign-in to be released: it times out.
+func TestTimeoutReleasesTheSignInSlot(t *testing.T) {
+       resetSessions(t)
+
+       restore := loginTimeout
+       loginTimeout = 500 * time.Millisecond
+       t.Cleanup(func() { loginTimeout = restore })
+
+       started, err := startFake(t, fakeWaits, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+
+       finished := awaitFinished(t, started.Id, 30*time.Second)
+       if finished.Ok || finished.Error != errLoginTimedOut.Error() {
+               t.Fatalf("a timed-out sign-in = %+v, want %q", finished, 
errLoginTimedOut)
+       }
+
+       loginTimeout = restore
+       if _, err := startFake(t, fakeWaits, nil); err != nil {
+               t.Fatalf("StartLogin() after a timeout = %v, want a started 
sign-in", err)
+       }
+}
+
+// A finished sign-in stays readable while the page is still polling it, and is
+// forgotten once nothing is.
+func TestFinishedSessionsAreForgottenEventually(t *testing.T) {
+       resetSessions(t)
+
+       started, err := startFake(t, fakeWaits, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+       if _, err := CancelLogin(started.Id); err != nil {
+               t.Fatalf("CancelLogin() = %v, want it cancelled", err)
+       }
+       if _, ok := LoginSession(started.Id); !ok {
+               t.Fatal("a just-cancelled sign-in was forgotten before the page 
could read it")
+       }
+
+       restore := finishedTtl
+       finishedTtl = -time.Second
+       t.Cleanup(func() { finishedTtl = restore })
+
+       next, err := startFake(t, fakeWaits, nil)
+       if err != nil {
+               t.Fatalf("StartLogin() = %v, want a started sign-in", err)
+       }
+       if _, ok := LoginSession(started.Id); ok {
+               t.Error("a sign-in nobody is polling any more was kept")
+       }
+       if _, ok := LoginSession(next.Id); !ok {
+               t.Error("the running sign-in was forgotten")
+       }
+}
diff --git a/agentauth/login_windows.go b/agentauth/login_windows.go
index 69b49dc..232f3ca 100644
--- a/agentauth/login_windows.go
+++ b/agentauth/login_windows.go
@@ -18,11 +18,29 @@ package agentauth
 
 import (
        "os/exec"
+       "strconv"
        "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) {
+// prepare 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 prepare(cmd *exec.Cmd) {
        cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
 }
+
+// terminate takes the whole tree. Codex is usually reached through a shim that
+// runs the real program as a child, and killing the shim alone would leave 
that
+// child holding the port the next sign-in listens on.
+func terminate(cmd *exec.Cmd) error {
+       if cmd.Process == nil {
+               return nil
+       }
+       kill := exec.Command("taskkill", "/F", "/T", "/PID", 
strconv.Itoa(cmd.Process.Pid))
+       kill.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+       if err := kill.Run(); err != nil {
+               // taskkill fails on a process that is already gone, which is 
the
+               // outcome being asked for anyway.
+               return cmd.Process.Kill()
+       }
+       return nil
+}
diff --git a/controllers/agent_account.go b/controllers/agent_account.go
index 5149f0f..ee23bda 100644
--- a/controllers/agent_account.go
+++ b/controllers/agent_account.go
@@ -290,6 +290,22 @@ func (c *ApiController) GetAgentSignin() {
        c.ResponseOk(session)
 }
 
+// CancelAgentSignin ends a sign-in that is still waiting for the browser.
+// Closing the page is not something the agent can see, so this is what frees
+// the port it holds and lets the next sign-in start.
+func (c *ApiController) CancelAgentSignin() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       session, err := agentauth.CancelLogin(c.Input().Get("id"))
+       if err != nil {
+               c.ResponseError(err.Error())
+               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.
diff --git a/routers/router.go b/routers/router.go
index 052ccb5..a6f1709 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -113,6 +113,7 @@ func initAPI() {
        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/cancel-agent-signin", &controllers.ApiController{}, 
"POST:CancelAgentSignin")
        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 4762044..84ef6d5 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -238,6 +238,12 @@ export function getAgentSignin(id: string) {
   return request<AgentSignin>(`/api/get-agent-signin${query({id: id})}`);
 }
 
+/** Ends a sign-in still waiting for the browser, freeing it for the next one.
+ *  Closing the page is not something the agent can see, so this is what says 
so. */
+export function cancelAgentSignin(id: string) {
+  return request<AgentSignin>(`/api/cancel-agent-signin${query({id: id})}`, 
"POST");
+}
+
 export interface AgentRouting {
   provider: string;
   fallbacks: string[];
diff --git a/web/src/components/AgentAccounts.tsx 
b/web/src/components/AgentAccounts.tsx
index 91f6a89..c945180 100644
--- a/web/src/components/AgentAccounts.tsx
+++ b/web/src/components/AgentAccounts.tsx
@@ -209,8 +209,10 @@ export function AgentSigninDialog({controls}: {controls: 
AgentAccountControls})
           </div>
         ) : null}
 
+        {/* A sign-in somebody ended is not a failure, so it is not shouted at
+            them the way one that went wrong is. */}
         {finished && session.error ? (
-          <Alert variant="destructive">
+          <Alert variant={session.cancelled ? "warning" : "destructive"}>
             <AlertDescription>{session.error}</AlertDescription>
           </Alert>
         ) : null}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 7066593..5c4fe9c 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -1184,14 +1184,21 @@ export function useAgentAccounts(agents: Agent[], 
enabled = true) {
       .then(() => setBusyKey(""));
   }, []);
 
+  // Closing the dialog on a sign-in that is still waiting ends it: the agent
+  // holds a fixed port until something tells it nobody is coming back, and
+  // until it lets go the next sign-in cannot start.
   const closeSession = React.useCallback(
     (signedIn: boolean) => {
+      const closed = session;
       setSession(null);
+      if (closed?.running) {
+        AgentBackend.cancelAgentSignin(closed.id).catch(() => undefined);
+      }
       if (signedIn) {
         load();
       }
     },
-    [load],
+    [load, session],
   );
 
   return {
diff --git a/web/src/types.ts b/web/src/types.ts
index af9be77..72c7009 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -260,6 +260,8 @@ export interface AgentSignin {
   url?: string;
   running: boolean;
   ok: boolean;
+  /** Set when someone ended the sign-in rather than when it went wrong. */
+  cancelled?: boolean;
   /** Who signed in, once someone has. */
   account?: string;
   error?: string;


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

Reply via email to