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]