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]