blob: 52289e947c89579a98e258073bafc52089bf519d [file]
// Copyright 2025 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 controllers
import (
"encoding/json"
"net"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agenthistory"
"github.com/apache/casbin-gateway/agenthome"
"github.com/apache/casbin-gateway/agentmonitor"
"github.com/apache/casbin-gateway/agentpatch"
"github.com/apache/casbin-gateway/agentprovider"
"github.com/apache/casbin-gateway/object"
)
type discoveredAgent struct {
agent.Installation
agentpatch.Status
// Provider is the "owner/name" id of the bound provider, and Fallbacks are the
// providers tried after it. Installations are discovered per host while the
// routing is stored per agent id, so they merge here.
Provider string `json:"provider"`
Fallbacks []string `json:"fallbacks"`
Mode string `json:"mode"`
// ProviderConfig is the state of the agent's own configuration file, which
// is what the config orchestrator writes.
ProviderConfig agentprovider.Status `json:"providerConfig"`
}
// GetAgents scans known installation locations and returns the AI agents
// installed in the environment where Casbin Gateway is running.
func (c *ApiController) GetAgents() {
if c.RequireAdmin() {
return
}
installations, err := agent.Scan(c.GetString("refresh") == "true")
if err != nil {
c.ResponseError(err.Error())
return
}
agents, err := object.GetAgents()
if err != nil {
c.ResponseError(err.Error())
return
}
result := make([]*discoveredAgent, 0, len(installations))
for _, installation := range installations {
target := targetOf(installation)
// Monitoring is on by default, so an installation that is not monitored
// yet is patched here instead of waiting for the switch to be flipped.
// A failure is left to the status below, which says what is wrong.
_ = agentpatch.EnsurePatched(target)
item := &discoveredAgent{
Installation: installation,
Status: agentpatch.StatusOf(target),
Fallbacks: []string{},
Mode: object.ModeGateway,
ProviderConfig: agentprovider.StatusOf(providerTarget(target)),
}
if stored, ok := agents[installation.AgentId]; ok {
item.Provider = stored.Provider
item.Mode = stored.Mode
if stored.Fallbacks != nil {
item.Fallbacks = stored.Fallbacks
}
}
result = append(result, item)
}
c.ResponseOk(result, agent.InContainer())
}
// UpdateAgentRouting binds one agent to the provider its requests are forwarded
// to, to the providers tried when that one cannot answer, and to the way it
// reaches them. The binding is per agent id.
//
// The configuration file of every installation Gateway can write is written
// here, since a binding an agent never reads is one that does nothing: it keeps
// calling the provider its own configuration names. Unbinding the agent puts
// that configuration back the way it was found.
func (c *ApiController) UpdateAgentRouting() {
if c.RequireAdmin() {
return
}
var form struct {
AgentId string `json:"agentId"`
Provider string `json:"provider"`
Fallbacks []string `json:"fallbacks"`
Mode string `json:"mode"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
c.ResponseError(err.Error())
return
}
if !agent.IsKnownAgentId(form.AgentId) {
c.ResponseError("unknown agent: " + form.AgentId)
return
}
if err := checkAgentProtocol(form.AgentId, form.Mode, append([]string{form.Provider}, form.Fallbacks...)); err != nil {
c.ResponseError(err.Error())
return
}
if err := object.SetAgentRouting(form.AgentId, form.Provider, form.Fallbacks, form.Mode); err != nil {
c.ResponseError(err.Error())
return
}
if form.Provider == "" {
if failure := restoreAgentProvider(form.AgentId); failure != "" {
c.ResponseError("the routing was cleared, but the agent configuration was not restored: " + failure)
return
}
} else if failure := reapplyAgentProvider(form.AgentId); failure != "" {
c.ResponseError("the routing was saved, but the agent configuration was not rewritten: " + failure)
return
}
c.ResponseOk(form.Provider)
}
// PatchAgent enables monitoring for one discovered installation.
func (c *ApiController) PatchAgent() {
if c.RequireAdmin() {
return
}
target, ok := c.readAgentPatchTarget()
if !ok {
return
}
if err := agentpatch.Patch(target); err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(agentpatch.StatusOf(target))
}
// UnpatchAgent disables monitoring and restores any configuration it changed.
func (c *ApiController) UnpatchAgent() {
if c.RequireAdmin() {
return
}
target, ok := c.readAgentPatchTarget()
if !ok {
return
}
if err := agentpatch.Unpatch(target); err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(agentpatch.StatusOf(target))
}
// GetAgentRecords returns the current process's in-memory agent activity.
func (c *ApiController) GetAgentRecords() {
if c.RequireAdmin() {
return
}
limit := 200
if value := c.Input().Get("limit"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
c.ResponseError(err.Error())
return
}
limit = parsed
}
c.ResponseOk(agentmonitor.ListRecords(agentmonitor.RecordQuery{
Agent: c.Input().Get("agent"),
EventType: c.Input().Get("eventType"),
Outcome: c.Input().Get("outcome"),
Session: c.Input().Get("session"),
Limit: limit,
}))
}
// GetAgentSessions groups the current in-memory records by agent session. The
// optional agent filter is what an agent's own detail page asks for.
func (c *ApiController) GetAgentSessions() {
if c.RequireAdmin() {
return
}
agentId := c.Input().Get("agent")
live := agentmonitor.ListSessions(agentmonitor.RecordQuery{Agent: agentId})
// The transcripts on disk are the sessions that already happened, so they
// are listed next to the monitored ones rather than only after Patch.
sessions := make([]agenthistory.Session, 0, len(live))
seen := map[string]bool{}
for _, session := range live {
seen[sessionSeenKey(session.Agent, session.SessionKey)] = true
sessions = append(sessions, agenthistory.Session{
Agent: session.Agent,
SessionKey: session.SessionKey,
Title: session.Title,
RecordCount: session.RecordCount,
FirstTime: session.FirstTime,
LastTime: session.LastTime,
})
}
for _, session := range historicalSessions(agentId) {
if seen[sessionSeenKey(session.Agent, session.SessionKey)] {
continue
}
sessions = append(sessions, session)
}
sort.SliceStable(sessions, func(left, right int) bool {
return sessions[left].LastTime > sessions[right].LastTime
})
c.ResponseOk(sessions)
}
// GetAgentSession reads one transcript in full, so that a session listed off
// disk can be opened and read instead of only counted. The session is looked up
// by key among the ones a scan found, which is what keeps a request from naming
// a file of its own.
func (c *ApiController) GetAgentSession() {
if c.RequireAdmin() {
return
}
agentId := c.Input().Get("agent")
sessionKey := c.Input().Get("session")
if sessionKey == "" {
c.ResponseError("session is required")
return
}
for _, session := range historicalSessions(agentId) {
if session.SessionKey != sessionKey {
continue
}
transcript, err := agenthistory.ReadTranscript(session)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(transcript)
return
}
c.ResponseError("no transcript on disk for this session")
}
// sessionSeenKey identifies one session across the two sources, so a session
// that monitoring already reported is not listed twice.
func sessionSeenKey(agentId string, sessionKey string) string {
return agentId + "/" + sessionKey
}
// historicalSessions reads the transcripts of every account with an agent on
// this machine. A home Gateway cannot open is skipped: the page lists what it
// can read, and says nothing about the rest.
func historicalSessions(agentId string) []agenthistory.Session {
installations, err := agent.Scan(false)
if err != nil {
return nil
}
sessions := []agenthistory.Session{}
scanned := map[string]bool{}
for _, installation := range installations {
home, err := agenthome.Resolve(installation.Owner)
if err != nil || scanned[home] {
continue
}
scanned[home] = true
for _, session := range agenthistory.Scan(home) {
if agentId == "" || strings.EqualFold(session.Agent, agentId) {
sessions = append(sessions, session)
}
}
}
return sessions
}
// 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() {
ip, ok := c.directLoopbackClient()
if !ok {
c.ResponseError("agent record ingestion is limited to direct loopback requests")
return
}
agentId, ok := agentpatch.ValidateIngestToken(c.Ctx.Input.Header(agentmonitor.IngestTokenHeader))
if !ok {
c.ResponseError("agent record ingestion requires a valid installation token")
return
}
var record agentmonitor.Record
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &record); err != nil {
c.ResponseError(err.Error())
return
}
if record.Agent == "" {
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
}
// readAgentInstallation resolves the request body against the installations that
// were actually discovered. Patching writes into the owner's home directory and
// starting runs a program, so an unverified body would let a caller name any
// account, and any file, on the host.
func (c *ApiController) readAgentInstallation() (agent.Installation, bool) {
var requested agentpatch.Target
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &requested); err != nil {
c.ResponseError(err.Error())
return agent.Installation{}, false
}
installations, err := agent.Scan(false)
if err != nil {
c.ResponseError(err.Error())
return agent.Installation{}, false
}
for _, installation := range installations {
if matchesTarget(targetOf(installation), requested) {
return installation, true
}
}
c.ResponseError("no discovered agent installation matches this target")
return agent.Installation{}, false
}
func (c *ApiController) readAgentPatchTarget() (agentpatch.Target, bool) {
installation, ok := c.readAgentInstallation()
if !ok {
return agentpatch.Target{}, false
}
return targetOf(installation), true
}
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 {
return agentpatch.Target{
AgentId: installation.AgentId,
Path: installation.Path,
Owner: installation.Owner,
}
}