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 e78d63d feat(agent): warn when the shell's env overrides the written
config
e78d63d is described below
commit e78d63da1bdfac4135e006e770598799790c8de7
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 11:08:33 2026 +0800
feat(agent): warn when the shell's env overrides the written config
---
agentenv/agentenv.go | 246 ++++++++++++++++++++++++++++++++
agentenv/shell.go | 109 ++++++++++++++
agentenv/source_unix.go | 59 ++++++++
agentenv/source_windows.go | 92 ++++++++++++
agentprovider/provider.go | 17 +++
web/src/components/EnvWarningBanner.tsx | 80 +++++++++++
web/src/components/ProviderCard.tsx | 7 +
web/src/locales/en/data.json | 13 ++
web/src/locales/zh/data.json | 13 ++
web/src/pages/AgentsPage.tsx | 9 ++
web/src/types.ts | 17 +++
11 files changed, 662 insertions(+)
diff --git a/agentenv/agentenv.go b/agentenv/agentenv.go
new file mode 100644
index 0000000..3071d53
--- /dev/null
+++ b/agentenv/agentenv.go
@@ -0,0 +1,246 @@
+// 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 agentenv finds the environment variables that beat what Gateway
+// writes into an agent's configuration file. An agent started from a shell
that
+// exports ANTHROPIC_BASE_URL reads that URL and not the one in settings.json,
so
+// a switch that wrote the file cleanly still has no effect, with nothing on
+// screen saying why.
+package agentenv
+
+import (
+ "os"
+ "os/user"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+// Conflict is one variable set in the environment that overrides the
+// configuration Gateway wrote for an agent.
+type Conflict struct {
+ Key string `json:"key"`
+ // Value is masked when the variable carries a credential.
+ Value string `json:"value"`
+ // Source is the kind of place it is set in, which the UI has the
wording
+ // for, and Path names the file when it is set in one.
+ Source string `json:"source"`
+ Path string `json:"path"`
+ // Fix is the command that clears it, empty when the file above has to
be
+ // edited instead.
+ Fix string `json:"fix"`
+}
+
+// variable is one environment variable an agent reads before its own
+// configuration file.
+type variable struct {
+ key string
+ // endpoint marks the variable that carries the base URL: a value equal
to
+ // the one Gateway wrote agrees with the configuration rather than
fighting
+ // it, and is not reported.
+ endpoint bool
+ secret bool
+}
+
+// agentVars is what each agent reads from the environment. Only the variables
+// that win over the configuration file belong here: reporting one an agent
+// ignores would send people editing their shell for nothing.
+var agentVars = map[string][]variable{
+ "claude-code": {
+ {key: "ANTHROPIC_BASE_URL", endpoint: true},
+ {key: "ANTHROPIC_AUTH_TOKEN", secret: true},
+ {key: "ANTHROPIC_API_KEY", secret: true},
+ {key: "ANTHROPIC_MODEL"},
+ },
+ "codex": {
+ {key: "OPENAI_BASE_URL", endpoint: true},
+ {key: "OPENAI_API_KEY", secret: true},
+ },
+ "codex-cli": {
+ {key: "OPENAI_BASE_URL", endpoint: true},
+ {key: "OPENAI_API_KEY", secret: true},
+ },
+ // The Gemini CLI reads ~/.gemini/.env with dotenv rules, which never
+ // replace a variable the shell already exports.
+ "gemini-cli": {
+ {key: "GOOGLE_GEMINI_BASE_URL", endpoint: true},
+ {key: "GEMINI_API_KEY", secret: true},
+ {key: "GEMINI_MODEL"},
+ },
+ // dsh resolves the route's key by name, and the environment answers
before
+ // the credentials file does.
+ "dsh": {
+ {key: "CASBIN_GATEWAY_API_KEY", secret: true},
+ },
+}
+
+// value is one variable found set, with where it was found.
+type value struct {
+ text string
+ source string
+ path string
+ fix string
+}
+
+// The kinds of place a variable is set in, as the UI names them.
+const (
+ SourceProcess = "process"
+ SourceShell = "shell"
+ SourceProfile = "profile"
+ SourceUser = "user"
+ SourceMachine = "machine"
+ SourceSystem = "system"
+)
+
+// source is one place the environment is set, with the variables it sets.
+type source struct {
+ kind string
+ path string
+ // fix is the command that clears a variable here, with %s for its name.
+ fix string
+ vars map[string]string
+}
+
+// The scan reads a handful of files, and the agent list asks for every
+// installation at once, so the result is held briefly rather than read once
per
+// agent per refresh.
+const cacheTTL = 5 * time.Second
+
+type cacheEntry struct {
+ found map[string]value
+ at time.Time
+}
+
+var (
+ cacheMutex sync.Mutex
+ cache = map[string]cacheEntry{}
+)
+
+// Check reports the variables that override what Gateway wrote into agentId's
+// configuration for owner. baseUrl is the endpoint Gateway wrote, empty when
it
+// wrote none.
+func Check(agentId string, owner string, baseUrl string) []Conflict {
+ conflicts := []Conflict{}
+ spec, ok := agentVars[agentId]
+ if !ok {
+ return conflicts
+ }
+
+ found := lookup(owner)
+ for _, item := range spec {
+ set, ok := found[item.key]
+ if !ok || set.text == "" {
+ continue
+ }
+ if item.endpoint && baseUrl != "" &&
strings.TrimRight(set.text, "/") == strings.TrimRight(baseUrl, "/") {
+ continue
+ }
+
+ text := set.text
+ if item.secret {
+ text = maskSecret(text)
+ }
+ fix := set.fix
+ if fix != "" {
+ fix = strings.ReplaceAll(fix, "%s", item.key)
+ }
+ conflicts = append(conflicts, Conflict{
+ Key: item.key,
+ Value: text,
+ Source: set.source,
+ Path: set.path,
+ Fix: fix,
+ })
+ }
+ return conflicts
+}
+
+// lookup finds every variable an agent might read, in the environment owner's
+// agents are started with. The first source that sets one wins, which is the
+// order the shell itself resolves them in.
+func lookup(owner string) map[string]value {
+ cacheMutex.Lock()
+ defer cacheMutex.Unlock()
+
+ if entry, ok := cache[owner]; ok && time.Since(entry.at) < cacheTTL {
+ return entry.found
+ }
+
+ keys := knownKeys()
+ found := map[string]value{}
+ for _, src := range append(processSource(owner, keys),
persistentSources(owner, keys)...) {
+ for key, text := range src.vars {
+ if _, taken := found[key]; taken {
+ continue
+ }
+ found[key] = value{text: text, source: src.kind, path:
src.path, fix: src.fix}
+ }
+ }
+
+ cache[owner] = cacheEntry{found: found, at: time.Now()}
+ return found
+}
+
+// processSource is the environment Gateway itself runs in, which is the one
the
+// agents of the same account inherit when they are started from the desktop.
+// Another account's agents inherit nothing from it, so it is left out there.
+func processSource(owner string, keys map[string]bool) []source {
+ if !runsAs(owner) {
+ return nil
+ }
+
+ vars := map[string]string{}
+ for key := range keys {
+ if text, ok := os.LookupEnv(key); ok && text != "" {
+ vars[key] = text
+ }
+ }
+ if len(vars) == 0 {
+ return nil
+ }
+ return []source{{kind: SourceProcess, fix: processFix, vars: vars}}
+}
+
+func runsAs(owner string) bool {
+ if strings.TrimSpace(owner) == "" {
+ return true
+ }
+ current, err := user.Current()
+ if err != nil {
+ return false
+ }
+ return agenthome.SameAccount(owner, current.Username)
+}
+
+// knownKeys is every variable any agent reads, so a scan keeps only those and
+// not the rest of the environment.
+func knownKeys() map[string]bool {
+ keys := map[string]bool{}
+ for _, spec := range agentVars {
+ for _, item := range spec {
+ keys[item.key] = true
+ }
+ }
+ return keys
+}
+
+// maskSecret shows enough of a credential to recognize it, not enough to use
it.
+func maskSecret(text string) string {
+ if len(text) <= 8 {
+ return "***"
+ }
+ return text[:4] + "***" + text[len(text)-4:]
+}
diff --git a/agentenv/shell.go b/agentenv/shell.go
new file mode 100644
index 0000000..30fb27c
--- /dev/null
+++ b/agentenv/shell.go
@@ -0,0 +1,109 @@
+// 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 agentenv
+
+import (
+ "os"
+ "strings"
+)
+
+// parser reads the assignments one startup file makes, keeping the variables
in
+// keys. A later line wins, the way the shell running the file would resolve
it.
+type parser func(line string) (string, string, bool)
+
+func readSource(path string, kind string, keys map[string]bool, parse parser)
*source {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+
+ vars := map[string]string{}
+ for _, line := range strings.Split(strings.ReplaceAll(string(data),
"\r\n", "\n"), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ key, text, ok := parse(line)
+ if !ok || !keys[key] || text == "" {
+ continue
+ }
+ vars[key] = text
+ }
+ if len(vars) == 0 {
+ return nil
+ }
+ return &source{kind: kind, path: path, vars: vars}
+}
+
+// parsePosix reads the shells' own spellings of an assignment: "export KEY=x",
+// a bare "KEY=x", fish's "set -gx KEY x" and csh's "setenv KEY x".
+func parsePosix(line string) (string, string, bool) {
+ switch {
+ case strings.HasPrefix(line, "set "):
+ fields := strings.Fields(line)
+ // Only an exported set reaches the agent, and the flags are
written in
+ // any order: -gx, -xg, -x.
+ if len(fields) < 4 || !strings.HasPrefix(fields[1], "-") ||
!strings.Contains(fields[1], "x") {
+ return "", "", false
+ }
+ return fields[2], unquote(strings.Join(fields[3:], " ")), true
+ case strings.HasPrefix(line, "setenv "):
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ return "", "", false
+ }
+ return fields[1], unquote(strings.Join(fields[2:], " ")), true
+ }
+
+ line = strings.TrimPrefix(line, "export ")
+ key, text, found := strings.Cut(line, "=")
+ if !found || strings.ContainsAny(key, " \t") {
+ return "", "", false
+ }
+ return key, unquote(text), true
+}
+
+// parsePowerShell reads the one spelling a profile uses to point a client
+// somewhere: $env:KEY = "x".
+func parsePowerShell(line string) (string, string, bool) {
+ if !strings.HasPrefix(strings.ToLower(line), "$env:") {
+ return "", "", false
+ }
+
+ key, text, found := strings.Cut(line[len("$env:"):], "=")
+ if !found {
+ return "", "", false
+ }
+ key = strings.TrimSpace(key)
+ if key == "" || strings.ContainsAny(key, " \t") {
+ return "", "", false
+ }
+ return key, unquote(strings.TrimSpace(text)), true
+}
+
+// unquote drops the quotes around a value, and the trailing comment after an
+// unquoted one.
+func unquote(text string) string {
+ text = strings.TrimSpace(text)
+ for _, quote := range []string{`"`, `'`} {
+ if len(text) >= 2 && strings.HasPrefix(text, quote) &&
strings.HasSuffix(text, quote) {
+ return text[1 : len(text)-1]
+ }
+ }
+ if index := strings.Index(text, " #"); index >= 0 {
+ text = strings.TrimSpace(text[:index])
+ }
+ return text
+}
diff --git a/agentenv/source_unix.go b/agentenv/source_unix.go
new file mode 100644
index 0000000..caea69b
--- /dev/null
+++ b/agentenv/source_unix.go
@@ -0,0 +1,59 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+
+package agentenv
+
+import (
+ "path/filepath"
+
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+// processFix clears a variable in the shell the agent is started from.
+const processFix = "unset %s"
+
+// startupFiles are what a shell reads before it starts an agent, in the order
a
+// login shell reads them.
+var startupFiles = []string{
+ ".zshenv",
+ ".zprofile",
+ ".zshrc",
+ ".bash_profile",
+ ".bash_login",
+ ".bashrc",
+ ".profile",
+ ".config/fish/config.fish",
+}
+
+// persistentSources are the files that set owner's environment again in every
+// new shell, so that a variable cleared in one session comes back in the next.
+func persistentSources(owner string, keys map[string]bool) []source {
+ home, err := agenthome.Resolve(owner)
+ if err != nil {
+ return nil
+ }
+
+ sources := []source{}
+ for _, name := range startupFiles {
+ if found := readSource(filepath.Join(home,
filepath.FromSlash(name)), SourceShell, keys, parsePosix); found != nil {
+ sources = append(sources, *found)
+ }
+ }
+ if found := readSource("/etc/environment", SourceSystem, keys,
parsePosix); found != nil {
+ sources = append(sources, *found)
+ }
+ return sources
+}
diff --git a/agentenv/source_windows.go b/agentenv/source_windows.go
new file mode 100644
index 0000000..c32a1e5
--- /dev/null
+++ b/agentenv/source_windows.go
@@ -0,0 +1,92 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package agentenv
+
+import (
+ "path/filepath"
+
+ "github.com/apache/casbin-gateway/agenthome"
+ "golang.org/x/sys/windows/registry"
+)
+
+// processFix clears a variable in the shell the agent is started from.
+const processFix = `Remove-Item Env:\%s`
+
+// The registry keys a new shell builds its environment from.
+const (
+ userEnvPath = `Environment`
+ machineEnvPath = `SYSTEM\CurrentControlSet\Control\Session
Manager\Environment`
+)
+
+// profileFiles are the PowerShell profiles, relative to the Documents folder,
+// that run before the agent started from that shell does.
+var profileFiles = []string{
+ `PowerShell\Microsoft.PowerShell_profile.ps1`,
+ `PowerShell\profile.ps1`,
+ `WindowsPowerShell\Microsoft.PowerShell_profile.ps1`,
+ `WindowsPowerShell\profile.ps1`,
+}
+
+// persistentSources are the places that set owner's environment again in every
+// new shell: the profile that runs at startup, then the registry the session
+// itself is built from.
+func persistentSources(owner string, keys map[string]bool) []source {
+ sources := []source{}
+ if home, err := agenthome.Resolve(owner); err == nil {
+ // Documents is redirected into OneDrive on many machines, and
the
+ // profile follows it there.
+ for _, documents := range []string{filepath.Join(home,
"Documents"), filepath.Join(home, "OneDrive", "Documents")} {
+ for _, name := range profileFiles {
+ if found := readSource(filepath.Join(documents,
name), SourceProfile, keys, parsePowerShell); found != nil {
+ sources = append(sources, *found)
+ }
+ }
+ }
+ }
+
+ // Only the account Gateway runs as has its own hive loaded here;
another
+ // account's user variables are out of reach, and the machine ones below
+ // still apply to it.
+ if runsAs(owner) {
+ if found := registrySource(registry.CURRENT_USER, userEnvPath,
SourceUser, `[Environment]::SetEnvironmentVariable("%s", $null, "User")`,
keys); found != nil {
+ sources = append(sources, *found)
+ }
+ }
+ if found := registrySource(registry.LOCAL_MACHINE, machineEnvPath,
SourceMachine, `[Environment]::SetEnvironmentVariable("%s", $null, "Machine")`,
keys); found != nil {
+ sources = append(sources, *found)
+ }
+ return sources
+}
+
+func registrySource(root registry.Key, path string, kind string, fix string,
keys map[string]bool) *source {
+ key, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
+ if err != nil {
+ return nil
+ }
+ defer key.Close()
+
+ vars := map[string]string{}
+ for name := range keys {
+ if text, _, err := key.GetStringValue(name); err == nil && text
!= "" {
+ vars[name] = text
+ }
+ }
+ if len(vars) == 0 {
+ return nil
+ }
+ return &source{kind: kind, fix: fix, vars: vars}
+}
diff --git a/agentprovider/provider.go b/agentprovider/provider.go
index 0f899c8..a676ba3 100644
--- a/agentprovider/provider.go
+++ b/agentprovider/provider.go
@@ -21,6 +21,8 @@ import (
"fmt"
"strings"
"sync"
+
+ "github.com/apache/casbin-gateway/agentenv"
)
// ErrNotSupported reports an agent whose configuration format Gateway cannot
@@ -94,6 +96,10 @@ type Status struct {
// Current is the endpoint the agent's files name right now, whichever
tool
// wrote them.
Current string `json:"current"`
+ // EnvConflicts are the variables set in the environment that the agent
reads
+ // before its configuration file, and so silently override what a switch
+ // writes into that file.
+ EnvConflicts []agentenv.Conflict `json:"envConflicts"`
}
type writer interface {
@@ -217,6 +223,17 @@ func Restore(target Target) error {
// that cannot be read is reported as detail rather than as an error, so the
// agent list stays usable.
func StatusOf(target Target) Status {
+ status := statusOf(target)
+ status.EnvConflicts = []agentenv.Conflict{}
+ // An agent Gateway cannot write is pointed at the gateway with these
very
+ // variables by hand, so only an agent with a writer is checked.
+ if status.Supported {
+ status.EnvConflicts = agentenv.Check(target.AgentId,
target.Owner, status.BaseUrl)
+ }
+ return status
+}
+
+func statusOf(target Target) Status {
value, ok := writers[target.AgentId]
if !ok {
return Status{
diff --git a/web/src/components/EnvWarningBanner.tsx
b/web/src/components/EnvWarningBanner.tsx
new file mode 100644
index 0000000..476d9c6
--- /dev/null
+++ b/web/src/components/EnvWarningBanner.tsx
@@ -0,0 +1,80 @@
+// 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.
+
+import {TriangleAlert} from "lucide-react";
+import i18next from "i18next";
+
+import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
+import {CodeText} from "@/components/shared/misc";
+import type {EnvConflict} from "@/types";
+
+/** The wording for each kind of place agentenv.Conflict reports. */
+const sourceLabels: Record<string, string> = {
+ process: "agent:Set in the environment Gateway runs in",
+ shell: "agent:Set in a shell startup file",
+ profile: "agent:Set in a PowerShell profile",
+ user: "agent:Set in the user environment variables",
+ machine: "agent:Set in the system environment variables",
+ system: "agent:Set in the system environment file",
+};
+
+/**
+ * The warning for a variable the shell already exports. Writing the agent's
+ * configuration file succeeds either way, and the agent still reads the
+ * environment first, so without this the switch looks applied and changes
+ * nothing.
+ */
+export function EnvWarningBanner({
+ conflicts,
+ applied,
+ className,
+}: {
+ conflicts?: EnvConflict[];
+ applied: boolean;
+ className?: string;
+}) {
+ const items = conflicts ?? [];
+ if (items.length === 0) {
+ return null;
+ }
+
+ return (
+ <Alert variant="warning" className={className}>
+ <TriangleAlert />
+ <AlertTitle>{i18next.t("agent:Environment overrides this
configuration")}</AlertTitle>
+ <AlertDescription>
+ <p>{i18next.t(applied ? "agent:Env conflict hint" : "agent:Env
conflict pending hint")}</p>
+ <div className="w-full space-y-2 pt-1">
+ {items.map(conflict => (
+ <div key={conflict.key} className="w-full space-y-1">
+ <CodeText
copyable>{`${conflict.key}=${conflict.value}`}</CodeText>
+ <p className="text-xs opacity-80">
+ {i18next.t(sourceLabels[conflict.source] ?? "agent:Set in the
environment")}
+ {conflict.path ? <code className="ml-1
break-all">{conflict.path}</code> : null}
+ </p>
+ {conflict.fix ? (
+ <p className="flex flex-wrap items-center gap-1 text-xs
opacity-80">
+ <span>{i18next.t("agent:Clear it with")}</span>
+ <CodeText copyable>{conflict.fix}</CodeText>
+ </p>
+ ) : (
+ <p className="text-xs opacity-80">{i18next.t("agent:Remove the
line to clear it")}</p>
+ )}
+ </div>
+ ))}
+ </div>
+ </AlertDescription>
+ </Alert>
+ );
+}
diff --git a/web/src/components/ProviderCard.tsx
b/web/src/components/ProviderCard.tsx
index 147899f..40b6418 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -18,6 +18,7 @@ import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
import {EnvSnippet} from "@/components/EnvSnippet";
+import {EnvWarningBanner} from "@/components/EnvWarningBanner";
import {ProviderIcon} from "@/components/ProviderIcon";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Loading} from "@/components/shared/loading";
@@ -196,6 +197,7 @@ export function ProviderCard({
detail: "",
builtin: "",
current: "",
+ envConflicts: [],
};
const noteKey = agentSetupNoteKey(agent.agentId);
const healthOf = (id: string) => health.find(item => item.provider === id);
@@ -274,6 +276,11 @@ export function ProviderCard({
</SelectContent>
</Select>
+ <EnvWarningBanner
+ conflicts={providerConfig.envConflicts}
+ applied={providerConfig.applied}
+ />
+
{mismatched ? (
<p className="text-sm text-warning">
{i18next.t("agent:Provider speaks another
API").replace("{protocol}", protocol)}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 3f6baad..6a3ac6c 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -62,6 +62,7 @@
"Changing the project": "Changing the project",
"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.",
+ "Clear it with": "Clear it with",
"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",
@@ -87,6 +88,10 @@
"Enforce permissions": "Enforce permissions",
"Enforced": "Enforced",
"Enforced hint": "Every request this agent relays through Gateway is held
to the rules below.",
+ "Env conflict hint": "The agent reads these variables before its
configuration file, so it still calls the endpoint they name and not the one
Gateway wrote. Clear them, then start the agent again.",
+ "Env conflict pending hint": "The agent reads these variables before its
configuration file, so what is written here takes effect only once they are
cleared.",
+ "Env override": "Environment override",
+ "Environment overrides this configuration": "The environment overrides
this configuration",
"Event": "Event",
"Extra rules": "Extra rules",
"Extra rules hint": "One policy line per row, as \"sub, obj, act, eft\".
They are checked before everything the switches wrote, so they can carve out
what the switches cannot say.",
@@ -196,6 +201,7 @@
"Remember something": "Remember something",
"Remove instance": "Remove instance",
"Remove instance hint": "Gateway forgets this instance. Its state
directory, and the account signed in to it, are left on disk.",
+ "Remove the line to clear it": "Remove that line, then start the agent
again.",
"Reported from": "Reported from",
"Restore configuration": "Restore configuration",
"Restore confirm hint": "Puts back the provider settings this agent had
before Gateway first wrote them, and leaves every other setting untouched.",
@@ -216,6 +222,13 @@
"Search the web": "Search the web",
"Session": "Session",
"Session title": "Session title",
+ "Set in a PowerShell profile": "Set in a PowerShell profile",
+ "Set in a shell startup file": "Set in a shell startup file",
+ "Set in the environment": "Set in the environment",
+ "Set in the environment Gateway runs in": "Set in the environment Gateway
itself runs in",
+ "Set in the system environment file": "Set in the system environment file",
+ "Set in the system environment variables": "Set in the system environment
variables",
+ "Set in the user environment variables": "Set in the user environment
variables",
"Source": "Source",
"Start": "Start",
"Start a sub-agent": "Start a sub-agent",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 33d5b4a..92cbdf9 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -62,6 +62,7 @@
"Changing the project": "修改项目",
"Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env
段,这样不用每次 export,所有会话都会生效。",
"Claude Desktop sandbox hint": "Claude Desktop 的 Cowork 和 Code
会话跑在它自己的沙箱里,那里的 127.0.0.1 是沙箱本身,不是这台主机。所以上面的地址用的是本机在局域网上的地址,Gateway
除回环外也会在该地址上监听。",
+ "Clear it with": "清除命令",
"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": "配置预览",
@@ -87,6 +88,10 @@
"Enforce permissions": "启用权限控制",
"Enforced": "已启用",
"Enforced hint": "该智能体经网关转发的每个请求,都会按下面的规则检查。",
+ "Env conflict hint": "该 Agent 会先读环境变量,再读配置文件,所以它仍然调用变量里的地址,而不是 Gateway
写入的地址。请清除它们,然后重新启动该 Agent。",
+ "Env conflict pending hint": "该 Agent
会先读环境变量,再读配置文件,所以在清除它们之前,这里写入的配置不会生效。",
+ "Env override": "环境变量覆盖",
+ "Environment overrides this configuration": "环境变量覆盖了该配置",
"Event": "事件",
"Extra rules": "额外规则",
"Extra rules hint": "每行一条策略,格式为 \"sub, obj, act,
eft\"。它们排在开关生成的规则之前,用来表达开关表达不了的例外。",
@@ -196,6 +201,7 @@
"Remember something": "记住信息",
"Remove instance": "移除实例",
"Remove instance hint": "Gateway 将不再管理该实例。它的状态目录以及其中登录的账号仍保留在磁盘上。",
+ "Remove the line to clear it": "请删除该行,然后重新启动该 Agent。",
"Reported from": "上报来源",
"Restore configuration": "还原配置",
"Restore confirm hint": "把该 Agent 在 Gateway 首次写入之前的供应商配置还原回去,其他配置保持不变。",
@@ -216,6 +222,13 @@
"Search the web": "搜索网页",
"Session": "会话",
"Session title": "会话标题",
+ "Set in a PowerShell profile": "设置在 PowerShell 配置文件中",
+ "Set in a shell startup file": "设置在 Shell 启动文件中",
+ "Set in the environment": "设置在环境变量中",
+ "Set in the environment Gateway runs in": "设置在 Gateway 自身运行的环境中",
+ "Set in the system environment file": "设置在系统环境变量文件中",
+ "Set in the system environment variables": "设置在系统环境变量中",
+ "Set in the user environment variables": "设置在用户环境变量中",
"Source": "来源",
"Start": "启动",
"Start a sub-agent": "启动子智能体",
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 32c4482..4e253b2 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -129,6 +129,15 @@ export default function AgentsPage({account}: {account:
Account}) {
</Badge>
</SimpleTooltip>
) : null}
+ {record.providerConfig?.envConflicts?.length ? (
+ <SimpleTooltip
+ title={`${i18next.t("agent:Environment overrides this
configuration")}: ${record.providerConfig.envConflicts
+ .map(conflict => conflict.key)
+ .join(", ")}`}
+ >
+ <Badge variant="warning">{i18next.t("agent:Env
override")}</Badge>
+ </SimpleTooltip>
+ ) : null}
</span>
</div>
) : (
diff --git a/web/src/types.ts b/web/src/types.ts
index b92a714..030b528 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -117,6 +117,21 @@ export interface AgentProviderFile {
preview: string;
}
+/**
+ * One environment variable that an agent reads before its own configuration
+ * file, so that what Gateway wrote into that file never takes effect.
+ */
+export interface EnvConflict {
+ key: string;
+ /** Masked when the variable carries a credential. */
+ value: string;
+ /** Where it is set, with the file in path when it is set in one. */
+ source: string;
+ path: string;
+ /** The command that clears it, empty when the file has to be edited. */
+ fix: string;
+}
+
/** The state of the agent's own configuration file, written by the
orchestrator. */
export interface AgentProviderConfig {
supported: boolean;
@@ -133,6 +148,8 @@ export interface AgentProviderConfig {
builtin: string;
/** The endpoint the agent's files name now, whichever tool wrote them. */
current: string;
+ /** The environment variables that override what Gateway writes here. */
+ envConflicts: EnvConflict[];
}
/** The cloud account an agent is signed in to, read from its own state. */
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]