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 ffcc923  feat(monitor): monitor dsh through a session-bus plugin
ffcc923 is described below

commit ffcc9239480435ee56d79fb4cd89878002342870
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 11:06:19 2026 +0800

    feat(monitor): monitor dsh through a session-bus plugin
---
 agenthome/home.go         |   6 +
 agentpatch/dsh.go         | 330 ++++++++++++++++++++++++++++++++++++++++++++++
 agentpatch/dsh_plugin.mjs | 197 +++++++++++++++++++++++++++
 3 files changed, 533 insertions(+)

diff --git a/agenthome/home.go b/agenthome/home.go
index d2323c3..6d76543 100644
--- a/agenthome/home.go
+++ b/agenthome/home.go
@@ -74,6 +74,12 @@ func lookupAccount(owner string) (*user.User, error) {
        return nil, err
 }
 
+// SameAccount reports whether two owner names name the same account, ignoring
+// any DOMAIN\ or @domain qualifier one of them carries.
+func SameAccount(left, right string) bool {
+       return sameAccount(left, right)
+}
+
 func sameAccount(left, right string) bool {
        return strings.EqualFold(accountName(left), accountName(right))
 }
diff --git a/agentpatch/dsh.go b/agentpatch/dsh.go
new file mode 100644
index 0000000..2b594e7
--- /dev/null
+++ b/agentpatch/dsh.go
@@ -0,0 +1,330 @@
+// 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 (
+       _ "embed"
+       "errors"
+       "fmt"
+       "net/url"
+       "os"
+       "os/user"
+       "path/filepath"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/agentmonitor"
+       "github.com/apache/casbin-gateway/internal/yamledit"
+       "gopkg.in/yaml.v3"
+)
+
+const (
+       // dshPluginFile is the telemetry backend Gateway owns. It sits beside 
the
+       // profiles' shared node_modules so that its import of the harness's own
+       // telemetry package resolves, and carries the extension that says it 
is an
+       // ES module: the directory has no package.json to say so, and Node 
warns
+       // into the operator's own dsh output when it has to guess.
+       dshPluginFile = gatewayEntryName + ".mjs"
+)
+
+//go:embed dsh_plugin.mjs
+var dshPluginSource string
+
+type dshPatcher struct{}
+
+func init() {
+       register(dshPatcher{})
+}
+
+func (dshPatcher) AgentId() string { return "dsh" }
+
+func (dshPatcher) Supported() bool { return true }
+
+func (p dshPatcher) Patch(target Target) error {
+       layout, err := p.layoutOf(target)
+       if err != nil {
+               return err
+       }
+       plugin, err := renderDshPlugin(target)
+       if err != nil {
+               return err
+       }
+
+       // The plugin file is the patch's own, so it is written through the 
change
+       // set and put back by it. The patch list is shared with the MCP 
servers an
+       // operator adds, so the entry in it is written last and removed by 
name:
+       // a failure here rolls the plugin file back before this returns.
+       return Apply(target, func(changes *ChangeSet) error {
+               if err := changes.MkdirAll(filepath.Dir(layout.pluginPath)); 
err != nil {
+                       return err
+               }
+               if err := changes.WriteFile(layout.pluginPath, []byte(plugin), 
0o644); err != nil {
+                       return err
+               }
+               return setDshEntry(layout, true)
+       })
+}
+
+func (p dshPatcher) Unpatch(target Target) error {
+       layout, err := p.layoutOf(target)
+       if err != nil {
+               return err
+       }
+       if err := setDshEntry(layout, false); err != nil {
+               return err
+       }
+       if err := Revert(target); err != nil {
+               return err
+       }
+       return RevokeIngestToken(monitorTarget(target))
+}
+
+func (p dshPatcher) Status(target Target) (Status, error) {
+       layout, err := p.layoutOf(target)
+       if err != nil {
+               return Status{}, err
+       }
+
+       data, err := os.ReadFile(layout.pluginPath)
+       if err != nil {
+               if os.IsNotExist(err) {
+                       return Status{Detail: "dsh plugin is not installed"}, 
nil
+               }
+               return Status{}, err
+       }
+       entry, err := dshEntryName(layout)
+       if err != nil {
+               return Status{}, err
+       }
+       if entry == "" {
+               return Status{Detail: "dsh plugin is installed but not listed 
in cordis.patch.yml"}, nil
+       }
+
+       current, err := recordsURL()
+       if err != nil {
+               return Status{}, err
+       }
+       // A plugin left by an older Gateway, or by one listening on another 
port,
+       // reports nowhere. Re-patching is what rewrites it.
+       if !strings.Contains(string(data), jsonString(current)) || entry != 
layout.moduleName {
+               return Status{Detail: "dsh plugin needs refresh"}, nil
+       }
+       if !IsApplied(target) {
+               return Status{Patched: true, Detail: "dsh plugin was installed 
outside Gateway"}, nil
+       }
+       return Status{Patched: true, Detail: "dsh plugin active after 
restart"}, nil
+}
+
+func (dshPatcher) PatchNotice(patched bool) (string, string) {
+       if patched {
+               return "Removes Gateway's audit-only dsh plugin.", "Restart dsh 
to stop loading it."
+       }
+       return "Installs an audit-only dsh plugin. It listens on the harness's 
session bus and changes nothing a session does.",
+               "Restart dsh to load it."
+}
+
+// dshLayout is where one installation keeps the two files a patch touches: the
+// plugin module and the home-level patch layer that loads it.
+type dshLayout struct {
+       pluginPath string
+       patchPath  string
+       // moduleName is the specifier the loader imports the plugin by. The 
home
+       // layer is composed into every profile, whose base URLs differ, so the
+       // entry names an absolute file URL rather than a relative path.
+       moduleName string
+}
+
+func (p dshPatcher) layoutOf(target Target) (dshLayout, error) {
+       home, err := dshHome(target.Owner)
+       if err != nil {
+               return dshLayout{}, err
+       }
+       pluginPath := filepath.Join(home, "profiles", dshPluginFile)
+       return dshLayout{
+               pluginPath: pluginPath,
+               patchPath:  filepath.Join(home, "cordis.patch.yml"),
+               moduleName: fileURL(pluginPath),
+       }, nil
+}
+
+// dshHome is the harness home the owner's dsh reads. DSH_HOME is meaningful
+// only for the account Gateway runs as.
+func dshHome(owner string) (string, error) {
+       if current, err := user.Current(); err == nil && 
agenthome.SameAccount(owner, current.Username) {
+               if configured := strings.TrimSpace(os.Getenv("DSH_HOME")); 
configured != "" {
+                       if !filepath.IsAbs(configured) {
+                               return "", errors.New("DSH_HOME must be an 
absolute path")
+                       }
+                       return filepath.Clean(configured), nil
+               }
+       }
+       home, err := agenthome.Resolve(owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".dsh"), nil
+}
+
+// fileURL is the absolute module specifier the loader imports. A Windows path
+// starts with its drive letter rather than a slash, and a file URL without one
+// would read the drive as the host.
+func fileURL(path string) string {
+       slashed := filepath.ToSlash(filepath.Clean(path))
+       if !strings.HasPrefix(slashed, "/") {
+               slashed = "/" + slashed
+       }
+       return (&url.URL{Scheme: "file", Path: slashed}).String()
+}
+
+func renderDshPlugin(target Target) (string, error) {
+       recordsUrl, err := recordsURL()
+       if err != nil {
+               return "", err
+       }
+       token, err := IssueIngestToken(monitorTarget(target))
+       if err != nil {
+               return "", err
+       }
+
+       plugin := dshPluginSource
+       for placeholder, value := range map[string]string{
+               "__CASBIN_GATEWAY_AGENT__":               jsonString("dsh"),
+               "__CASBIN_GATEWAY_RECORDS_URL__":         
jsonString(recordsUrl),
+               "__CASBIN_GATEWAY_AGENT_PATH__":          
jsonString(target.Path),
+               "__CASBIN_GATEWAY_OWNER__":               
jsonString(target.Owner),
+               "__CASBIN_GATEWAY_INGEST_TOKEN__":        jsonString(token),
+               "__CASBIN_GATEWAY_INGEST_TOKEN_HEADER__": 
jsonString(agentmonitor.IngestTokenHeader),
+       } {
+               plugin = strings.ReplaceAll(plugin, placeholder, value)
+       }
+       return plugin, nil
+}
+
+// setDshEntry adds or removes the loader entry that mounts the plugin. The 
file
+// is a list of patches, each of which may insert entries into the composed
+// tree; Gateway owns one row in it and leaves every other patch alone.
+func setDshEntry(layout dshLayout, want bool) error {
+       document, patches, err := loadDshPatches(layout.patchPath)
+       if err != nil {
+               return err
+       }
+
+       kept := patches.Content[:0]
+       found := false
+       for _, patch := range patches.Content {
+               inserts := yamledit.Get(patch, "insert")
+               if inserts != nil && inserts.Kind == yaml.SequenceNode {
+                       rows := inserts.Content[:0]
+                       for _, row := range inserts.Content {
+                               if yamledit.String(row, "id") != 
gatewayEntryName {
+                                       rows = append(rows, row)
+                                       continue
+                               }
+                               found = true
+                               if want {
+                                       // The row is rewritten rather than 
kept, so a moved plugin
+                                       // or a renamed module is corrected by 
patching again.
+                                       if err := yamledit.Set(row, 
layout.moduleName, "name"); err != nil {
+                                               return err
+                                       }
+                                       rows = append(rows, row)
+                               }
+                       }
+                       inserts.Content = rows
+                       // A patch whose only purpose was the entry it no 
longer inserts is
+                       // not a patch worth keeping.
+                       if len(rows) == 0 && len(patch.Content) == 2 {
+                               continue
+                       }
+               }
+               kept = append(kept, patch)
+       }
+       patches.Content = kept
+
+       if want && !found {
+               row, err := yamledit.Node(map[string]any{"id": 
gatewayEntryName, "name": layout.moduleName})
+               if err != nil {
+                       return err
+               }
+               patch, err := yamledit.Node(map[string]any{"insert": []any{}})
+               if err != nil {
+                       return err
+               }
+               inserts := yamledit.Get(patch, "insert")
+               inserts.Style = 0
+               inserts.Content = append(inserts.Content, row)
+               patches.Content = append(patches.Content, patch)
+       }
+       if !want && !found {
+               return nil
+       }
+       return saveDshPatches(layout.patchPath, document)
+}
+
+// dshEntryName is the module the patch list loads Gateway's plugin from, empty
+// when the list has no entry of Gateway's.
+func dshEntryName(layout dshLayout) (string, error) {
+       _, patches, err := loadDshPatches(layout.patchPath)
+       if err != nil {
+               return "", err
+       }
+       for _, patch := range patches.Content {
+               inserts := yamledit.Get(patch, "insert")
+               if inserts == nil || inserts.Kind != yaml.SequenceNode {
+                       continue
+               }
+               for _, row := range inserts.Content {
+                       if yamledit.String(row, "id") == gatewayEntryName {
+                               return yamledit.String(row, "name"), nil
+                       }
+               }
+       }
+       return "", nil
+}
+
+func loadDshPatches(path string) (*yamledit.Document, *yaml.Node, error) {
+       data, err := os.ReadFile(path)
+       if err != nil && !os.IsNotExist(err) {
+               return nil, nil, err
+       }
+       document, err := yamledit.Parse(data)
+       if err != nil {
+               return nil, nil, fmt.Errorf("parse %s: %w", path, err)
+       }
+       patches, err := document.Sequence()
+       if err != nil {
+               return nil, nil, fmt.Errorf("parse %s: %w", path, err)
+       }
+       return document, patches, nil
+}
+
+func saveDshPatches(path string, document *yamledit.Document) error {
+       data, err := document.Bytes()
+       if err != nil {
+               return err
+       }
+       // A list Gateway created and has just emptied is removed rather than 
left
+       // behind. One the operator wrote keeps its comments, and so keeps its 
file.
+       if strings.TrimSpace(string(data)) == "[]" {
+               if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+                       return err
+               }
+               return nil
+       }
+       if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+               return err
+       }
+       return os.WriteFile(path, data, 0o600)
+}
diff --git a/agentpatch/dsh_plugin.mjs b/agentpatch/dsh_plugin.mjs
new file mode 100644
index 0000000..92d02f0
--- /dev/null
+++ b/agentpatch/dsh_plugin.mjs
@@ -0,0 +1,197 @@
+// 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.
+
+// Audit-only dsh plugin. It listens on the harness's own session bus and
+// reports what the agent did; it registers no service and answers no call, so
+// nothing in a session waits on it and nothing it does can change a result.
+//
+// It deliberately does not mount on the `sessionTelemetry` seam: the harness
+// accepts one backend there, `dsh-base` already mounts the OpenTelemetry one,
+// and a second registration fails the whole plugin tree at boot.
+
+const AGENT = __CASBIN_GATEWAY_AGENT__;
+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 = 16 * 1024;
+const MAX_TEXT_CHARS = 4 * 1024;
+
+// The session events worth a record. The bus also carries the assembled
+// assistant message, the raw stream chunks and the request header holding the
+// whole system prompt and tool schemas; those are the conversation itself
+// rather than what the agent did with it.
+const EVENTS = {
+  "user/message": ["prompt", "submitted", "attempted"],
+  "tool/call": ["tool", "call", "attempted"],
+  "tool/result": ["tool", "call", "success"],
+  "turn/end": ["session", "stop", "success"],
+  "compaction/start": ["compact", "before", "attempted"],
+  "compaction/end": ["compact", "after", "success"],
+};
+
+const SENSITIVE_KEY = 
/secret|token|password|passwd|credential|private.?key|api.?key|access.?key|authorization|cookie/i;
+
+function sanitize(value, key = "", depth = 0) {
+  if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
+  if (depth > 6) return undefined;
+  if (Array.isArray(value)) return value.slice(0, 50).map((item) => 
sanitize(item, "", depth + 1));
+  if (value && typeof value === "object") {
+    const result = {};
+    for (const [childKey, childValue] of Object.entries(value)) {
+      result[childKey] = sanitize(childValue, childKey, depth + 1);
+    }
+    return result;
+  }
+  if (typeof value === "string" && value.length > MAX_TEXT_CHARS) {
+    return `${value.slice(0, MAX_TEXT_CHARS)}...[truncated]`;
+  }
+  return value;
+}
+
+function objectFor(payload) {
+  try {
+    const value = JSON.stringify(sanitize(payload ?? {}));
+    if (!value) return "";
+    return value.length > MAX_OBJECT_CHARS ? `${value.slice(0, 
MAX_OBJECT_CHARS)}...[truncated]` : value;
+  } catch {
+    return "";
+  }
+}
+
+function post(record) {
+  try {
+    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,
+      body: JSON.stringify({ agent: AGENT, agentPath: AGENT_PATH, user: OWNER, 
...record }),
+      signal: controller.signal,
+    })
+      .catch(() => {})
+      .finally(() => clearTimeout(timer));
+  } catch {
+    // Monitoring is best effort and must never reach the session.
+  }
+}
+
+function sessionKey(session) {
+  try {
+    return String(session?.id ?? "");
+  } catch {
+    return "";
+  }
+}
+
+// messageText joins the text of a user message. Attachments and file 
references
+// are left out: the record says what was asked, not what was pasted in.
+function messageText(data) {
+  try {
+    const content = data?.content ?? data?.message?.content;
+    if (typeof content === "string") return content.slice(0, MAX_TEXT_CHARS);
+    if (!Array.isArray(content)) return "";
+    return content
+      .filter((part) => part && part.type === "text" && typeof part.text === 
"string")
+      .map((part) => part.text)
+      .join("\n")
+      .slice(0, MAX_TEXT_CHARS);
+  } catch {
+    return "";
+  }
+}
+
+function reportEvent(session, event) {
+  const mapping = EVENTS[event?.type];
+  if (!mapping) return;
+
+  const [eventType, action, outcome] = mapping;
+  const data = event.data ?? {};
+  const record = {
+    createdTime: new Date(event.time ?? Date.now()).toISOString(),
+    eventType,
+    action,
+    outcome,
+    sessionKey: sessionKey(session),
+  };
+
+  switch (event.type) {
+  case "user/message":
+    record.title = messageText(data);
+    record.object = objectFor({ text: record.title });
+    break;
+  case "tool/call":
+    record.toolName = data.name ?? "";
+    record.toolUseId = data.callId ?? "";
+    record.object = objectFor({ turn: data.turn, step: data.step, arguments: 
data.arguments });
+    break;
+  case "tool/result":
+    // The result message is the tool's own output, which can be a whole file.
+    record.outcome = data.error ? "failure" : "success";
+    record.detail = data.error ? `${data.error.name}: ${data.error.code}` : "";
+    record.object = objectFor({ turn: data.turn, step: data.step, error: 
data.error });
+    break;
+  case "turn/end":
+    record.outcome = data.reason && data.reason !== "completed" ? "failure" : 
"success";
+    record.detail = typeof data.reason === "string" ? data.reason : "";
+    record.object = objectFor({ turn: data.turn, reason: data.reason });
+    break;
+  default:
+    record.object = objectFor(data);
+  }
+  post(record);
+}
+
+export default {
+  name: "casbin-gateway-agent-monitor",
+  inject: ["sessions"],
+  apply(ctx) {
+    const guard = (handler) => (...args) => {
+      try {
+        handler(...args);
+      } catch {
+        // A shape Gateway cannot read is dropped rather than thrown back into
+        // the harness, which is running a session on this call.
+      }
+    };
+
+    ctx.on("session/created", guard((session) => post({
+      createdTime: new Date().toISOString(),
+      eventType: "session",
+      action: "start",
+      sessionKey: sessionKey(session),
+    })));
+
+    ctx.on("session/disposed", guard((session) => post({
+      createdTime: new Date().toISOString(),
+      eventType: "session",
+      action: "end",
+      sessionKey: sessionKey(session),
+    })));
+
+    ctx.on("session/event", guard(reportEvent));
+
+    ctx.on("agent/error", guard((error) => post({
+      createdTime: new Date().toISOString(),
+      eventType: "session",
+      action: "error",
+      outcome: "failure",
+      detail: String(error?.message ?? error ?? "").slice(0, MAX_TEXT_CHARS),
+    })));
+  },
+};


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

Reply via email to