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 e5ee779  feat(agent): show the signed-in account of each agent
e5ee779 is described below

commit e5ee7797b8b40ef2406b1713bcca6c25725bddd1
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 3 14:53:00 2026 +0800

    feat(agent): show the signed-in account of each agent
---
 agent/account.go                     | 295 +++++++++++++++++++++++++++++++++++
 agent/account_darwin.go              |  24 +++
 agent/account_unix.go                |  24 +++
 agent/account_windows.go             |  25 +++
 agent/agent.go                       |   3 +
 agent/scan_darwin.go                 |   4 +-
 agent/scan_linux.go                  |   4 +-
 agent/scan_windows.go                |   1 +
 web/src/components/AgentGridCard.tsx |  27 +++-
 web/src/locales/en/data.json         |   1 +
 web/src/locales/zh/data.json         |   1 +
 web/src/pages/AgentDetailPage.tsx    |  16 ++
 web/src/types.ts                     |   9 ++
 13 files changed, 431 insertions(+), 3 deletions(-)

diff --git a/agent/account.go b/agent/account.go
new file mode 100644
index 0000000..7ceb7a6
--- /dev/null
+++ b/agent/account.go
@@ -0,0 +1,295 @@
+// 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 agent
+
+import (
+       "bytes"
+       "encoding/base64"
+       "encoding/json"
+       "os"
+       "path/filepath"
+       "strings"
+)
+
+// Account is the cloud account an agent is signed in to, read from the state
+// the agent keeps on disk. Fields are best-effort: an agent may record only an
+// email, or none of these.
+type Account struct {
+       Email string `json:"email,omitempty"`
+       Name  string `json:"name,omitempty"`
+       Plan  string `json:"plan,omitempty"`
+}
+
+// maxAccountFileSize caps reads of the state files accounts are read from.
+const maxAccountFileSize = 8 * 1024 * 1024
+
+// fillAccounts attaches the signed-in account to each installation whose agent
+// keeps one, reading the owner's home directory. It is read per agent id and
+// home, so installations that share both share one read.
+func fillAccounts(installations []Installation, homes []homeDir) {
+       homeByOwner := make(map[string]string, len(homes))
+       for _, home := range homes {
+               if _, ok := homeByOwner[home.owner]; !ok {
+                       homeByOwner[home.owner] = home.path
+               }
+       }
+
+       cache := map[string]*Account{}
+       for i := range installations {
+               kind := accountKind(installations[i].AgentId)
+               if kind == "" {
+                       continue
+               }
+               home := homeByOwner[installations[i].Owner]
+               if home == "" {
+                       continue
+               }
+               key := kind + "\x00" + home
+               account, ok := cache[key]
+               if !ok {
+                       account = readAccount(kind, home)
+                       cache[key] = account
+               }
+               installations[i].Account = account
+       }
+}
+
+// accountKind maps an agent id to the reader that knows where its account is,
+// empty for an agent Gateway cannot read an account from.
+func accountKind(agentId string) string {
+       switch {
+       case agentId == "claude-code":
+               return "claude-code"
+       case agentId == "claude-desktop":
+               return "claude-desktop"
+       case strings.HasPrefix(agentId, "codex"):
+               return "codex"
+       default:
+               return ""
+       }
+}
+
+func readAccount(kind, home string) *Account {
+       switch kind {
+       case "codex":
+               return readCodexAccount(home)
+       case "claude-code":
+               return readClaudeCodeAccount(home)
+       case "claude-desktop":
+               return readClaudeDesktopAccount(home)
+       default:
+               return nil
+       }
+}
+
+// readCodexAccount decodes the profile carried in the JWT that Codex and the
+// ChatGPT desktop app store after a ChatGPT sign-in.
+func readCodexAccount(home string) *Account {
+       data := readCapped(filepath.Join(home, ".codex", "auth.json"))
+       if data == nil {
+               return nil
+       }
+       var auth struct {
+               Tokens struct {
+                       AccessToken string `json:"access_token"`
+                       IDToken     string `json:"id_token"`
+               } `json:"tokens"`
+       }
+       if json.Unmarshal(data, &auth) != nil {
+               return nil
+       }
+
+       account := &Account{}
+       // The id token carries email and name as standard claims, the access 
token
+       // carries them nested in a profile object; either may be present.
+       for _, token := range []string{auth.Tokens.IDToken, 
auth.Tokens.AccessToken} {
+               claims := decodeJWTClaims(token)
+               if claims == nil {
+                       continue
+               }
+               if profile, ok := 
claims["https://api.openai.com/profile";].(map[string]any); ok {
+                       fillString(&account.Email, profile["email"])
+                       fillString(&account.Name, profile["name"])
+               }
+               fillString(&account.Email, claims["email"])
+               fillString(&account.Name, claims["name"])
+               if authClaim, ok := 
claims["https://api.openai.com/auth";].(map[string]any); ok {
+                       fillString(&account.Plan, 
authClaim["chatgpt_plan_type"])
+               }
+       }
+       if account.Email == "" && account.Name == "" {
+               return nil
+       }
+       return account
+}
+
+// fillString sets target from value only when target is still empty and value
+// is a non-empty string.
+func fillString(target *string, value any) {
+       if *target != "" {
+               return
+       }
+       if text, ok := value.(string); ok && text != "" {
+               *target = text
+       }
+}
+
+// readClaudeCodeAccount reads the oauth account Claude Code records in its
+// top-level config file.
+func readClaudeCodeAccount(home string) *Account {
+       data := readCapped(filepath.Join(home, ".claude.json"))
+       if data == nil {
+               return nil
+       }
+       var config struct {
+               OauthAccount struct {
+                       EmailAddress     string `json:"emailAddress"`
+                       DisplayName      string `json:"displayName"`
+                       FullName         string `json:"fullName"`
+                       OrganizationType string `json:"organizationType"`
+               } `json:"oauthAccount"`
+       }
+       if json.Unmarshal(data, &config) != nil {
+               return nil
+       }
+
+       oauth := config.OauthAccount
+       account := &Account{
+               Email: oauth.EmailAddress,
+               Name:  firstNonEmpty(oauth.DisplayName, oauth.FullName),
+               Plan:  strings.TrimPrefix(oauth.OrganizationType, "claude_"),
+       }
+       if account.Email == "" && account.Name == "" {
+               return nil
+       }
+       return account
+}
+
+// readClaudeDesktopAccount pulls the profile the desktop app leaves in its
+// IndexedDB store. The store is a Chromium format with no stable reader while
+// the app holds it open, so the fields are lifted out by their markers.
+func readClaudeDesktopAccount(home string) *Account {
+       dir := claudeDesktopDataDir(home)
+       if dir == "" {
+               return nil
+       }
+       account := &Account{}
+       scanClaudeIndexedDB(filepath.Join(dir, "IndexedDB"), account)
+       if account.Email == "" && account.Name == "" {
+               return nil
+       }
+       return account
+}
+
+// scanClaudeIndexedDB walks the IndexedDB tree looking for the account 
profile,
+// stopping at the first file that carries both an email and a name.
+func scanClaudeIndexedDB(root string, account *Account) {
+       _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) 
error {
+               if err != nil || info.IsDir() || info.Size() > 
maxAccountFileSize {
+                       return nil
+               }
+               data, readErr := os.ReadFile(path)
+               if readErr != nil {
+                       return nil
+               }
+               if account.Email == "" {
+                       account.Email = idbMarkedString(data, "email_address")
+               }
+               if account.Name == "" {
+                       account.Name = idbMarkedString(data, "full_name")
+               }
+               if account.Email != "" && account.Name != "" {
+                       return filepath.SkipAll
+               }
+               return nil
+       })
+}
+
+// idbMarkedString reads the length-prefixed string stored after a field name 
in
+// Chromium's IndexedDB value blobs: the field name, a 0x22 tag, a single 
length
+// byte, then that many bytes of the value.
+func idbMarkedString(data []byte, field string) string {
+       marker := bytes.Index(data, []byte(field))
+       if marker < 0 {
+               return ""
+       }
+       p := marker + len(field)
+       tag := bytes.IndexByte(data[p:min(len(data), p+3)], '"')
+       if tag < 0 {
+               return ""
+       }
+       p += tag + 1
+       if p >= len(data) {
+               return ""
+       }
+       n := int(data[p])
+       p++
+       if n <= 0 || n > 128 || p+n > len(data) {
+               return ""
+       }
+       value := data[p : p+n]
+       for _, b := range value {
+               if b < 0x20 || b >= 0x7f {
+                       return ""
+               }
+       }
+       return string(value)
+}
+
+// decodeJWTClaims returns the claim set of a JWT without verifying it: the
+// account fields are read from local state the user already trusts.
+func decodeJWTClaims(token string) map[string]any {
+       parts := strings.Split(token, ".")
+       if len(parts) != 3 {
+               return nil
+       }
+       payload, err := 
base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
+       if err != nil {
+               return nil
+       }
+       var claims map[string]any
+       if json.Unmarshal(payload, &claims) != nil {
+               return nil
+       }
+       return claims
+}
+
+func readCapped(path string) []byte {
+       info, err := os.Stat(path)
+       if err != nil || !info.Mode().IsRegular() || info.Size() > 
maxAccountFileSize {
+               return nil
+       }
+       data, err := os.ReadFile(path)
+       if err != nil {
+               return nil
+       }
+       return data
+}
+
+func firstNonEmpty(values ...string) string {
+       for _, value := range values {
+               if value != "" {
+                       return value
+               }
+       }
+       return ""
+}
+
+func min(a, b int) int {
+       if a < b {
+               return a
+       }
+       return b
+}
diff --git a/agent/account_darwin.go b/agent/account_darwin.go
new file mode 100644
index 0000000..a4ed5a3
--- /dev/null
+++ b/agent/account_darwin.go
@@ -0,0 +1,24 @@
+// 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 darwin
+
+package agent
+
+import "path/filepath"
+
+// claudeDesktopDataDir is where the macOS desktop app keeps its Electron 
state.
+func claudeDesktopDataDir(home string) string {
+       return filepath.Join(home, "Library", "Application Support", "Claude")
+}
diff --git a/agent/account_unix.go b/agent/account_unix.go
new file mode 100644
index 0000000..41d13d5
--- /dev/null
+++ b/agent/account_unix.go
@@ -0,0 +1,24 @@
+// 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 && !darwin
+
+package agent
+
+import "path/filepath"
+
+// claudeDesktopDataDir is where the Linux desktop app keeps its Electron 
state.
+func claudeDesktopDataDir(home string) string {
+       return filepath.Join(home, ".config", "Claude")
+}
diff --git a/agent/account_windows.go b/agent/account_windows.go
new file mode 100644
index 0000000..ad52e2c
--- /dev/null
+++ b/agent/account_windows.go
@@ -0,0 +1,25 @@
+// 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 agent
+
+import "path/filepath"
+
+// claudeDesktopDataDir is where the Windows desktop app keeps its Electron
+// state, under the roaming profile.
+func claudeDesktopDataDir(home string) string {
+       return filepath.Join(home, "AppData", "Roaming", "Claude")
+}
diff --git a/agent/agent.go b/agent/agent.go
index b5aa980..b58a32f 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -23,6 +23,9 @@ type Installation struct {
        Path          string `json:"path"`
        InstallMethod string `json:"installMethod"`
        Owner         string `json:"owner"`
+       // Account is the cloud account signed in to this agent, when one can 
be read
+       // from its own state. Nil for an agent that stores no account, or none 
yet.
+       Account *Account `json:"account,omitempty"`
 }
 
 // Known is one agent Gateway can detect, whether or not it is installed here.
