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 d196048 fix(agent): reach Gateway from Claude Desktop's sandbox
d196048 is described below
commit d196048b4790fb8d4429b7449c72f0b4040efeef
Author: Yang Luo <[email protected]>
AuthorDate: Thu Aug 27 22:36:21 2026 +0800
fix(agent): reach Gateway from Claude Desktop's sandbox
---
agent/agent.go | 13 +++
agent/fingerprint.go | 3 +
agent/fingerprints/claude-desktop.json | 1 +
conf/app.conf | 5 +-
controllers/agent.go | 21 +++++
controllers/agent_provider.go | 30 +++++--
main.go | 6 ++
service/lan.go | 140 +++++++++++++++++++++++++++++++++
service/startup.go | 20 ++++-
util/network.go | 47 +++++++++++
web/src/components/ProviderCard.tsx | 2 +-
web/src/lib/agents.ts | 2 +
web/src/locales/en/data.json | 1 +
web/src/locales/zh/data.json | 1 +
web/src/types.ts | 2 +
15 files changed, 282 insertions(+), 12 deletions(-)
diff --git a/agent/agent.go b/agent/agent.go
index bca4022..fa79739 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -36,6 +36,19 @@ func DisplayNameOf(id string) string {
return ""
}
+// RunsSandboxed reports whether an agent runs its sessions in a sandbox of its
+// own. Claude Desktop does: its Cowork and Code sessions run in a virtual
+// machine, where 127.0.0.1 is that machine rather than this host, so a
loopback
+// endpoint never reaches Gateway.
+func RunsSandboxed(id string) bool {
+ for i := range fingerprints {
+ if fingerprints[i].ID == id {
+ return fingerprints[i].Sandboxed
+ }
+ }
+ return false
+}
+
// IsKnownAgentId reads the fingerprints rather than a host scan, so an agent
// stays configurable while it is not installed.
func IsKnownAgentId(id string) bool {
diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 145b3ba..5ac56d3 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -27,6 +27,9 @@ type Fingerprint struct {
LaunchArgs []string `json:"launchArgs,omitempty"`
// Desktop marks a windowed app, which is launched without a console.
Desktop bool `json:"desktop,omitempty"`
+ // Sandboxed marks an agent that runs its sessions in a sandbox of its
own,
+ // where 127.0.0.1 is the sandbox rather than this host.
+ Sandboxed bool `json:"sandboxed,omitempty"`
StateDir string `json:"stateDir,omitempty"`
NpmPackage string `json:"npmPackage,omitempty"`
diff --git a/agent/fingerprints/claude-desktop.json
b/agent/fingerprints/claude-desktop.json
index bfa68c6..ccf1d31 100644
--- a/agent/fingerprints/claude-desktop.json
+++ b/agent/fingerprints/claude-desktop.json
@@ -3,6 +3,7 @@
"displayName": "Claude Desktop",
"execName": "claude",
"desktop": true,
+ "sandboxed": true,
"msixFamily": "Claude_pzs8sxrjxfjjc",
"desktopInstallerDir": "AnthropicClaude"
}
diff --git a/conf/app.conf b/conf/app.conf
index 9c580ee..1ad22df 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -12,7 +12,10 @@ httpport = 17000
; Interface the management UI binds to. It is loopback by default: the local
; admin is signed in without a password and the relay hands out the provider
; keys, so exposing both to the network is opt-in. Set it to 0.0.0.0 to serve
-; other machines, and change the admin password before you do.
+; other machines, and change the admin password before you do. One address is
+; opened regardless: while an agent that runs in a sandbox of its own is routed
+; through the gateway, this host's own address is served too, since loopback
+; inside such a sandbox is the sandbox rather than this machine.
httpaddr = 127.0.0.1
runmode = dev
SessionOn = true
diff --git a/controllers/agent.go b/controllers/agent.go
index 52289e9..5a09be2 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -29,6 +29,7 @@ import (
"github.com/apache/casbin-gateway/agentpatch"
"github.com/apache/casbin-gateway/agentprovider"
"github.com/apache/casbin-gateway/object"
+ "github.com/apache/casbin-gateway/service"
)
type discoveredAgent struct {
@@ -44,6 +45,10 @@ type discoveredAgent struct {
// ProviderConfig is the state of the agent's own configuration file,
which
// is what the config orchestrator writes.
ProviderConfig agentprovider.Status `json:"providerConfig"`
+ // ProxyBaseUrl is the endpoint this agent reaches its bound provider
at. The
+ // page cannot build it from its own address: an agent that runs in a
sandbox
+ // is given this host's network address rather than loopback.
+ ProxyBaseUrl string `json:"proxyBaseUrl"`
}
// GetAgents scans known installation locations and returns the AI agents
@@ -66,6 +71,7 @@ func (c *ApiController) GetAgents() {
}
result := make([]*discoveredAgent, 0, len(installations))
+ baseUrls := map[string]string{}
for _, installation := range installations {
target := targetOf(installation)
// Monitoring is on by default, so an installation that is not
monitored
@@ -79,6 +85,14 @@ func (c *ApiController) GetAgents() {
Mode: object.ModeGateway,
ProviderConfig:
agentprovider.StatusOf(providerTarget(target)),
}
+ baseUrl, ok := baseUrls[installation.AgentId]
+ if !ok {
+ // A host with no address of its own cannot serve a
sandboxed agent
+ // at all; the binding itself reports that, and the
listing stays.
+ baseUrl, _ = gatewayAgentUrl(installation.AgentId)
+ baseUrls[installation.AgentId] = baseUrl
+ }
+ item.ProxyBaseUrl = baseUrl
if stored, ok := agents[installation.AgentId]; ok {
item.Provider = stored.Provider
item.Mode = stored.Mode
@@ -129,6 +143,13 @@ func (c *ApiController) UpdateAgentRouting() {
return
}
+ // An agent that runs in a sandbox is given this host's own address, so
the
+ // management port has to answer there before that endpoint is written.
+ if err := service.SyncLanAccess(); err != nil {
+ c.ResponseError("the routing was saved, but the agent cannot
reach Gateway from its sandbox: " + 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)
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index f873b3b..575f2d3 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -16,6 +16,8 @@ package controllers
import (
"fmt"
+ "net"
+ "strconv"
"strings"
"github.com/apache/casbin-gateway/agent"
@@ -23,6 +25,7 @@ import (
"github.com/apache/casbin-gateway/agentprovider"
"github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/object"
+ "github.com/apache/casbin-gateway/service"
)
// PlanAgentProvider renders what a switch would write, without touching a
file.
@@ -166,7 +169,11 @@ func agentEndpoint(agentId string)
(agentprovider.Endpoint, error) {
return endpoint, nil
}
- endpoint.BaseUrl = gatewayAgentUrl(agentId)
+ baseUrl, err := gatewayAgentUrl(agentId)
+ if err != nil {
+ return endpoint, err
+ }
+ endpoint.BaseUrl = baseUrl
endpoint.ServesResponsesApi = true
// A client-auth provider forwards whatever the agent sends, so it must
keep
// sending its own credentials: a placeholder token written into the
agent's
@@ -177,11 +184,22 @@ func agentEndpoint(agentId string)
(agentprovider.Endpoint, error) {
return endpoint, nil
}
-// gatewayAgentUrl is the loopback base URL an agent reaches its own provider
at.
-// One URL serves every wire format: an OpenAI client appends /chat/completions
-// to it, Codex appends /responses, and an Anthropic one appends /v1/messages.
-func gatewayAgentUrl(agentId string) string {
- return fmt.Sprintf("http://127.0.0.1:%d/v1/agents/%s",
conf.GetHttpPort(), agentId)
+// gatewayAgentUrl is the base URL an agent reaches its own provider at. One
URL
+// serves every wire format: an OpenAI client appends /chat/completions to it,
+// Codex appends /responses, and an Anthropic one appends /v1/messages. An
agent
+// that runs its sessions in a sandbox is named this host's own address instead
+// of loopback, which inside a sandbox is the sandbox itself.
+func gatewayAgentUrl(agentId string) (string, error) {
+ host := "127.0.0.1"
+ if agent.RunsSandboxed(agentId) {
+ lan, err := service.LanHost()
+ if err != nil {
+ return "", err
+ }
+ host = lan
+ }
+ address := net.JoinHostPort(host, strconv.Itoa(conf.GetHttpPort()))
+ return fmt.Sprintf("http://%s/v1/agents/%s", address, agentId), nil
}
// reapplyAgentProvider writes the bound provider into the configuration of
every
diff --git a/main.go b/main.go
index fe6c708..72cd03b 100644
--- a/main.go
+++ b/main.go
@@ -175,5 +175,11 @@ func main() {
service.Start()
+ // The agent endpoint of a sandboxed agent names this host's own
address, and
+ // the configuration naming it outlives this process.
+ if err := service.SyncLanAccess(); err != nil {
+ beego.Error("the endpoint of an agent that runs in a sandbox
cannot be served:", err)
+ }
+
beego.Run(fmt.Sprintf("%s:%v", addr, port))
}
diff --git a/service/lan.go b/service/lan.go
new file mode 100644
index 0000000..bbda484
--- /dev/null
+++ b/service/lan.go
@@ -0,0 +1,140 @@
+// 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 service
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "strconv"
+ "sync"
+
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/object"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/beego/beego"
+)
+
+var (
+ lanMutex sync.Mutex
+ lanServer *http.Server
+)
+
+// LanHost is the address an agent that runs in a sandbox reaches Gateway at.
+// Inside such a sandbox 127.0.0.1 is the sandbox itself, so the loopback
+// endpoint every other agent is given never arrives here.
+func LanHost() (string, error) {
+ addr := conf.GetHttpAddr()
+ if ip := net.ParseIP(addr); ip != nil && !ip.IsLoopback() &&
!ip.IsUnspecified() {
+ return addr, nil
+ }
+ return util.LanIPv4()
+}
+
+// SyncLanAccess makes the management port answer on LanHost() for as long as
an
+// agent that runs in a sandbox is routed through the gateway, and only then:
the
+// endpoint such an agent is given has to be served, while every other agent is
+// reached over loopback and needs nothing opened.
+//
+// It takes effect without a restart and leaves httpaddr alone — one more
address
+// is exactly what the agent needs, where widening the bind would also serve
+// every other interface of this host. Requests arriving there are off-box as
far
+// as the rest of Gateway is concerned, so they carry the relay token, which is
+// written into the agent's configuration along with the endpoint.
+func SyncLanAccess() error {
+ needed, err := sandboxedAgentIsBound()
+ if err != nil {
+ return err
+ }
+ if !needed {
+ closeLanAccess()
+ return nil
+ }
+
+ host, err := LanHost()
+ if err != nil {
+ return err
+ }
+ // A non-loopback httpaddr is already served by beego itself.
+ if !conf.IsHttpAddrLoopback() {
+ return nil
+ }
+
+ address := net.JoinHostPort(host, strconv.Itoa(conf.GetHttpPort()))
+ lanMutex.Lock()
+ defer lanMutex.Unlock()
+ if lanServer != nil {
+ if lanServer.Addr == address {
+ return nil
+ }
+ // The host changed networks since the listener was opened.
+ _ = lanServer.Close()
+ lanServer = nil
+ }
+
+ listener, err := net.Listen("tcp", address)
+ if err != nil {
+ return fmt.Errorf("cannot serve the agent endpoint on %s: %w",
address, err)
+ }
+ server := &http.Server{Addr: address, Handler: beego.BeeApp.Handlers}
+ lanServer = server
+
+ go func() {
+ err := server.Serve(listener)
+ lanMutex.Lock()
+ if lanServer == server {
+ lanServer = nil
+ }
+ lanMutex.Unlock()
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ beego.Error("the agent endpoint on", address, "stopped
serving:", err)
+ }
+ }()
+
+ fmt.Printf("Casbin Gateway: also serving http://%s, which is where an
agent running in a sandbox reaches it\n", address)
+ return nil
+}
+
+func closeLanAccess() {
+ lanMutex.Lock()
+ server := lanServer
+ lanServer = nil
+ lanMutex.Unlock()
+ if server == nil {
+ return
+ }
+
+ _ = server.Close()
+ fmt.Printf("Casbin Gateway: stopped serving http://%s, no agent runs in
a sandbox any more\n", server.Addr)
+}
+
+// sandboxedAgentIsBound reports whether any agent that runs in a sandbox
reaches
+// its provider through this gateway. In direct mode the agent is given the
+// provider's own upstream and never calls here.
+func sandboxedAgentIsBound() (bool, error) {
+ agents, err := object.GetAgents()
+ if err != nil {
+ return false, err
+ }
+
+ for id, routing := range agents {
+ if routing.Provider != "" && routing.Mode == object.ModeGateway
&& agent.RunsSandboxed(id) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/service/startup.go b/service/startup.go
index ef3e1ca..a2b5ecc 100644
--- a/service/startup.go
+++ b/service/startup.go
@@ -54,18 +54,30 @@ func PrintStartupSummary() {
// the bind address decides whether the local admin is signed in automatically.
func describeManagementUrl() string {
url := fmt.Sprintf("http://localhost:%d", conf.GetHttpPort())
- if conf.IsHttpAddrLoopback() {
- return url + " (this machine only)"
+ if !conf.IsHttpAddrLoopback() {
+ return fmt.Sprintf("%s, bound to %s", url, conf.GetHttpAddr())
}
+ if lanAccessNeeded() {
+ if host, err := LanHost(); err == nil {
+ return fmt.Sprintf("%s, and %s:%d for an agent that
runs in a sandbox", url, host, conf.GetHttpPort())
+ }
+ }
+
+ return url + " (this machine only)"
+}
- return fmt.Sprintf("%s, bound to %s", url, conf.GetHttpAddr())
+// lanAccessNeeded mirrors what SyncLanAccess is about to do, so the table says
+// what this process will serve rather than what httpaddr alone would suggest.
+func lanAccessNeeded() bool {
+ needed, err := sandboxedAgentIsBound()
+ return err == nil && needed
}
// describeRelayAuth explains what an agent has to send to /v1. A request off
// this machine needs the token, which is also what Gateway writes into the
// configuration of an agent it switches.
func describeRelayAuth() string {
- if conf.IsHttpAddrLoopback() {
+ if conf.IsHttpAddrLoopback() && !lanAccessNeeded() {
return "this machine only, no token needed"
}
diff --git a/util/network.go b/util/network.go
index a722d0e..7b25100 100644
--- a/util/network.go
+++ b/util/network.go
@@ -15,6 +15,7 @@
package util
import (
+ "errors"
"net"
"os"
)
@@ -50,3 +51,49 @@ func IsIntranetIp(ip string) bool {
parsedIP.IsLinkLocalUnicast() ||
parsedIP.IsLinkLocalMulticast()
}
+
+// LanIPv4 is the address this host answers at on its own network, which is
what
+// a sandbox reaches Gateway at: loopback inside one is the sandbox itself. It
is
+// read from the route to a public address, so the interface a sandbox is
+// bridged onto wins over the virtual ones a hypervisor adds. Nothing is sent —
+// a UDP socket is never connected.
+func LanIPv4() (string, error) {
+ conn, err := net.Dial("udp4", "8.8.8.8:53")
+ if err == nil {
+ defer conn.Close()
+ if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
+ if ip := addr.IP.To4(); ip != nil && !ip.IsLoopback()
&& !ip.IsUnspecified() {
+ return ip.String(), nil
+ }
+ }
+ }
+ return firstPrivateIPv4()
+}
+
+// firstPrivateIPv4 is the fallback for a host with no route to the internet.
+func firstPrivateIPv4() (string, error) {
+ interfaces, err := net.Interfaces()
+ if err != nil {
+ return "", err
+ }
+
+ for _, item := range interfaces {
+ if item.Flags&net.FlagUp == 0 || item.Flags&net.FlagLoopback !=
0 {
+ continue
+ }
+ addrs, err := item.Addrs()
+ if err != nil {
+ continue
+ }
+ for _, addr := range addrs {
+ network, ok := addr.(*net.IPNet)
+ if !ok {
+ continue
+ }
+ if ip := network.IP.To4(); ip != nil && ip.IsPrivate() {
+ return ip.String(), nil
+ }
+ }
+ }
+ return "", errors.New("this host has no address of its own on any
network, so an agent that runs in a sandbox cannot reach Gateway")
+}
diff --git a/web/src/components/ProviderCard.tsx
b/web/src/components/ProviderCard.tsx
index 3069317..da00d60 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -413,7 +413,7 @@ export function ProviderCard({
<p className="text-sm
text-muted-foreground">{i18next.t("agent:Base URL hint")}</p>
<EnvSnippet
protocol={providerProtocol(bound.type)}
- baseUrl={agentProxyBaseUrl(agent.agentId)}
+ baseUrl={agent.proxyBaseUrl ||
agentProxyBaseUrl(agent.agentId)}
defaultShell={shellForPath(agent.path)}
includeToken={!usesClientAuth(bound)}
/>
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 25c807d..f8a5e48 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -70,6 +70,8 @@ export function agentSetupNoteKey(agentId: string) {
switch (agentId) {
case "claude-code":
return "agent:Claude Code config hint";
+ case "claude-desktop":
+ return "agent:Claude Desktop sandbox hint";
case "codex-cli":
return "agent:Codex CLI config hint";
case "opencode":
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 01163c7..2c7ff9f 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -41,6 +41,7 @@
"Built-in model": "Built-in model",
"Built-in model restored": "Put back on its own model",
"Claude Code config hint": "The same variables can live in the \"env\"
block of ~/.claude/settings.json, so every session picks them up without
exporting anything.",
+ "Claude Desktop sandbox hint": "Claude Desktop runs its Cowork and Code
sessions in a sandbox of its own, where 127.0.0.1 is that sandbox rather than
this host. The URL above names this machine on its own network instead, and
Gateway answers there as well as on loopback.",
"Client auth token hint": "This provider forwards the agent's own
credentials, so no token is set: the agent keeps the sign-in it already has.",
"Codex CLI config hint": "The base URL can also be a model provider in
~/.codex/config.toml, selected with model_provider. Its wire_api has to be
\"responses\", the only wire format Codex still speaks.",
"Configuration preview": "Configuration preview",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index f619c1d..82d8c43 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -41,6 +41,7 @@
"Built-in model": "自带模型",
"Built-in model restored": "已恢复为自带模型",
"Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env
段,这样不用每次 export,所有会话都会生效。",
+ "Claude Desktop sandbox hint": "Claude Desktop 的 Cowork 和 Code
会话跑在它自己的沙箱里,那里的 127.0.0.1 是沙箱本身,不是这台主机。所以上面的地址用的是本机在局域网上的地址,Gateway
除回环外也会在该地址上监听。",
"Client auth token hint": "该供应商转发 Agent 自己的凭据,所以不设置 token,Agent
继续用它已有的登录。",
"Codex CLI config hint": "base URL 也可以写成 ~/.codex/config.toml 里的一个 model
provider,再用 model_provider 选中它。它的 wire_api 必须是 \"responses\",这是 Codex
现在唯一还支持的线格式。",
"Configuration preview": "配置预览",
diff --git a/web/src/types.ts b/web/src/types.ts
index 5d0ad24..8059294 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -244,6 +244,8 @@ export interface Agent {
/** "gateway" routes through the local proxy, "direct" writes the upstream.
*/
mode: string;
providerConfig: AgentProviderConfig;
+ /** Where this agent reaches its bound provider, as the server resolved it.
*/
+ proxyBaseUrl?: string;
}
/** The live processes of one agent installation, keyed by owner and path. */
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]