You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/08/15 07:59:32 UTC
(casbin-gateway) branch master updated (0022059 -> 9cf8023)
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
from 0022059 feat: add agent patch monitoring (#196)
new 283d1ea fix(agentpatch): resolve the owner home strictly and verify patch targets
new 59b09b4 fix(agentpatch): never wedge unpatch when a config changed after patch
new ce4b11d fix(agentpatch): authenticate agent record ingestion
new 16a8d7d feat(agentpatch): tell operators what patching Claude Code changes
new f800bf5 feat(agentmonitor): make the record window and poll interval configurable
new 2e3a045 perf(agentmonitor): keep status off the poll path and prune stale cursors
new af7f431 fix(web): make the records page reach the whole window and stop it flickering
new 9cf8023 docs: describe how agent monitoring has to be deployed and what it retains
The 8 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
README.md | 70 +++++++++++
agenthook/hook.go | 4 +
agentmonitor/codex.go | 243 +++++++++++++++++++++++++++----------
agentmonitor/monitor.go | 40 ++++--
agentmonitor/openagent.go | 3 +-
agentmonitor/record.go | 5 +
agentmonitor/store.go | 40 +++++-
agentmonitor/transcript_windows.go | 7 +-
agentpatch/claude_code.go | 33 ++++-
agentpatch/claude_desktop.go | 111 ++++++++++++-----
agentpatch/endpoint.go | 19 ++-
agentpatch/home.go | 67 +++++++++-
agentpatch/openclaw.go | 38 +++++-
agentpatch/openclaw_hook.js | 6 +-
agentpatch/state.go | 120 +++++++++++-------
agentpatch/token.go | 187 ++++++++++++++++++++++++++++
conf/app.conf | 9 +-
conf/conf.go | 22 ++++
controllers/agent.go | 74 +++++++++--
main.go | 7 +-
mcpserver/mcpserver.go | 47 ++++---
web/src/AgentRecordsPage.js | 63 ++++++++--
web/src/AgentsPage.js | 6 +-
web/src/locales/en/data.json | 5 +
web/src/locales/zh/data.json | 5 +
25 files changed, 1012 insertions(+), 219 deletions(-)
create mode 100644 agentpatch/token.go
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 01/08: fix(agentpatch): resolve the owner home strictly and verify patch targets
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 283d1eac377dfdab64934641eea62590d3cbfb95
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:01:19 2026 +0800
fix(agentpatch): resolve the owner home strictly and verify patch targets
homeOf fell back to os.UserHomeDir() whenever user.Lookup failed. Lookup
routinely fails for DOMAIN\user on Windows, so patching an installation owned
by another account silently wrote the hook into the home of whichever account
runs Gateway: the operator saw Patched while nothing was collected and an
unrelated settings.json had been rewritten. An unresolvable owner is now an
error, with a fallback only when the owner really is the current account.
PatchAgent and UnpatchAgent also took the agentId/path/owner triple straight
from the request body. Combined with the home lookup that is a write primitive
into any account's home, so the target is now matched against the discovered
installations before it is used.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
agentpatch/home.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++++----
controllers/agent.go | 25 +++++++++++++++++++-
2 files changed, 87 insertions(+), 5 deletions(-)
diff --git a/agentpatch/home.go b/agentpatch/home.go
index 7ae7263..24659ca 100644
--- a/agentpatch/home.go
+++ b/agentpatch/home.go
@@ -15,15 +15,74 @@
package agentpatch
import (
+ "fmt"
"os"
"os/user"
+ "path/filepath"
+ "strings"
)
+// homeOf resolves the home directory of the installation owner. Patching writes
+// into that home, so guessing here would silently modify the wrong account's
+// configuration: an unresolvable owner is an error rather than a fallback to
+// whichever account happens to run Gateway.
func homeOf(target Target) (string, error) {
- if target.Owner != "" {
- if account, err := user.Lookup(target.Owner); err == nil && account.HomeDir != "" {
- return account.HomeDir, nil
+ owner := strings.TrimSpace(target.Owner)
+ if owner == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolve home directory: %w", err)
}
+ return home, nil
}
- return os.UserHomeDir()
+
+ if account, err := lookupAccount(owner); err == nil {
+ if account.HomeDir == "" {
+ return "", fmt.Errorf("account %q has no home directory", owner)
+ }
+ return account.HomeDir, nil
+ }
+
+ // user.Lookup is unreliable for domain-qualified names on Windows and for
+ // directory-backed accounts on Linux, so fall back to the process home only
+ // when the owner really is the account Gateway runs as.
+ if current, err := user.Current(); err == nil && sameAccount(owner, current.Username) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolve home directory of %q: %w", owner, err)
+ }
+ return home, nil
+ }
+ return "", fmt.Errorf("cannot resolve the home directory of %q; run Gateway as that user to patch this installation", owner)
+}
+
+// lookupAccount resolves an owner name, retrying with the bare account name so
+// that DOMAIN\user and user@domain forms also resolve.
+func lookupAccount(owner string) (*user.User, error) {
+ account, err := user.Lookup(owner)
+ if err == nil {
+ return account, nil
+ }
+ if bare := accountName(owner); bare != owner && bare != "" {
+ if account, bareErr := user.Lookup(bare); bareErr == nil {
+ return account, nil
+ }
+ }
+ return nil, err
+}
+
+func sameAccount(left, right string) bool {
+ return strings.EqualFold(accountName(left), accountName(right))
+}
+
+// accountName strips any domain qualifier from DOMAIN\user or user@domain.
+func accountName(value string) string {
+ value = strings.TrimSpace(value)
+ if index := strings.LastIndexAny(value, `\/`); index >= 0 {
+ value = value[index+1:]
+ }
+ if index := strings.Index(value, "@"); index > 0 {
+ value = value[:index]
+ }
+ return filepath.Base(value)
}
diff --git a/controllers/agent.go b/controllers/agent.go
index 1c41af5..cd036ff 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -17,7 +17,9 @@ package controllers
import (
"encoding/json"
"net"
+ "path/filepath"
"strconv"
+ "strings"
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agentmonitor"
@@ -147,13 +149,34 @@ func (c *ApiController) AddAgentRecord() {
c.ResponseOk()
}
+// readAgentPatchTarget resolves the request body against the installations that
+// were actually discovered. Patching writes into the owner's home directory, so
+// an unverified body would let a caller name any account on the host.
func (c *ApiController) readAgentPatchTarget() (agentpatch.Target, bool) {
var target agentpatch.Target
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &target); err != nil {
c.ResponseError(err.Error())
return target, false
}
- return target, true
+
+ installations, err := agent.Scan(false)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return target, false
+ }
+ for _, installation := range installations {
+ if candidate := targetOf(installation); matchesTarget(candidate, target) {
+ return candidate, true
+ }
+ }
+ c.ResponseError("no discovered agent installation matches this target")
+ return target, false
+}
+
+func matchesTarget(discovered, requested agentpatch.Target) bool {
+ return discovered.AgentId == requested.AgentId &&
+ strings.EqualFold(filepath.Clean(discovered.Path), filepath.Clean(requested.Path)) &&
+ strings.EqualFold(discovered.Owner, requested.Owner)
}
func targetOf(installation agent.Installation) agentpatch.Target {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 04/08: feat(agentpatch): tell operators what patching Claude Code changes
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 16a8d7d497110351ecb562f9db6f5021f833ee3b
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:06:36 2026 +0800
feat(agentpatch): tell operators what patching Claude Code changes
claudeCodePatcher was the only patcher without PatchNotice, so its row fell back
to the generic "Enables audit-only monitoring for this agent" and offered no
follow-up. Claude Desktop and OpenClaw both said what they change and that a
restart is needed, which made Claude Code look like it needed nothing, when in
fact sessions already running keep using the settings they loaded at startup.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
agentpatch/claude_code.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go
index 328e2a7..297c9b8 100644
--- a/agentpatch/claude_code.go
+++ b/agentpatch/claude_code.go
@@ -85,6 +85,16 @@ func (claudeCodePatcher) Unpatch(target Target) error {
return RevokeIngestToken(target)
}
+// PatchNotice explains the change in the operator's own words. Every other
+// patcher supplies one, so without it the Claude Code row was the only one in
+// the UI that gave no hint about what changes or whether a restart is needed.
+func (claudeCodePatcher) PatchNotice(patched bool) (string, string) {
+ if patched {
+ return "Removes Gateway's audit-only Claude Code hooks.", "Restart any Claude Code session that is already running."
+ }
+ return "Installs audit-only Claude Code hooks. Hooks observe events and never block an action.", "Restart any Claude Code session that is already running."
+}
+
func (claudeCodePatcher) Status(target Target) (Status, error) {
path, err := claudeCodeConfigPath(target)
if err != nil {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 06/08: perf(agentmonitor): keep status off the poll path and prune stale cursors
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 2e3a0459d1f17b7f2d3271d3e7ce6a827674b201
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:46:36 2026 +0800
perf(agentmonitor): keep status off the poll path and prune stale cursors
poll held the manager mutex for the whole pass, including the directory walk,
the file reads and the record parsing. CodexMonitorStatus needs the same mutex
and the agents page asks for it every few seconds, so reading the UI queued
behind disk IO. Status also walked the sessions directory itself, so displaying
a row cost a second full scan.
The manager now has two locks with a strict order. pollMutex makes the poller
the single writer of cursor state and covers the IO; mutex only guards the maps
and is taken in short bursts. Status answers from a summary the poller records
at the end of each pass, so it performs no IO at all and never touches cursor
fields the poller is writing.
Cursors were also never removed. Rollout directories only grow, so every file
ever seen kept an entry that was serialized back to the state file on each
dirty poll. Cursors whose file is gone are now dropped as part of the pass.
The remaining hardcoded one second tickers move to the configured interval.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
agentmonitor/codex.go | 243 +++++++++++++++++++++++++++----------
agentmonitor/openagent.go | 3 +-
agentmonitor/transcript_windows.go | 7 +-
3 files changed, 185 insertions(+), 68 deletions(-)
diff --git a/agentmonitor/codex.go b/agentmonitor/codex.go
index c63ee02..f2a54ee 100644
--- a/agentmonitor/codex.go
+++ b/agentmonitor/codex.go
@@ -32,7 +32,6 @@ import (
const (
codexMonitorStateFile = "codex-rollout-monitor.json"
- codexPollInterval = time.Second
maxCodexRolloutLine = 8 * 1024 * 1024
)
@@ -66,13 +65,30 @@ type codexSavedState struct {
Cursors map[string]*codexCursor `json:"cursors"`
}
+// codexRootSummary is what one sessions directory looked like after the last
+// poll. Status answers from it so that reading the UI never waits on disk and
+// never touches cursor state the poller is mutating.
+type codexRootSummary struct {
+ agentFiles map[string]int
+ unknown int
+ lastPolled time.Time
+ everSampled bool
+}
+
+// codexMonitorManager owns two locks with a strict order: pollMutex is taken
+// before mutex, never the other way round. pollMutex serializes the file IO and
+// the cursor mutation it performs; mutex only guards the maps, so status stays
+// responsive while a poll is reading disk.
type codexMonitorManager struct {
+ pollMutex sync.Mutex
+
mutex sync.Mutex
statePath string
addRecord func(*Record)
claims map[string]codexClaim
cursors map[string]*codexCursor
lastErr map[string]error
+ summaries map[string]codexRootSummary
dirty bool
stop chan struct{}
done chan struct{}
@@ -87,10 +103,14 @@ func newCodexMonitorManager(statePath string, addRecord func(*Record)) *codexMon
claims: map[string]codexClaim{},
cursors: map[string]*codexCursor{},
lastErr: map[string]error{},
+ summaries: map[string]codexRootSummary{},
}
}
func (manager *codexMonitorManager) start() error {
+ // pollMutex is always taken before mutex; see codexMonitorManager.
+ manager.pollMutex.Lock()
+ defer manager.pollMutex.Unlock()
manager.mutex.Lock()
defer manager.mutex.Unlock()
if manager.stop != nil {
@@ -178,21 +198,32 @@ func (manager *codexMonitorManager) enable(claim codexClaim) error {
return errors.New("agent path, owner and an absolute CODEX_HOME are required")
}
+ // Seeding reads the sessions directory and writes cursors, so it takes
+ // pollMutex to stay the single writer, then mutex for the maps.
+ manager.pollMutex.Lock()
+ defer manager.pollMutex.Unlock()
+
manager.mutex.Lock()
- defer manager.mutex.Unlock()
if manager.statePath == "" {
manager.statePath = monitorStatePath(codexMonitorStateFile)
}
key := codexClaimKey(claim.AgentID, claim.Path, claim.Owner)
- if _, exists := manager.claims[key]; exists {
+ _, exists := manager.claims[key]
+ root := codexSessionsRoot(claim.CodexHome)
+ seeded := manager.hasClaimForRootLocked(root)
+ manager.mutex.Unlock()
+
+ if exists {
return nil
}
- root := codexSessionsRoot(claim.CodexHome)
- if !manager.hasClaimForRootLocked(root) {
- if err := manager.seedRootLocked(root); err != nil {
+ if !seeded {
+ if err := manager.seedRoot(root); err != nil {
return err
}
}
+
+ manager.mutex.Lock()
+ defer manager.mutex.Unlock()
manager.claims[key] = claim
manager.dirty = true
if err := manager.saveLocked(); err != nil {
@@ -208,6 +239,8 @@ func DisableCodexMonitor(agentID, path, ownerName string) error {
}
func (manager *codexMonitorManager) disable(agentID, path, ownerName string) error {
+ manager.pollMutex.Lock()
+ defer manager.pollMutex.Unlock()
manager.mutex.Lock()
defer manager.mutex.Unlock()
key := codexClaimKey(agentID, path, ownerName)
@@ -232,48 +265,42 @@ func CodexMonitorStatus(agentID, path, ownerName string) (bool, string) {
return codexMonitor.status(canonicalAgentID(agentID), path, ownerName)
}
+// status answers entirely from the last poll's summary. It performs no disk IO,
+// so the UI can refresh it as often as it likes without competing with the
+// poller for the session directories.
func (manager *codexMonitorManager) status(agentID, path, ownerName string) (bool, string) {
manager.mutex.Lock()
defer manager.mutex.Unlock()
+
claim, found := manager.claims[codexClaimKey(agentID, path, ownerName)]
if !found {
return false, "not patched"
}
- root := codexSessionsRoot(claim.CodexHome)
- files, exists, err := codexRolloutFiles(root)
- if err == nil {
- err = manager.lastErr[codexPathKey(root)]
- }
- if err != nil {
+ key := codexPathKey(codexSessionsRoot(claim.CodexHome))
+ if err := manager.lastErr[key]; err != nil {
return true, "monitor error: " + err.Error()
}
- if !exists {
- return true, "waiting for activity: sessions directory not found"
+ summary, sampled := manager.summaries[key]
+ if !sampled || !summary.everSampled {
+ return true, "waiting for the first scan of the sessions directory"
}
- if len(files) == 0 {
- return true, "waiting for activity: no rollout files"
+
+ matchingFiles := summary.agentFiles[claim.AgentID]
+ knownFiles := 0
+ for _, count := range summary.agentFiles {
+ knownFiles += count
}
- matchingFiles, knownFiles, unsupportedFiles := 0, 0, 0
- for _, cursor := range manager.cursors {
- if codexPathKey(cursor.Root) != codexPathKey(root) {
- continue
- }
- if cursor.AgentID == "" {
- unsupportedFiles++
- continue
- }
- knownFiles++
- if cursor.AgentID == claim.AgentID {
- matchingFiles++
- }
+ if knownFiles == 0 && summary.unknown > 0 {
+ return true, fmt.Sprintf("unsupported source: %d rollout file(s) did not identify as Codex", summary.unknown)
}
- if knownFiles == 0 && unsupportedFiles > 0 {
- return true, fmt.Sprintf("unsupported source: %d rollout file(s) did not identify as Codex", unsupportedFiles)
+ if knownFiles == 0 {
+ return true, "waiting for activity: no rollout files"
}
if matchingFiles == 0 {
return true, "waiting for activity: no matching source"
}
- return true, fmt.Sprintf("active: monitoring %d rollout file(s)", matchingFiles)
+ return true, fmt.Sprintf("active: monitoring %d rollout file(s); last scan %s",
+ matchingFiles, summary.lastPolled.Format(time.RFC3339))
}
func (manager *codexMonitorManager) startPollerLocked() {
@@ -286,7 +313,7 @@ func (manager *codexMonitorManager) startPollerLocked() {
func (manager *codexMonitorManager) run(stop <-chan struct{}, done chan<- struct{}) {
defer close(done)
manager.poll()
- ticker := time.NewTicker(codexPollInterval)
+ ticker := time.NewTicker(monitorPollInterval())
defer ticker.Stop()
for {
select {
@@ -298,62 +325,144 @@ func (manager *codexMonitorManager) run(stop <-chan struct{}, done chan<- struct
}
}
+// poll rescans every claimed sessions directory. It holds pollMutex for the
+// whole pass so cursor state has a single writer, and takes mutex only in short
+// bursts so the status endpoint is never blocked behind disk IO.
func (manager *codexMonitorManager) poll() {
- manager.mutex.Lock()
- defer manager.mutex.Unlock()
+ manager.pollMutex.Lock()
+ defer manager.pollMutex.Unlock()
- roots := map[string]string{}
- for _, claim := range manager.claims {
- root := codexSessionsRoot(claim.CodexHome)
- roots[codexPathKey(root)] = root
- }
- for key, root := range roots {
+ for key, root := range manager.claimedRoots() {
files, _, err := codexRolloutFiles(root)
if err != nil {
- manager.lastErr[key] = err
+ manager.setRootError(key, err)
continue
}
- delete(manager.lastErr, key)
+ manager.setRootError(key, nil)
+ // Rollout directories only ever grow, so cursors for files that have been
+ // rotated away are dropped here instead of accumulating forever.
+ manager.pruneCursors(root, files)
+
for _, path := range files {
- cursor, err := manager.cursorForFileLocked(root, path, false)
+ cursor, err := manager.cursorForFile(root, path, false)
if err != nil {
- manager.lastErr[key] = err
+ manager.setRootError(key, err)
continue
}
- if err := manager.consumeFileLocked(cursor); err != nil {
- manager.lastErr[key] = err
+ if err := manager.consumeFile(cursor); err != nil {
+ manager.setRootError(key, err)
}
}
+ manager.refreshSummary(key, root)
}
- if manager.dirty {
- if err := manager.saveLocked(); err != nil {
- for key := range roots {
- manager.lastErr[key] = err
- }
+ manager.flushState()
+}
+
+func (manager *codexMonitorManager) claimedRoots() map[string]string {
+ manager.mutex.Lock()
+ defer manager.mutex.Unlock()
+ roots := map[string]string{}
+ for _, claim := range manager.claims {
+ root := codexSessionsRoot(claim.CodexHome)
+ roots[codexPathKey(root)] = root
+ }
+ return roots
+}
+
+func (manager *codexMonitorManager) setRootError(key string, err error) {
+ manager.mutex.Lock()
+ if err == nil {
+ delete(manager.lastErr, key)
+ } else {
+ manager.lastErr[key] = err
+ }
+ manager.mutex.Unlock()
+}
+
+// pruneCursors drops cursors under root whose file no longer exists.
+func (manager *codexMonitorManager) pruneCursors(root string, files []string) {
+ live := make(map[string]struct{}, len(files))
+ for _, path := range files {
+ live[codexPathKey(path)] = struct{}{}
+ }
+
+ manager.mutex.Lock()
+ defer manager.mutex.Unlock()
+ rootKey := codexPathKey(root)
+ for key, cursor := range manager.cursors {
+ if codexPathKey(cursor.Root) != rootKey {
+ continue
+ }
+ if _, found := live[key]; !found {
+ delete(manager.cursors, key)
+ manager.dirty = true
+ }
+ }
+}
+
+// refreshSummary records what status should report for root, so status never
+// reads cursor fields the poller may be writing.
+func (manager *codexMonitorManager) refreshSummary(key, root string) {
+ manager.mutex.Lock()
+ defer manager.mutex.Unlock()
+
+ summary := codexRootSummary{agentFiles: map[string]int{}, lastPolled: time.Now(), everSampled: true}
+ rootKey := codexPathKey(root)
+ for _, cursor := range manager.cursors {
+ if codexPathKey(cursor.Root) != rootKey {
+ continue
+ }
+ if cursor.AgentID == "" {
+ summary.unknown++
+ continue
}
+ summary.agentFiles[cursor.AgentID]++
}
+ manager.summaries[key] = summary
}
-func (manager *codexMonitorManager) seedRootLocked(root string) error {
+func (manager *codexMonitorManager) flushState() {
+ manager.mutex.Lock()
+ defer manager.mutex.Unlock()
+ if !manager.dirty {
+ return
+ }
+ if err := manager.saveLocked(); err != nil {
+ for key := range manager.summaries {
+ manager.lastErr[key] = err
+ }
+ }
+}
+
+// seedRoot skips the rollout history that already exists when a root is first
+// claimed. It must be called with pollMutex held.
+func (manager *codexMonitorManager) seedRoot(root string) error {
files, _, err := codexRolloutFiles(root)
if err != nil {
return err
}
for _, path := range files {
- if _, err := manager.cursorForFileLocked(root, path, true); err != nil {
+ if _, err := manager.cursorForFile(root, path, true); err != nil {
return err
}
}
return nil
}
-func (manager *codexMonitorManager) cursorForFileLocked(root, path string, seedEOF bool) (*codexCursor, error) {
+// cursorForFile resolves the cursor for one rollout file. It must be called
+// with pollMutex held: file metadata is read outside mutex, and only the map
+// lookup and insert take it.
+func (manager *codexMonitorManager) cursorForFile(root, path string, seedEOF bool) (*codexCursor, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
key := codexPathKey(path)
+
+ manager.mutex.Lock()
cursor := manager.cursors[key]
+ manager.mutex.Unlock()
+
if cursor == nil {
meta, err := codexFileHeader(path)
if err != nil {
@@ -363,20 +472,22 @@ func (manager *codexMonitorManager) cursorForFileLocked(root, path string, seedE
Path: path, Root: root, SessionKey: meta.SessionKey, AgentID: meta.AgentID,
Pending: map[string]codexPendingCall{},
}
+ manager.mutex.Lock()
manager.cursors[key] = cursor
manager.dirty = true
+ manager.mutex.Unlock()
}
if seedEOF {
cursor.Offset = info.Size()
resetCodexCursorTurn(cursor)
- manager.dirty = true
+ manager.markDirty()
} else if info.Size() < cursor.Offset {
cursor.Offset = 0
cursor.SessionKey = ""
cursor.AgentID = ""
resetCodexCursorTurn(cursor)
- manager.dirty = true
+ manager.markDirty()
}
if cursor.AgentID == "" && cursor.Offset == 0 && info.Size() > 0 {
meta, err := codexFileHeader(path)
@@ -394,7 +505,15 @@ func resetCodexCursorTurn(cursor *codexCursor) {
cursor.Pending = map[string]codexPendingCall{}
}
-func (manager *codexMonitorManager) consumeFileLocked(cursor *codexCursor) error {
+func (manager *codexMonitorManager) markDirty() {
+ manager.mutex.Lock()
+ manager.dirty = true
+ manager.mutex.Unlock()
+}
+
+// consumeFile reads the bytes appended since the last poll. It must be called
+// with pollMutex held; the read itself deliberately runs without mutex.
+func (manager *codexMonitorManager) consumeFile(cursor *codexCursor) error {
info, err := os.Stat(cursor.Path)
if err != nil {
return err
@@ -422,9 +541,9 @@ func (manager *codexMonitorManager) consumeFileLocked(cursor *codexCursor) error
return nil
}
cursor.Offset += size
- manager.dirty = true
+ manager.markDirty()
if line != nil {
- claim := manager.claimForCursorLocked(cursor)
+ claim := manager.claimForCursor(cursor)
for _, record := range parseCodexRolloutLine(bytes.TrimSpace(line), cursor, claim) {
manager.addRecord(record)
}
@@ -432,7 +551,9 @@ func (manager *codexMonitorManager) consumeFileLocked(cursor *codexCursor) error
}
}
-func (manager *codexMonitorManager) claimForCursorLocked(cursor *codexCursor) *codexClaim {
+func (manager *codexMonitorManager) claimForCursor(cursor *codexCursor) *codexClaim {
+ manager.mutex.Lock()
+ defer manager.mutex.Unlock()
for _, claim := range manager.claims {
if claim.AgentID == cursor.AgentID && codexPathKey(codexSessionsRoot(claim.CodexHome)) == codexPathKey(cursor.Root) {
return &claim
diff --git a/agentmonitor/openagent.go b/agentmonitor/openagent.go
index f06c213..d4f57d1 100644
--- a/agentmonitor/openagent.go
+++ b/agentmonitor/openagent.go
@@ -32,7 +32,6 @@ import (
const (
openAgentMonitorStateFile = "openagent-audit-monitor.json"
- openAgentPollInterval = time.Second
maxOpenAgentAuditLine = 8 * 1024 * 1024
openAgentAgentID = "openagent"
openAgentAuditSubdir = "audit"
@@ -227,7 +226,7 @@ func (manager *openAgentMonitorManager) startPollerLocked() {
func (manager *openAgentMonitorManager) run(stop <-chan struct{}, done chan<- struct{}) {
defer close(done)
manager.poll()
- ticker := time.NewTicker(openAgentPollInterval)
+ ticker := time.NewTicker(monitorPollInterval())
defer ticker.Stop()
for {
select {
diff --git a/agentmonitor/transcript_windows.go b/agentmonitor/transcript_windows.go
index 996e5a1..74d8c92 100644
--- a/agentmonitor/transcript_windows.go
+++ b/agentmonitor/transcript_windows.go
@@ -33,10 +33,7 @@ import (
"github.com/apache/casbin-gateway/auditutil"
)
-const (
- transcriptPollInterval = time.Second
- maxTranscriptLine = 8 * 1024 * 1024
-)
+const maxTranscriptLine = 8 * 1024 * 1024
type transcriptRoot struct {
path string
@@ -187,7 +184,7 @@ func (monitor *transcriptMonitor) Status() transcriptStatus {
func (monitor *transcriptMonitor) run() {
defer close(monitor.done)
- ticker := time.NewTicker(transcriptPollInterval)
+ ticker := time.NewTicker(monitorPollInterval())
defer ticker.Stop()
for {
select {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 07/08: fix(web): make the records page reach the whole window and stop it flickering
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit af7f43111625195ad1a7690f8a61fa7aaf0ed989
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:50:38 2026 +0800
fix(web): make the records page reach the whole window and stop it flickering
The page could only ever show 200 records. getAgentRecords defaulted to that
limit and the caller never passed one, so with a 1000 record window the older
800 were unreachable through the UI with no control to ask for them. The limit
is now a filter like the others, carried in the URL so a view can be shared.
The three second poll also raised the table's loading state on every tick, which
covered the rows being read and left the refresh button spinning forever.
Background refreshes are now silent, and auto refresh can be turned off.
AgentsPage asked for agent:Unpatched, a key that exists in neither locale, so a
successful unpatch rendered the raw key. It now uses translated phrases that
read as status rather than as the internal verb.
The payload block hardcoded a light grey that disappears against a dark
background; it uses a translucent overlay instead.
Records live only in memory, which operators discovered by losing them across a
restart, so the page now says so up front.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
web/src/AgentRecordsPage.js | 63 ++++++++++++++++++++++++++++++++++++--------
web/src/AgentsPage.js | 6 ++---
web/src/locales/en/data.json | 5 ++++
web/src/locales/zh/data.json | 5 ++++
4 files changed, 65 insertions(+), 14 deletions(-)
diff --git a/web/src/AgentRecordsPage.js b/web/src/AgentRecordsPage.js
index ec44637..165d89f 100644
--- a/web/src/AgentRecordsPage.js
+++ b/web/src/AgentRecordsPage.js
@@ -13,7 +13,7 @@
// limitations under the License.
import React, {useCallback, useEffect, useState} from "react";
-import {Alert, Button, Descriptions, Input, Result, Select, Space, Table, Tag, Typography} from "antd";
+import {Alert, Button, Descriptions, Input, Result, Select, Space, Switch, Table, Tag, Typography} from "antd";
import {ReloadOutlined, RobotOutlined} from "@ant-design/icons";
import i18next from "i18next";
import {Link, useHistory, useLocation} from "react-router-dom";
@@ -27,8 +27,17 @@ function monitorAgentId(agentId) {
return agentId === "codex_vscode" || agentId === "codex-vscode" ? "codex-cli" : agentId;
}
+// The server keeps a bounded in-memory window; these are the slices of it the
+// page can ask for. Without a control here the UI could only ever reach the
+// first 200 of the records the backend was holding.
+const limitOptions = [200, 500, 1000, 5000];
+const defaultLimit = 200;
+
+// A translucent overlay rather than a fixed grey, so the payload block stays
+// readable whichever theme the surrounding app is using.
const payloadStyle = {
- background: "#f5f5f5",
+ background: "rgba(127, 127, 127, 0.12)",
+ color: "inherit",
borderRadius: 4,
fontFamily: "monospace",
fontSize: 12,
@@ -97,20 +106,27 @@ export default function AgentRecordsPage({account}) {
const eventType = search.get("eventType") || "";
const outcome = search.get("outcome") || "";
const session = search.get("session") || "";
+ const limit = Number(search.get("limit")) || defaultLimit;
const isAdmin = Setting.isAdminUser(account);
const [agents, setAgents] = useState([]);
const [records, setRecords] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
+ const [autoRefresh, setAutoRefresh] = useState(true);
const [sessionDraft, setSessionDraft] = useState(session);
- const load = useCallback(() => {
+ const load = useCallback((foreground = true) => {
if (!isAdmin) {
return;
}
- setLoading(true);
- AgentBackend.getAgentRecords(agent, eventType, outcome, session).then(res => {
+ // Background refreshes must not raise the table's loading state: the poll
+ // below runs every few seconds and would otherwise cover the rows the
+ // operator is reading, and spin the refresh button permanently.
+ if (foreground) {
+ setLoading(true);
+ }
+ AgentBackend.getAgentRecords(agent, eventType, outcome, session, limit).then(res => {
if (res.status === "ok") {
setRecords(res.data || []);
setError("");
@@ -120,9 +136,11 @@ export default function AgentRecordsPage({account}) {
}).catch(err => {
setError(err.message || String(err));
}).then(() => {
- setLoading(false);
+ if (foreground) {
+ setLoading(false);
+ }
});
- }, [agent, eventType, isAdmin, outcome, session]);
+ }, [agent, eventType, isAdmin, limit, outcome, session]);
useEffect(() => {
if (!isAdmin) {
@@ -141,10 +159,13 @@ export default function AgentRecordsPage({account}) {
return;
}
- load();
- const interval = setInterval(load, 3000);
+ load(true);
+ if (!autoRefresh) {
+ return undefined;
+ }
+ const interval = setInterval(() => load(false), 3000);
return () => clearInterval(interval);
- }, [isAdmin, load]);
+ }, [autoRefresh, isAdmin, load]);
useEffect(() => {
setSessionDraft(session);
@@ -250,8 +271,22 @@ export default function AgentRecordsPage({account}) {
<div style={{padding: "24px"}}>
<Space style={{display: "flex", justifyContent: "space-between", marginBottom: 16}}>
<Title level={3} style={{margin: 0}}>{i18next.t("agent:Agent Records")}</Title>
- <Button icon={<ReloadOutlined />} loading={loading} onClick={load}>{i18next.t("general:Refresh")}</Button>
+ <Space>
+ <Switch
+ checked={autoRefresh}
+ checkedChildren={i18next.t("agent:Auto refresh")}
+ unCheckedChildren={i18next.t("agent:Auto refresh")}
+ onChange={setAutoRefresh}
+ />
+ <Button icon={<ReloadOutlined />} loading={loading} onClick={() => load(true)}>{i18next.t("general:Refresh")}</Button>
+ </Space>
</Space>
+ <Alert
+ type="info"
+ showIcon
+ message={i18next.t("agent:Records are kept in memory only and are lost when Gateway restarts")}
+ style={{marginBottom: 16}}
+ />
<Space wrap style={{marginBottom: 16}}>
<Select
value={agent}
@@ -277,6 +312,12 @@ export default function AgentRecordsPage({account}) {
onChange={value => setFilter("outcome", value)}
style={{width: 160}}
/>
+ <Select
+ value={limit}
+ options={limitOptions.map(value => ({label: i18next.t("agent:Last {count} records").replace("{count}", value), value: value}))}
+ onChange={value => setFilter("limit", value === defaultLimit ? "" : String(value))}
+ style={{width: 170}}
+ />
<Input.Search
allowClear
enterButton={i18next.t("agent:Filter")}
diff --git a/web/src/AgentsPage.js b/web/src/AgentsPage.js
index d6f7172..41bee33 100644
--- a/web/src/AgentsPage.js
+++ b/web/src/AgentsPage.js
@@ -71,11 +71,11 @@ export default function AgentsPage({account}) {
setBusyKey(rowKey(record));
(patched ? AgentBackend.unpatchAgent(target) : AgentBackend.patchAgent(target)).then(res => {
if (res.status === "ok") {
- const success = patched ? "Unpatched" : "Patched";
+ const done = patched ? i18next.t("agent:Monitoring disabled") : i18next.t("agent:Monitoring enabled");
const followup = res.data?.followup || record.followup;
Setting.showMessage("success", followup
- ? `${i18next.t(`agent:${success}`)} ${record.name}. ${followup}`
- : `${i18next.t(`agent:${success}`)} ${record.name}`);
+ ? `${done}: ${record.name}. ${followup}`
+ : `${done}: ${record.name}`);
scan();
} else {
Setting.showMessage("error", res.msg || i18next.t("agent:Failed to update agent patch"));
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 539f782..24dd332 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -14,6 +14,7 @@
"All agents": "All agents",
"All event types": "All event types",
"All outcomes": "All outcomes",
+ "Auto refresh": "Auto refresh",
"Detail": "Detail",
"Duration": "Duration",
"Event": "Event",
@@ -25,8 +26,11 @@
"First activity": "First activity",
"Install Method": "Install Method",
"Last activity": "Last activity",
+ "Last {count} records": "Last {count} records",
"MCP target": "MCP target",
"Model": "Model",
+ "Monitoring disabled": "Monitoring disabled",
+ "Monitoring enabled": "Monitoring enabled",
"No agent records yet - patch an agent to start collecting them": "No agent records yet - patch an agent to start collecting them",
"No agent sessions yet - patch an agent to start collecting them": "No agent sessions yet - patch an agent to start collecting them",
"No supported agents found": "No supported agents found",
@@ -38,6 +42,7 @@
"Payload": "Payload",
"Prompt ID": "Prompt ID",
"Records": "Records",
+ "Records are kept in memory only and are lost when Gateway restarts": "Records are kept in memory only and are lost when Gateway restarts",
"Reported from": "Reported from",
"Scan": "Scan",
"Session": "Session",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index b0d8f2f..ea47c7c 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -14,6 +14,7 @@
"All agents": "全部 Agent",
"All event types": "全部事件类型",
"All outcomes": "全部结果",
+ "Auto refresh": "自动刷新",
"Detail": "详情",
"Duration": "耗时",
"Event": "事件",
@@ -25,8 +26,11 @@
"First activity": "首次活动",
"Install Method": "安装方式",
"Last activity": "最后活动",
+ "Last {count} records": "最近 {count} 条记录",
"MCP target": "MCP 目标",
"Model": "模型",
+ "Monitoring disabled": "已关闭监控",
+ "Monitoring enabled": "已开启监控",
"No agent records yet - patch an agent to start collecting them": "暂无 Agent 记录,请先 Patch Agent 开始采集",
"No agent sessions yet - patch an agent to start collecting them": "暂无 Agent 会话,请先 Patch Agent 开始采集",
"No supported agents found": "未发现支持的 Agent",
@@ -38,6 +42,7 @@
"Payload": "负载",
"Prompt ID": "Prompt ID",
"Records": "记录",
+ "Records are kept in memory only and are lost when Gateway restarts": "记录仅保存在内存中,Gateway 重启后会丢失",
"Reported from": "上报来源",
"Scan": "探测",
"Session": "会话",
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 02/08: fix(agentpatch): never wedge unpatch when a config changed after patch
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 59b09b48ab2d4d173a22a5c7e68ae354b87b3ac0
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:02:43 2026 +0800
fix(agentpatch): never wedge unpatch when a config changed after patch
verifyPatchOwnership refused the whole revert when any patched file no longer
hashed to what Patch wrote. Claude Desktop rewrites claude_desktop_config.json
whenever the user edits their MCP servers, so that condition is reached in
normal use, and because Apply reverts first the installation could then neither
be unpatched nor patched again, with no force option and no guidance.
rollback now restores only the files the patch still owns, leaves externally
changed files exactly as they are, and always clears the tracked state, so a
target can never get stuck. Skipped files are reported as PartialRevertError so
the operator is told what to clean up by hand.
The Claude Desktop patcher no longer backs up the whole config either. It owns
one mcpServers key and adds or deletes exactly that, which is reliable no matter
what else rewrote the file, and it discards manifests left by the old scheme
without restoring them over the current configuration.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
agentpatch/claude_desktop.go | 94 ++++++++++++++++++++++-----------
agentpatch/state.go | 120 ++++++++++++++++++++++++++++---------------
2 files changed, 143 insertions(+), 71 deletions(-)
diff --git a/agentpatch/claude_desktop.go b/agentpatch/claude_desktop.go
index c60326e..c53a284 100644
--- a/agentpatch/claude_desktop.go
+++ b/agentpatch/claude_desktop.go
@@ -39,6 +39,10 @@ func (claudeDesktopPatcher) AgentId() string { return "claude-desktop" }
func (claudeDesktopPatcher) Supported() bool { return true }
+// Patch registers exactly one MCP server entry and leaves the rest of
+// claude_desktop_config.json alone. Claude Desktop rewrites that file whenever
+// the user edits their servers, so a whole-file backup could never be restored
+// safely; owning a single key keeps Patch and Unpatch reliable regardless.
func (p claudeDesktopPatcher) Patch(target Target) error {
configPath, err := p.configPath(target)
if err != nil {
@@ -48,52 +52,85 @@ func (p claudeDesktopPatcher) Patch(target Target) error {
if err != nil {
return err
}
- if err := Apply(target, func(changes *ChangeSet) error {
- if err := changes.MkdirAll(filepath.Dir(configPath)); err != nil {
- return err
- }
- config, err := readJSONObject(changes, configPath)
- if err != nil {
- return err
- }
- servers, exists := config["mcpServers"]
- if !exists {
- servers = map[string]any{}
- config["mcpServers"] = servers
- }
- object, ok := objectAt(servers)
- if !ok {
- return errors.New("mcpServers must be a JSON object")
- }
- object[claudeDesktopServerName] = entry
- updated, err := json.MarshalIndent(config, "", " ")
- if err != nil {
- return err
- }
- return changes.WriteFile(configPath, append(updated, '\n'), 0o600)
- }); err != nil {
+
+ stateMutex.Lock()
+ err = updateClaudeDesktopServers(configPath, func(servers map[string]any) bool {
+ servers[claudeDesktopServerName] = entry
+ return true
+ })
+ stateMutex.Unlock()
+ if err != nil {
return err
}
+
if runtime.GOOS != "windows" {
return nil
}
if err := agentmonitor.EnableCoworkMonitor(target.Path, target.Owner); err != nil {
- _ = Revert(target)
+ _ = p.Unpatch(target)
return err
}
return nil
}
-func (claudeDesktopPatcher) Unpatch(target Target) error {
- if err := Revert(target); err != nil {
+func (p claudeDesktopPatcher) Unpatch(target Target) error {
+ configPath, err := p.configPath(target)
+ if err != nil {
+ return err
+ }
+
+ stateMutex.Lock()
+ err = updateClaudeDesktopServers(configPath, func(servers map[string]any) bool {
+ if _, found := servers[claudeDesktopServerName]; !found {
+ return false
+ }
+ delete(servers, claudeDesktopServerName)
+ return true
+ })
+ // Discard any manifest written by an earlier release that backed up the whole
+ // file, without restoring it over the user's current configuration.
+ discardStateLocked(target)
+ stateMutex.Unlock()
+ if err != nil {
return err
}
+
if runtime.GOOS == "windows" {
return agentmonitor.DisableCoworkMonitor(target.Path)
}
return nil
}
+// updateClaudeDesktopServers applies mutate to the mcpServers object and writes
+// the file back only when mutate reports a change.
+func updateClaudeDesktopServers(configPath string, mutate func(map[string]any) bool) error {
+ config, mode, exists, err := readJSONConfig(configPath)
+ if err != nil {
+ return err
+ }
+
+ servers, found := config["mcpServers"]
+ if !found {
+ servers = map[string]any{}
+ }
+ object, ok := objectAt(servers)
+ if !ok {
+ return errors.New("mcpServers must be a JSON object")
+ }
+ if !mutate(object) {
+ return nil
+ }
+ if len(object) == 0 {
+ delete(config, "mcpServers")
+ } else {
+ config["mcpServers"] = object
+ }
+ if !exists {
+ mode = 0o600
+ }
+ return writeJSONConfig(configPath, config, mode)
+}
+
func (p claudeDesktopPatcher) Status(target Target) (Status, error) {
configPath, err := p.configPath(target)
if err != nil {
@@ -106,9 +143,6 @@ func (p claudeDesktopPatcher) Status(target Target) (Status, error) {
if !exists || !hasClaudeDesktopServer(config) {
return Status{Detail: "MCP recorder is not registered"}, nil
}
- if !IsApplied(target) {
- return Status{Patched: true, Detail: "MCP recorder is registered outside Gateway"}, nil
- }
if runtime.GOOS != "windows" {
return Status{Patched: true, Detail: "MCP recorder registered; restart Claude Desktop to apply it"}, nil
}
diff --git a/agentpatch/state.go b/agentpatch/state.go
index acf9891..1ffabab 100644
--- a/agentpatch/state.go
+++ b/agentpatch/state.go
@@ -18,15 +18,21 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
"github.com/apache/casbin-gateway/conf"
)
+// gatewayEntryName is the identifier Gateway writes into every agent
+// configuration it touches, so operators can find and remove it by hand.
+const gatewayEntryName = "casbin-gateway-agent-monitor"
+
type changeKind string
const (
@@ -130,7 +136,9 @@ func Apply(target Target, apply func(*ChangeSet) error) error {
stateMutex.Lock()
defer stateMutex.Unlock()
- if err := revertLocked(target); err != nil {
+ // A previous patch whose files were edited externally must not block a fresh
+ // one: revertLocked has already cleaned up the tracked state by that point.
+ if err := revertLocked(target); err != nil && !errors.As(err, new(*PartialRevertError)) {
return err
}
changes := &ChangeSet{
@@ -158,18 +166,32 @@ func Revert(target Target) error {
return revertLocked(target)
}
+// revertLocked restores what it can and always clears the tracked state, so an
+// installation can never end up unable to unpatch and unable to re-patch.
func revertLocked(target Target) error {
saved, err := loadManifest(target)
if err != nil || saved == nil {
return err
}
- if err := rollback(saved, backupDir(target)); err != nil {
- return err
+ rollbackErr := rollback(saved, backupDir(target))
+ if rollbackErr != nil && !errors.As(rollbackErr, new(*PartialRevertError)) {
+ return rollbackErr
}
if err := os.Remove(manifestPath(target)); err != nil && !os.IsNotExist(err) {
return err
}
- return os.RemoveAll(backupDir(target))
+ if err := os.RemoveAll(backupDir(target)); err != nil {
+ return err
+ }
+ return rollbackErr
+}
+
+// discardStateLocked drops a target's manifest and backups without restoring
+// them. Patchers that own a precise entry rather than a whole file use it to
+// clear state written by an earlier, backup-based release.
+func discardStateLocked(target Target) {
+ _ = os.Remove(manifestPath(target))
+ _ = os.RemoveAll(backupDir(target))
}
// IsApplied reports whether Gateway has the manifest needed to restore target.
@@ -180,58 +202,74 @@ func IsApplied(target Target) bool {
return err == nil && saved != nil
}
+// rollback restores the files this patch still owns. A file that changed after
+// Patch is left exactly as the operator or the agent left it: refusing the whole
+// unpatch instead would strand the installation, because Apply reverts first and
+// so a stale manifest would block re-patching too. Skipped files are reported so
+// the caller can tell the operator what to clean up by hand.
func rollback(saved *manifest, backups string) error {
- if err := verifyPatchOwnership(saved); err != nil {
- return err
- }
-
var first error
+ var skipped []string
for index := len(saved.Changes) - 1; index >= 0; index-- {
item := saved.Changes[index]
- var err error
- switch item.Kind {
- case changeDir:
+ if item.Kind == changeDir {
_ = os.Remove(item.Path)
- case changeFile:
- if item.Backup == "" {
- err = os.Remove(item.Path)
- if os.IsNotExist(err) {
- err = nil
- }
- } else if content, readErr := os.ReadFile(filepath.Join(backups, item.Backup)); readErr != nil {
- err = readErr
- } else {
- err = os.WriteFile(item.Path, content, item.Mode)
- if err == nil {
- err = os.Chmod(item.Path, item.Mode)
- }
+ continue
+ }
+ if !patchStillOwns(item) {
+ skipped = append(skipped, item.Path)
+ continue
+ }
+
+ var err error
+ if item.Backup == "" {
+ err = os.Remove(item.Path)
+ if os.IsNotExist(err) {
+ err = nil
+ }
+ } else if content, readErr := os.ReadFile(filepath.Join(backups, item.Backup)); readErr != nil {
+ err = readErr
+ } else {
+ err = os.WriteFile(item.Path, content, item.Mode)
+ if err == nil {
+ err = os.Chmod(item.Path, item.Mode)
}
}
if err != nil && first == nil {
first = fmt.Errorf("restore %s: %w", item.Path, err)
}
}
+ if first == nil && len(skipped) != 0 {
+ return &PartialRevertError{Paths: skipped}
+ }
return first
}
-func verifyPatchOwnership(saved *manifest) error {
- for _, item := range saved.Changes {
- if item.Kind != changeFile {
- continue
- }
- content, err := os.ReadFile(item.Path)
- if err != nil {
- return fmt.Errorf("cannot safely unpatch %s: %w", item.Path, err)
- }
- info, err := os.Stat(item.Path)
- if err != nil {
- return fmt.Errorf("cannot safely unpatch %s: %w", item.Path, err)
- }
- if item.PatchedHash == "" || item.PatchedHash != contentHash(content) || item.PatchedMode != info.Mode().Perm() {
- return fmt.Errorf("cannot safely unpatch %s because it changed after Patch", item.Path)
- }
+// PartialRevertError reports files that Unpatch deliberately left untouched
+// because they changed after Patch. The patch state is still cleaned up, so the
+// installation can be patched again.
+type PartialRevertError struct {
+ Paths []string
+}
+
+func (e *PartialRevertError) Error() string {
+ return fmt.Sprintf("monitoring was disabled, but %s changed after Patch and was left unmodified; remove any remaining %q entry by hand",
+ strings.Join(e.Paths, ", "), gatewayEntryName)
+}
+
+// patchStillOwns reports whether a file is byte-for-byte what Patch wrote.
+func patchStillOwns(item change) bool {
+ content, err := os.ReadFile(item.Path)
+ if err != nil {
+ // A file the patch created and something else deleted is already in the
+ // desired post-unpatch state; a file we cannot read is not ours to touch.
+ return os.IsNotExist(err) && item.Backup == ""
}
- return nil
+ info, err := os.Stat(item.Path)
+ if err != nil {
+ return false
+ }
+ return item.PatchedHash != "" && item.PatchedHash == contentHash(content) && item.PatchedMode == info.Mode().Perm()
}
func stateDir() string {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 05/08: feat(agentmonitor): make the record window and poll interval configurable
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit f800bf50362416b76d9df1996d6b6d2b740407a5
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:39:03 2026 +0800
feat(agentmonitor): make the record window and poll interval configurable
RecordCapacity was a const, so the only bound on the live window was 1000
records against a 64 KiB payload cap: a busy host could hold roughly 64 MiB with
no way for an operator to trade retention for memory. The file tailers polled on
a hardcoded one second tick, which is expensive on hosts with a long agent
history and cannot be relaxed.
Both are now read from app.conf as agentRecordCapacity and
agentMonitorPollSeconds, with the poll default relaxed to five seconds. Session
directories only ever grow and are only ever appended to, so a slower tick costs
a little freshness and saves a great deal of disk traffic.
Resizing the ring buffer keeps the most recent records that still fit rather
than discarding the window.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
agentmonitor/monitor.go | 40 ++++++++++++++++++++++++++++++++--------
agentmonitor/store.go | 40 +++++++++++++++++++++++++++++++++++++---
conf/app.conf | 9 +++++++--
conf/conf.go | 22 ++++++++++++++++++++++
main.go | 7 ++++++-
5 files changed, 104 insertions(+), 14 deletions(-)
diff --git a/agentmonitor/monitor.go b/agentmonitor/monitor.go
index 0543fe8..b4d260c 100644
--- a/agentmonitor/monitor.go
+++ b/agentmonitor/monitor.go
@@ -21,27 +21,44 @@ import (
"path/filepath"
"strings"
"sync"
+ "time"
)
-// DefaultStateDir stores monitor declarations and tail cursors. Monitoring
-// records themselves are never written here.
-const DefaultStateDir = "./data/agent-patches"
+const (
+ // DefaultStateDir stores monitor declarations and tail cursors. Monitoring
+ // records themselves are never written here.
+ DefaultStateDir = "./data/agent-patches"
+
+ // DefaultPollInterval is how often file-tailed agents are rescanned. Agent
+ // session directories only ever grow, so polling faster than this costs real
+ // disk traffic for very little extra freshness.
+ DefaultPollInterval = 5 * time.Second
+)
var monitorConfig = struct {
sync.RWMutex
- stateDir string
-}{stateDir: DefaultStateDir}
+ stateDir string
+ pollInterval time.Duration
+}{stateDir: DefaultStateDir, pollInterval: DefaultPollInterval}
-// Configure selects the directory used for declaration and cursor JSON files.
-// It must be called before Start when the default directory is unsuitable.
-func Configure(stateDir string) {
+// Configure selects the directory used for declaration and cursor JSON files,
+// how often local logs are rescanned, and how many records the live window
+// holds. It must be called before Start when the defaults are unsuitable.
+func Configure(stateDir string, pollInterval time.Duration, recordCapacity int) {
monitorConfig.Lock()
if strings.TrimSpace(stateDir) == "" {
monitorConfig.stateDir = DefaultStateDir
} else {
monitorConfig.stateDir = filepath.Clean(stateDir)
}
+ if pollInterval <= 0 {
+ monitorConfig.pollInterval = DefaultPollInterval
+ } else {
+ monitorConfig.pollInterval = pollInterval
+ }
monitorConfig.Unlock()
+
+ SetRecordCapacity(recordCapacity)
}
func monitorStatePath(name string) string {
@@ -51,6 +68,13 @@ func monitorStatePath(name string) string {
return path
}
+func monitorPollInterval() time.Duration {
+ monitorConfig.RLock()
+ interval := monitorConfig.pollInterval
+ monitorConfig.RUnlock()
+ return interval
+}
+
// Start restores persisted monitor declarations and begins polling their local
// append-only logs. Each monitor starts independently.
func Start() error {
diff --git a/agentmonitor/store.go b/agentmonitor/store.go
index d2a1b6b..1659a3b 100644
--- a/agentmonitor/store.go
+++ b/agentmonitor/store.go
@@ -21,8 +21,11 @@ import (
"time"
)
-// RecordCapacity is the fixed live diagnostic window retained by Gateway.
-const RecordCapacity = 1000
+// DefaultRecordCapacity is the live diagnostic window retained by Gateway when
+// no capacity is configured. Records live only in memory, so this value bounds
+// how much a busy host can retain: each record may hold up to
+// auditutil.MaxPayloadBytes of sanitized payload.
+const DefaultRecordCapacity = 1000
// RecordQuery narrows the live diagnostic window. Empty fields match all
// values and a non-positive Limit returns every matching record.
@@ -58,12 +61,43 @@ type Store struct {
sequence uint64
}
-var recordStore = newStore(RecordCapacity)
+var recordStore = newStore(DefaultRecordCapacity)
func newStore(capacity int) *Store {
+ if capacity <= 0 {
+ capacity = DefaultRecordCapacity
+ }
return &Store{entries: make([]storedRecord, capacity)}
}
+// SetRecordCapacity resizes the live window, keeping the most recent records
+// that still fit. It is a no-op when the capacity is unchanged.
+func SetRecordCapacity(capacity int) {
+ recordStore.SetCapacity(capacity)
+}
+
+// SetCapacity resizes the ring buffer in place, preserving arrival order.
+func (store *Store) SetCapacity(capacity int) {
+ if capacity <= 0 {
+ capacity = DefaultRecordCapacity
+ }
+
+ store.mutex.Lock()
+ defer store.mutex.Unlock()
+ if capacity == len(store.entries) {
+ return
+ }
+
+ retained := store.snapshotLocked()
+ if len(retained) > capacity {
+ retained = retained[len(retained)-capacity:]
+ }
+ store.entries = make([]storedRecord, capacity)
+ copy(store.entries, retained)
+ store.count = len(retained)
+ store.next = store.count % capacity
+}
+
// AddRecord appends one event to Gateway's process-local monitoring window.
func AddRecord(record *Record) {
recordStore.Add(record)
diff --git a/conf/app.conf b/conf/app.conf
index 374105b..1cd0a02 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -33,6 +33,11 @@ appMap =
acmeEmail = ""
acmePrivateKey = ""
-; Agent patch manifests, config backups, and file-tail cursors. Agent activity
-; records themselves are held only in memory.
+; Agent patch manifests, config backups, ingest tokens, and file-tail cursors.
+; Agent activity records themselves are held only in memory.
agentPatchStateDir = "./data/agent-patches"
+; How many agent monitoring records to keep in memory. They are never persisted,
+; so this is also the memory bound for the live window.
+agentRecordCapacity = 1000
+; How often to rescan the append-only logs of file-tailed agents, in seconds.
+agentMonitorPollSeconds = 5
diff --git a/conf/conf.go b/conf/conf.go
index 77da0c2..856f3ce 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -133,6 +133,28 @@ func GetAgentPatchStateDir() string {
return dir
}
+// GetAgentRecordCapacity is how many agent monitoring records Gateway keeps in
+// memory. Records are never written to disk, so this value alone bounds the
+// memory the live window can occupy.
+func GetAgentRecordCapacity() int {
+ res, err := strconv.Atoi(GetConfigString("agentRecordCapacity"))
+ if err != nil || res <= 0 {
+ res = 1000
+ }
+ return res
+}
+
+// GetAgentMonitorPollSeconds is how often Gateway rescans the append-only logs
+// of agents that are monitored by tailing files. Larger values trade detection
+// latency for far less disk traffic on hosts with a long agent history.
+func GetAgentMonitorPollSeconds() int {
+ res, err := strconv.Atoi(GetConfigString("agentMonitorPollSeconds"))
+ if err != nil || res <= 0 {
+ res = 5
+ }
+ return res
+}
+
func GetConfigRealDataSourceName(driverName string) string {
var dataSourceName string
if driverName != "mysql" {
diff --git a/main.go b/main.go
index 923e2b6..54d1379 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,7 @@ package main
import (
"fmt"
+ "time"
"github.com/apache/casbin-gateway/agenthook"
"github.com/apache/casbin-gateway/agentmonitor"
@@ -54,7 +55,11 @@ func main() {
run.InitSelfStart()
object.StartMonitorSitesLoop()
- agentmonitor.Configure(conf.GetAgentPatchStateDir())
+ agentmonitor.Configure(
+ conf.GetAgentPatchStateDir(),
+ time.Duration(conf.GetAgentMonitorPollSeconds())*time.Second,
+ conf.GetAgentRecordCapacity(),
+ )
if err := agentmonitor.Start(); err != nil {
beego.Error("agent monitor could not start:", err)
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 03/08: fix(agentpatch): authenticate agent record ingestion
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit ce4b11dc69a3736d5715f84a0b50cee9a7126161
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:05:52 2026 +0800
fix(agentpatch): authenticate agent record ingestion
/api/add-agent-record treated a loopback socket as authorization. That is not a
boundary in either direction. Behind nginx or a Docker port mapping every
external request arrives from 127.0.0.1, and because CORS allows any origin with
credentials, any web page the operator visits can POST to 127.0.0.1:17000
directly. Either one lets an attacker forge activity, and an audit trail that
can be poisoned is worth nothing.
Patch now mints a per-installation token, persists it beside the other patch
state, and passes it to the reporter it configures. Hooks, the MCP recorder and
the OpenClaw handler present it in a header, the endpoint verifies it in
constant time, and Unpatch revokes it so a hook left behind cannot keep
reporting. The token also pins which agent a reporter may speak for.
Requests carrying a forwarding header are refused outright, so loopback means
the peer really is local.
recordsURL now validates httpport instead of formatting whatever it finds,
because an invalid value was being baked into agent configuration files.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
agenthook/hook.go | 4 +
agentmonitor/record.go | 5 ++
agentpatch/claude_code.go | 23 ++++--
agentpatch/claude_desktop.go | 17 +++-
agentpatch/endpoint.go | 19 ++++-
agentpatch/openclaw.go | 38 +++++++--
agentpatch/openclaw_hook.js | 6 +-
agentpatch/token.go | 187 +++++++++++++++++++++++++++++++++++++++++++
controllers/agent.go | 49 +++++++++---
mcpserver/mcpserver.go | 47 ++++++-----
10 files changed, 348 insertions(+), 47 deletions(-)
diff --git a/agenthook/hook.go b/agenthook/hook.go
index b6fff12..3e81664 100644
--- a/agenthook/hook.go
+++ b/agenthook/hook.go
@@ -59,6 +59,7 @@ func Run(args []string, input io.Reader) error {
recordsURL := flags.String("records-url", "", "Gateway agent record endpoint")
agentPath := flags.String("agent-path", "", "agent installation path")
owner := flags.String("user", "", "agent installation owner")
+ ingestToken := flags.String("ingest-token", "", "credential presented to the Gateway record endpoint")
if err := flags.Parse(args); err != nil {
return err
}
@@ -88,6 +89,9 @@ func Run(args []string, input io.Reader) error {
return err
}
request.Header.Set("Content-Type", "application/json")
+ if *ingestToken != "" {
+ request.Header.Set(agentmonitor.IngestTokenHeader, *ingestToken)
+ }
response, err := (&http.Client{Timeout: reportTimeout}).Do(request)
if err != nil {
return err
diff --git a/agentmonitor/record.go b/agentmonitor/record.go
index 7e14554..68390f0 100644
--- a/agentmonitor/record.go
+++ b/agentmonitor/record.go
@@ -21,6 +21,11 @@ import (
"github.com/google/uuid"
)
+// IngestTokenHeader carries the per-installation secret that a hook or MCP
+// recorder presents when reporting a record. It lives here so that the reporter
+// packages and the patcher packages can share it without importing each other.
+const IngestTokenHeader = "X-Casbin-Gateway-Agent-Token"
+
// Record is one observed agent behaviour. It is deliberately independent from
// Gateway's HTTP traffic record and is retained only in the live monitor store.
type Record struct {
diff --git a/agentpatch/claude_code.go b/agentpatch/claude_code.go
index 5a31e0f..328e2a7 100644
--- a/agentpatch/claude_code.go
+++ b/agentpatch/claude_code.go
@@ -72,13 +72,17 @@ func (claudeCodePatcher) Unpatch(target Target) error {
return err
}
config, mode, exists, err := readJSONConfig(path)
- if err != nil || !exists {
+ if err != nil {
return err
}
- if !removeClaudeCodeHooks(config) {
- return nil
+ // The credential is revoked on every unpatch path, including the ones that
+ // change no file, so a stale hook can never keep reporting.
+ if exists && removeClaudeCodeHooks(config) {
+ if err := writeJSONConfig(path, config, mode); err != nil {
+ return err
+ }
}
- return writeJSONConfig(path, config, mode)
+ return RevokeIngestToken(target)
}
func (claudeCodePatcher) Status(target Target) (Status, error) {
@@ -118,13 +122,22 @@ func claudeCodeHookCommand(target Target) (string, []string, error) {
if err != nil {
return "", nil, fmt.Errorf("resolve Gateway executable: %w", err)
}
+ url, err := recordsURL()
+ if err != nil {
+ return "", nil, err
+ }
+ token, err := IssueIngestToken(target)
+ if err != nil {
+ return "", nil, err
+ }
return executable, []string{
agenthook.Subcommand,
agenthook.OwnershipFlag,
"--agent", "claude-code",
- "--records-url", recordsURL(),
+ "--records-url", url,
"--agent-path", target.Path,
"--user", target.Owner,
+ "--ingest-token", token,
}, nil
}
diff --git a/agentpatch/claude_desktop.go b/agentpatch/claude_desktop.go
index c53a284..40f396d 100644
--- a/agentpatch/claude_desktop.go
+++ b/agentpatch/claude_desktop.go
@@ -96,9 +96,11 @@ func (p claudeDesktopPatcher) Unpatch(target Target) error {
}
if runtime.GOOS == "windows" {
- return agentmonitor.DisableCoworkMonitor(target.Path)
+ if err := agentmonitor.DisableCoworkMonitor(target.Path); err != nil {
+ return err
+ }
}
- return nil
+ return RevokeIngestToken(target)
}
// updateClaudeDesktopServers applies mutate to the mcpServers object and writes
@@ -177,6 +179,14 @@ func (p claudeDesktopPatcher) serverEntry(target Target) (map[string]any, error)
if err != nil {
return nil, fmt.Errorf("resolve Gateway executable: %w", err)
}
+ url, err := recordsURL()
+ if err != nil {
+ return nil, err
+ }
+ token, err := IssueIngestToken(target)
+ if err != nil {
+ return nil, err
+ }
return map[string]any{
"command": executable,
"args": []string{
@@ -184,7 +194,8 @@ func (p claudeDesktopPatcher) serverEntry(target Target) (map[string]any, error)
"--agent", p.AgentId(),
"--agent-path", target.Path,
"--user", target.Owner,
- "--records-url", recordsURL(),
+ "--records-url", url,
+ "--ingest-token", token,
},
}, nil
}
diff --git a/agentpatch/endpoint.go b/agentpatch/endpoint.go
index 9a58550..cd5a6e1 100644
--- a/agentpatch/endpoint.go
+++ b/agentpatch/endpoint.go
@@ -16,14 +16,25 @@ package agentpatch
import (
"fmt"
+ "strconv"
+ "strings"
"github.com/apache/casbin-gateway/conf"
)
-func recordsURL() string {
- port := conf.GetConfigString("httpport")
+const defaultHttpPort = "17000"
+
+// recordsURL is the loopback endpoint a patched installation reports to. A
+// malformed httpport would bake an unreachable URL into an agent's config, so
+// it is rejected while the operator can still see the error.
+func recordsURL() (string, error) {
+ port := strings.TrimSpace(conf.GetConfigString("httpport"))
if port == "" {
- port = "17000"
+ port = defaultHttpPort
+ }
+ number, err := strconv.Atoi(port)
+ if err != nil || number <= 0 || number > 65535 {
+ return "", fmt.Errorf("cannot build the agent record endpoint: httpport %q is not a valid port", port)
}
- return fmt.Sprintf("http://127.0.0.1:%s/api/add-agent-record", port)
+ return fmt.Sprintf("http://127.0.0.1:%d/api/add-agent-record", number), nil
}
diff --git a/agentpatch/openclaw.go b/agentpatch/openclaw.go
index 5b25c77..33ce6ce 100644
--- a/agentpatch/openclaw.go
+++ b/agentpatch/openclaw.go
@@ -21,6 +21,8 @@ import (
"os"
"path/filepath"
"strings"
+
+ "github.com/apache/casbin-gateway/agentmonitor"
)
const (
@@ -50,6 +52,14 @@ func (p openclawPatcher) Patch(target Target) error {
if err != nil {
return err
}
+ token, err := IssueIngestToken(target)
+ if err != nil {
+ return err
+ }
+ handler, err := renderOpenclawHandler(target, token)
+ if err != nil {
+ return err
+ }
return Apply(target, func(changes *ChangeSet) error {
if err := changes.MkdirAll(layout.hookDir); err != nil {
return err
@@ -57,7 +67,7 @@ func (p openclawPatcher) Patch(target Target) error {
if err := changes.WriteFile(filepath.Join(layout.hookDir, "HOOK.md"), []byte(openclawHookManifest), 0o644); err != nil {
return err
}
- if err := changes.WriteFile(filepath.Join(layout.hookDir, "handler.js"), []byte(renderOpenclawHandler(target)), 0o644); err != nil {
+ if err := changes.WriteFile(filepath.Join(layout.hookDir, "handler.js"), []byte(handler), 0o644); err != nil {
return err
}
return enableOpenclawHook(changes, layout.configPath)
@@ -65,7 +75,10 @@ func (p openclawPatcher) Patch(target Target) error {
}
func (openclawPatcher) Unpatch(target Target) error {
- return Revert(target)
+ if err := Revert(target); err != nil {
+ return err
+ }
+ return RevokeIngestToken(target)
}
func (p openclawPatcher) Status(target Target) (Status, error) {
@@ -116,10 +129,23 @@ func (p openclawPatcher) layoutOf(target Target) (openclawLayout, error) {
}, nil
}
-func renderOpenclawHandler(target Target) string {
- handler := strings.ReplaceAll(openclawHookHandler, "__CASBIN_GATEWAY_RECORDS_URL__", jsonString(recordsURL()))
- handler = strings.ReplaceAll(handler, "__CASBIN_GATEWAY_AGENT_PATH__", jsonString(target.Path))
- return strings.ReplaceAll(handler, "__CASBIN_GATEWAY_OWNER__", jsonString(target.Owner))
+func renderOpenclawHandler(target Target, ingestToken string) (string, error) {
+ url, err := recordsURL()
+ if err != nil {
+ return "", err
+ }
+ replacements := map[string]string{
+ "__CASBIN_GATEWAY_RECORDS_URL__": jsonString(url),
+ "__CASBIN_GATEWAY_AGENT_PATH__": jsonString(target.Path),
+ "__CASBIN_GATEWAY_OWNER__": jsonString(target.Owner),
+ "__CASBIN_GATEWAY_INGEST_TOKEN__": jsonString(ingestToken),
+ "__CASBIN_GATEWAY_INGEST_TOKEN_HEADER__": jsonString(agentmonitor.IngestTokenHeader),
+ }
+ handler := openclawHookHandler
+ for placeholder, value := range replacements {
+ handler = strings.ReplaceAll(handler, placeholder, value)
+ }
+ return handler, nil
}
func enableOpenclawHook(changes *ChangeSet, configPath string) error {
diff --git a/agentpatch/openclaw_hook.js b/agentpatch/openclaw_hook.js
index 13c3d02..4150450 100644
--- a/agentpatch/openclaw_hook.js
+++ b/agentpatch/openclaw_hook.js
@@ -15,6 +15,8 @@
const RECORDS_URL = __CASBIN_GATEWAY_RECORDS_URL__;
const AGENT_PATH = __CASBIN_GATEWAY_AGENT_PATH__;
const OWNER = __CASBIN_GATEWAY_OWNER__;
+const INGEST_TOKEN = __CASBIN_GATEWAY_INGEST_TOKEN__;
+const INGEST_TOKEN_HEADER = __CASBIN_GATEWAY_INGEST_TOKEN_HEADER__;
const POST_TIMEOUT_MS = 3000;
const MAX_OBJECT_CHARS = 64 * 1024;
@@ -46,9 +48,11 @@ function objectFor(context) {
function post(record) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
+ const headers = { "Content-Type": "application/json" };
+ if (INGEST_TOKEN) headers[INGEST_TOKEN_HEADER] = INGEST_TOKEN;
fetch(RECORDS_URL, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers,
body: JSON.stringify(record),
signal: controller.signal,
}).catch(() => {}).finally(() => clearTimeout(timer));
diff --git a/agentpatch/token.go b/agentpatch/token.go
new file mode 100644
index 0000000..423ebdc
--- /dev/null
+++ b/agentpatch/token.go
@@ -0,0 +1,187 @@
+// 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 agentpatch
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+const ingestTokenFile = "ingest-tokens.json"
+
+// ingestToken is the credential one patched installation presents when it
+// reports a record.
+type ingestToken struct {
+ Token string `json:"token"`
+ AgentId string `json:"agentId"`
+ CreatedTime string `json:"createdTime"`
+}
+
+type ingestTokenStore struct {
+ mutex sync.Mutex
+ loaded bool
+ tokens map[string]ingestToken
+}
+
+// tokens is guarded by its own mutex: patchers call into it while they already
+// hold stateMutex, so it must never reach back for that lock.
+var tokens = &ingestTokenStore{tokens: map[string]ingestToken{}}
+
+// IssueIngestToken returns the reporting credential for target, minting and
+// persisting a new one when the target has none. Re-patching an installation
+// deliberately keeps the existing token so that a hook left behind by a crashed
+// patch run does not start failing.
+func IssueIngestToken(target Target) (string, error) {
+ return tokens.issue(target)
+}
+
+// RevokeIngestToken invalidates the credential of one installation. Records
+// reported with it afterwards are rejected.
+func RevokeIngestToken(target Target) error {
+ return tokens.revoke(target)
+}
+
+// ValidateIngestToken reports whether presented matches a live installation
+// credential, and returns the agent it was issued for.
+func ValidateIngestToken(presented string) (string, bool) {
+ return tokens.validate(presented)
+}
+
+func (store *ingestTokenStore) issue(target Target) (string, error) {
+ store.mutex.Lock()
+ defer store.mutex.Unlock()
+ if err := store.loadLocked(); err != nil {
+ return "", err
+ }
+
+ key := targetKey(target)
+ if existing, found := store.tokens[key]; found && existing.Token != "" {
+ return existing.Token, nil
+ }
+ secret, err := newSecret()
+ if err != nil {
+ return "", err
+ }
+ store.tokens[key] = ingestToken{
+ Token: secret,
+ AgentId: target.AgentId,
+ CreatedTime: time.Now().Format(time.RFC3339),
+ }
+ if err := store.saveLocked(); err != nil {
+ delete(store.tokens, key)
+ return "", err
+ }
+ return secret, nil
+}
+
+func (store *ingestTokenStore) revoke(target Target) error {
+ store.mutex.Lock()
+ defer store.mutex.Unlock()
+ if err := store.loadLocked(); err != nil {
+ return err
+ }
+
+ key := targetKey(target)
+ previous, found := store.tokens[key]
+ if !found {
+ return nil
+ }
+ delete(store.tokens, key)
+ if err := store.saveLocked(); err != nil {
+ store.tokens[key] = previous
+ return err
+ }
+ return nil
+}
+
+func (store *ingestTokenStore) validate(presented string) (string, bool) {
+ if presented == "" {
+ return "", false
+ }
+
+ store.mutex.Lock()
+ defer store.mutex.Unlock()
+ if err := store.loadLocked(); err != nil {
+ return "", false
+ }
+ for _, issued := range store.tokens {
+ if subtle.ConstantTimeCompare([]byte(issued.Token), []byte(presented)) == 1 {
+ return issued.AgentId, true
+ }
+ }
+ return "", false
+}
+
+func (store *ingestTokenStore) loadLocked() error {
+ if store.loaded {
+ return nil
+ }
+ data, err := os.ReadFile(ingestTokenPath())
+ if os.IsNotExist(err) {
+ store.loaded = true
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ var saved struct {
+ Tokens map[string]ingestToken `json:"tokens"`
+ }
+ if err := json.Unmarshal(data, &saved); err != nil {
+ return fmt.Errorf("parse agent ingest tokens: %w", err)
+ }
+ if saved.Tokens != nil {
+ store.tokens = saved.Tokens
+ }
+ store.loaded = true
+ return nil
+}
+
+func (store *ingestTokenStore) saveLocked() error {
+ path := ingestTokenPath()
+ if len(store.tokens) == 0 {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(map[string]any{"tokens": store.tokens}, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(data, '\n'), 0o600)
+}
+
+func ingestTokenPath() string {
+ return filepath.Join(stateDir(), ingestTokenFile)
+}
+
+func newSecret() (string, error) {
+ buffer := make([]byte, 32)
+ if _, err := rand.Read(buffer); err != nil {
+ return "", fmt.Errorf("generate agent ingest token: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(buffer), nil
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index cd036ff..8225189 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -120,18 +120,20 @@ func (c *ApiController) GetAgentSessions() {
c.ResponseOk(agentmonitor.ListSessions(agentmonitor.RecordQuery{}))
}
-// AddAgentRecord accepts reports from a hook or MCP process launched locally
-// by Gateway. Those processes have no browser session, so loopback is the
-// authorization boundary.
+// AddAgentRecord accepts reports from a hook or MCP process launched locally by
+// Gateway. Those processes have no browser session, so they authenticate with
+// the per-installation credential issued at Patch time. Loopback alone is not a
+// trust boundary: behind a reverse proxy every caller looks local, and any web
+// page the operator visits can reach 127.0.0.1.
func (c *ApiController) AddAgentRecord() {
- remoteAddr := c.Ctx.Request.RemoteAddr
- host, _, err := net.SplitHostPort(remoteAddr)
- if err != nil {
- host = remoteAddr
+ ip, ok := c.directLoopbackClient()
+ if !ok {
+ c.ResponseError("agent record ingestion is limited to direct loopback requests")
+ return
}
- ip := net.ParseIP(host)
- if ip == nil || !ip.IsLoopback() {
- c.ResponseError("agent record ingestion is limited to loopback")
+ agentId, ok := agentpatch.ValidateIngestToken(c.Ctx.Input.Header(agentmonitor.IngestTokenHeader))
+ if !ok {
+ c.ResponseError("agent record ingestion requires a valid installation token")
return
}
@@ -144,11 +146,38 @@ func (c *ApiController) AddAgentRecord() {
c.ResponseError("agent is required")
return
}
+ // The token decides which agent a reporter may speak for, so a compromised
+ // hook cannot attribute its activity to a different installation.
+ if agentId != "" && !strings.EqualFold(record.Agent, agentId) {
+ c.ResponseError("agent does not match the installation this token was issued for")
+ return
+ }
record.ClientIp = ip.String()
agentmonitor.AddRecord(&record)
c.ResponseOk()
}
+// directLoopbackClient reports the peer address, rejecting anything that was
+// relayed by a proxy. A forwarding header means the real client is remote even
+// though the socket is local.
+func (c *ApiController) directLoopbackClient() (net.IP, bool) {
+ for _, header := range []string{"X-Forwarded-For", "X-Real-Ip", "Forwarded"} {
+ if c.Ctx.Input.Header(header) != "" {
+ return nil, false
+ }
+ }
+ remoteAddr := c.Ctx.Request.RemoteAddr
+ host, _, err := net.SplitHostPort(remoteAddr)
+ if err != nil {
+ host = remoteAddr
+ }
+ ip := net.ParseIP(host)
+ if ip == nil || !ip.IsLoopback() {
+ return nil, false
+ }
+ return ip, true
+}
+
// readAgentPatchTarget resolves the request body against the installations that
// were actually discovered. Patching writes into the owner's home directory, so
// an unverified body would let a caller name any account on the host.
diff --git a/mcpserver/mcpserver.go b/mcpserver/mcpserver.go
index 1d09e27..e7a81f1 100644
--- a/mcpserver/mcpserver.go
+++ b/mcpserver/mcpserver.go
@@ -63,28 +63,30 @@ var tools = []map[string]any{{
// Server serves one MCP stdio session.
type Server struct {
- in io.Reader
- out io.Writer
- agentID string
- agentPath string
- user string
- recordsURL string
- client *http.Client
+ in io.Reader
+ out io.Writer
+ agentID string
+ agentPath string
+ user string
+ recordsURL string
+ ingestToken string
+ client *http.Client
queue chan *agentmonitor.Record
reporting sync.WaitGroup
}
-func newServer(in io.Reader, out io.Writer, agentID, agentPath, user, recordsURL string) *Server {
+func newServer(in io.Reader, out io.Writer, agentID, agentPath, user, recordsURL, ingestToken string) *Server {
return &Server{
- in: in,
- out: out,
- agentID: agentID,
- agentPath: agentPath,
- user: user,
- recordsURL: recordsURL,
- client: &http.Client{Timeout: reportTimeout},
- queue: make(chan *agentmonitor.Record, reportQueueDepth),
+ in: in,
+ out: out,
+ agentID: agentID,
+ agentPath: agentPath,
+ user: user,
+ recordsURL: recordsURL,
+ ingestToken: ingestToken,
+ client: &http.Client{Timeout: reportTimeout},
+ queue: make(chan *agentmonitor.Record, reportQueueDepth),
}
}
@@ -110,10 +112,11 @@ func Run(args []string) error {
agentID := flags.String("agent", "claude-desktop", "agent that launched this server")
agentPath := flags.String("agent-path", "", "agent installation path")
user := flags.String("user", "", "agent installation owner")
+ ingestToken := flags.String("ingest-token", "", "credential presented to the Gateway record endpoint")
if err := flags.Parse(args); err != nil {
return err
}
- return newServer(os.Stdin, os.Stdout, *agentID, *agentPath, *user, *recordsURL).Serve()
+ return newServer(os.Stdin, os.Stdout, *agentID, *agentPath, *user, *recordsURL, *ingestToken).Serve()
}
// Serve processes line-delimited JSON-RPC messages until the client closes
@@ -240,7 +243,15 @@ func (s *Server) startReporter() {
if err != nil {
continue
}
- response, err := s.client.Post(s.recordsURL, "application/json", bytes.NewReader(body))
+ request, err := http.NewRequest(http.MethodPost, s.recordsURL, bytes.NewReader(body))
+ if err != nil {
+ continue
+ }
+ request.Header.Set("Content-Type", "application/json")
+ if s.ingestToken != "" {
+ request.Header.Set(agentmonitor.IngestTokenHeader, s.ingestToken)
+ }
+ response, err := s.client.Do(request)
if err == nil {
_ = response.Body.Close()
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org
(casbin-gateway) 08/08: docs: describe how agent monitoring has to be deployed and what it retains
Posted by hs...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 9cf80235f50e78c435ca36cbee09389adbc80b14
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sat Aug 15 14:52:19 2026 +0800
docs: describe how agent monitoring has to be deployed and what it retains
Agent monitoring shipped without documentation, and three of its properties are
surprising enough that operators only discover them by being bitten.
It is not a service you deploy once for a fleet. It reads and writes files owned
by a particular account on a particular machine, so it must run natively on the
same host as the agent and as that user. Under Docker, which is how the rest of
the project is deployed, the Agents page is simply empty, and there is nothing
on the page to explain why.
Records are held in memory and lost on restart. That makes this a live
diagnostic window rather than an audit log, and it should be stated before
someone relies on it for retention or evidence.
Different agents are monitored by different mechanisms and do not emit the same
event types, so a filter can legitimately return nothing. The table says which
agent yields what, and which ones are observed without touching their config.
Also documents the new configuration keys and the privacy question, since
payloads contain prompts, commands and file contents that any Gateway admin can
read.
Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
---
README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 70 insertions(+)
diff --git a/README.md b/README.md
index e07db2b..cc6206d 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,76 @@ Casbin Gateway uses Casdoor to manage members. If you want to log in with oauth,
Casbin Gateway uses Casdoor to upload files to cloud storage, send Emails and send SMSs. See Casdoor for more details.
+## Agent monitoring
+
+The Agents page discovers AI coding agents installed on the host and can enable
+audit-only monitoring for the ones Gateway supports. Monitoring never blocks an
+agent action; it only observes and records.
+
+### Where this has to run
+
+Unlike the rest of Casbin Gateway, agent monitoring is not a service you deploy
+once for a fleet. It reads and writes files that belong to a specific user
+account on a specific machine, so it only works when Gateway runs:
+
+- **on the same host as the agent**, natively rather than in a container. A
+ container cannot see the host's installed agents or its home directories, so
+ the Agents page will simply be empty under Docker or Kubernetes.
+- **as the user who owns the installation**, or as an account that can resolve
+ that user and write into their home directory. Gateway refuses to patch an
+ installation whose owner it cannot resolve rather than guessing.
+
+Patching Claude Code writes the absolute path of the running Gateway binary into
+`~/.claude/settings.json`. Moving or reinstalling Gateway invalidates that path,
+so unpatch before relocating the binary and patch again afterwards.
+
+### What is recorded, and for how long
+
+Records are held **in memory only**. They are never written to disk, and every
+record is lost when Gateway restarts. This is a live diagnostic window, not a
+durable audit log; do not rely on it for retention or evidence.
+
+The window holds `agentRecordCapacity` records (1000 by default), each capped at
+64 KiB of sanitized payload, so it bounds its own memory use. Credentials are
+redacted before a record is stored, and writes to files such as `.env`, `.ssh/`
+and `*.pem` have their content removed while keeping the path.
+
+Payloads still contain prompts, commands and file contents. Anyone with admin
+access to Gateway can read them, which has obvious privacy implications for the
+people whose agents are monitored — decide whether that is acceptable, and
+whether those people should be told, before enabling this in an organization.
+
+### What each agent reports
+
+Agents are monitored by different mechanisms, so they do not all produce the same
+events. Filtering for an event type an agent never emits returns nothing.
+
+| Agent | Mechanism | Changes the agent's config | Events |
+|-------|-----------|----------------------------|--------|
+| Claude Code | Command hooks in `settings.json` | Yes | session, prompt, tool, mcp, permission, subagent, compact |
+| Claude Desktop | MCP recorder registration | Yes (one `mcpServers` entry) | session, mcp; plus tool and mcp from Cowork transcripts on Windows |
+| Codex, Codex CLI, Codex for VS Code | Tails rollout JSONL | No | llm, tool, mcp, session |
+| OpenAgent | Tails audit JSONL | No | llm, tool, mcp, session |
+| OpenClaw | Hook handler in `~/.openclaw` | Yes | tool, session |
+| Cursor, Cursor Agent, Windsurf | Not implemented yet | — | — |
+
+Agents that are monitored by tailing files need no configuration change, so
+unpatching them leaves nothing behind. For the others, Gateway removes only the
+entry it added; if the file changed after patching, the entry it could not
+safely remove is named in the error so it can be deleted by hand.
+
+### Configuration
+
+| Key | Default | Meaning |
+|-----|---------|---------|
+| `agentPatchStateDir` | `./data/agent-patches` | Patch manifests, config backups, ingest tokens and tail cursors |
+| `agentRecordCapacity` | `1000` | Records kept in the in-memory window |
+| `agentMonitorPollSeconds` | `5` | How often file-tailed agents are rescanned |
+
+Patched agents report over loopback to `/api/add-agent-record`, authenticating
+with a token issued for that installation. Gateway rejects reports that arrive
+through a proxy, so do not expose this endpoint through a reverse proxy.
+
## Contribute
For Casbin Gateway, if you have any questions, you can open Issues, or you can also directly start Pull Requests(but we recommend opening issues first to communicate with the community).
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org