diff --git a/agent/scan_darwin.go b/agent/scan_darwin.go
index 63236ec..f4bcc97 100644
--- a/agent/scan_darwin.go
+++ b/agent/scan_darwin.go
@@ -65,7 +65,9 @@ func scan(ctx context.Context) []Installation {
        // Last, so that an agent found both on disk and by its port keeps the
        // richer install-layout row when the two resolve to the same 
executable.
        installations = append(installations, scanLocalServers(ctx)...)
-       return 
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+       result := 
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+       fillAccounts(result, homes)
+       return result
 }
 
 func darwinHomes() []homeDir {
diff --git a/agent/scan_linux.go b/agent/scan_linux.go
index 08bbb29..0a9d074 100644
--- a/agent/scan_linux.go
+++ b/agent/scan_linux.go
@@ -71,7 +71,9 @@ func scan(ctx context.Context) []Installation {
        // Last, so that an agent found both on disk and by its port keeps the
        // richer install-layout row when the two resolve to the same 
executable.
        installations = append(installations, scanLocalServers(ctx)...)
-       return 
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+       result := 
expandSharedCodexInstallations(dedupeInstallations(installations), homes)
+       fillAccounts(result, homes)
+       return result
 }
 
 func readHomes(passwdPath string) []homeDir {
diff --git a/agent/scan_windows.go b/agent/scan_windows.go
index 436bc88..e1df2cf 100644
--- a/agent/scan_windows.go
+++ b/agent/scan_windows.go
@@ -85,6 +85,7 @@ func scan(ctx context.Context) []Installation {
                        installations[i].Version = 
executableVersion(installations[i].Path)
                }
        }
+       fillAccounts(installations, homes)
        return installations
 }
 
diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index cc784a4..5018795 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, ChevronRight} from "lucide-react";
+import {Bot, ChevronRight, UserRound} from "lucide-react";
 import i18next from "i18next";
 
 import {AgentIcon} from "@/components/AgentIcon";
@@ -47,6 +47,7 @@ import {providerIdOf, providerProtocol} from 
"@/lib/providers";
 import {formatCost, formatTokens} from "@/lib/usage";
 import type {
   Agent,
+  AgentAccount,
   AgentRuntime,
   AgentUsageStat,
   LlmAgentStat,
@@ -89,6 +90,29 @@ function Metric({
   );
 }
 
+/** The label for a signed-in account: its name, or its email when unnamed. */
+export function accountLabel(account: AgentAccount) {
+  return account.name || account.email || "";
+}
+
+/** The signed-in account of an agent, shown under its path. */
+function AccountLine({account}: {account?: AgentAccount}) {
+  const label = account ? accountLabel(account) : "";
+  if (!label) {
+    return null;
+  }
+  const title = account?.email && account.email !== label ? `${label} · 
${account.email}` : label;
+
+  return (
+    <SimpleTooltip title={title}>
+      <p className="text-muted-foreground mt-0.5 flex items-center gap-1 
truncate text-xs">
+        <UserRound className="size-3 shrink-0" />
+        <span className="truncate">{label}</span>
+      </p>
+    </SimpleTooltip>
+  );
+}
+
 /** What the vendor of the bound provider says is left on the plan. */
 function PlanUsage({quota}: {quota?: ProviderQuota}) {
   // A provider nobody can read a balance from has nothing to show here.
@@ -208,6 +232,7 @@ export function AgentGridCard({
             <SimpleTooltip title={agent.path}>
               <p className="text-muted-foreground truncate font-mono 
text-xs">{agent.path}</p>
             </SimpleTooltip>
+            <AccountLine account={agent.account} />
           </div>
           <RunBadge status={status} />
         </div>
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index c79f3a7..0cf0594 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -18,6 +18,7 @@
     "Username": "Username"
   },
   "agent": {
+    "Account": "Account",
     "Advanced view": "Advanced view",
     "Advanced view hint": "Every installation found, with its processes, path, 
owner and install method.",
     "Agent": "Agent",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 37c8b95..0ebdc1f 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -18,6 +18,7 @@
     "Username": "用户名"
   },
   "agent": {
+    "Account": "账户",
     "Advanced view": "高级视图",
     "Advanced view hint": "扫描到的每个安装,含进程、路径、所有者和安装方式。",
     "Agent": "智能体",
diff --git a/web/src/pages/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index c5e5eb7..749ab59 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 {accountLabel} from "@/components/AgentGridCard";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {InstallOutput, ToolUpgradeConfirmDialog} from 
"@/components/ToolUpgradeConfirmDialog";
 import {DataTable, type Column} from "@/components/shared/data-table";
@@ -496,6 +497,21 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
               </span>
             </InfoRow>
             <InfoRow label={i18next.t("general:Owner")}>{agent.owner}</InfoRow>
+            {agent.account && accountLabel(agent.account) ? (
+              <InfoRow label={i18next.t("agent:Account")}>
+                <span className="flex flex-wrap items-center gap-2">
+                  <span>{accountLabel(agent.account)}</span>
+                  {agent.account.email && agent.account.email !== 
accountLabel(agent.account) ? (
+                    <code className="text-muted-foreground 
text-xs">{agent.account.email}</code>
+                  ) : null}
+                  {agent.account.plan ? (
+                    <Badge variant="muted" className="capitalize">
+                      {agent.account.plan}
+                    </Badge>
+                  ) : null}
+                </span>
+              </InfoRow>
+            ) : null}
             <InfoRow label={i18next.t("general:Path")}>
               <span className="flex items-start gap-1">
                 <code className="min-w-0 flex-1 text-xs">{agent.path}</code>
diff --git a/web/src/types.ts b/web/src/types.ts
index 8ce9225..3d111ce 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -128,6 +128,13 @@ export interface AgentProviderConfig {
   current: string;
 }
 
+/** The cloud account an agent is signed in to, read from its own state. */
+export interface AgentAccount {
+  email?: string;
+  name?: string;
+  plan?: string;
+}
+
 export interface Agent {
   agentId: string;
   name: string;
@@ -135,6 +142,8 @@ export interface Agent {
   installMethod: string;
   owner: string;
   path: string;
+  /** The account signed in to this agent, absent when none can be read. */
+  account?: AgentAccount;
   supported: boolean;
   patched: boolean;
   detail?: string;


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

Reply via email to