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/09/05 17:27:54 UTC
(casbin-gateway) 03/03: fix(connections): write where the agent reads, and show what landed there
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 ad2570f37eea3784af3cd9abb86b405c69b0a1e8
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sun Sep 6 01:27:29 2026 +0800
fix(connections): write where the agent reads, and show what landed there
---
agentconfig/agentconfig.go | 1 +
agentconfig/layout.go | 108 ++++++++++++++++++++++++++++++-----
controllers/connector.go | 4 ++
object/connection_install.go | 13 +++--
web/src/locales/en/data.json | 12 +++-
web/src/locales/zh/data.json | 11 +++-
web/src/pages/ConnectionsPage.tsx | 115 ++++++++++++++++++++++++++++++++------
web/src/types.ts | 4 +-
8 files changed, 228 insertions(+), 40 deletions(-)
diff --git a/agentconfig/agentconfig.go b/agentconfig/agentconfig.go
index f9aafa7..6c1261b 100644
--- a/agentconfig/agentconfig.go
+++ b/agentconfig/agentconfig.go
@@ -303,6 +303,7 @@ type CopyRequest struct {
const (
ActionCreate = "create"
ActionOverwrite = "overwrite"
+ ActionRemove = "remove"
ActionSkip = "skip"
ActionFailed = "failed"
)
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 3464e7f..7b6523e 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -19,6 +19,7 @@ import (
"path/filepath"
"runtime"
"sort"
+ "strings"
"github.com/apache/casbin-gateway/agenthome"
)
@@ -58,6 +59,10 @@ type skillLayout struct {
// tree that is searched for the skills folders the plugins ship, or the same
// directory inside every project the agent has been run in.
type skillSource struct {
+ // base is the agent's own directory when its home variable can move that
+ // directory; nil for a source that sits straight under the account's home,
+ // as the shared ~/.agents skills do.
+ base agentDir
segments []string
scope string
scan bool
@@ -68,7 +73,14 @@ type skillSource struct {
}
func (s skillSource) dir(home string) string {
- return filepath.Join(append([]string{home}, s.segments...)...)
+ return joinIn(s.base, home, s.segments)
+}
+
+// within puts this source inside a directory the agent's home variable can
+// move, rather than straight under the account's home.
+func (s skillSource) within(base agentDir) skillSource {
+ s.base = base
+ return s
}
// roots is every directory this source contributes: one under the home
@@ -127,11 +139,17 @@ func (l *mcpLayout) path(home string) string {
// The agents spell it differently — CLAUDE.md, AGENTS.md, global_rules.md — and
// hold the same thing in it.
type promptLayout struct {
+ base agentDir
segments []string
}
func (l *promptLayout) path(home string) string {
- return filepath.Join(append([]string{home}, l.segments...)...)
+ return joinIn(l.base, home, l.segments)
+}
+
+func (l *promptLayout) within(base agentDir) *promptLayout {
+ l.base = base
+ return l
}
// promptFile names one agent's instruction file, last segment first so the file
@@ -220,10 +238,10 @@ var layouts = map[string]layout{
// directory, which is the first user source.
"dsh": {
skills: &skillLayout{sources: []skillSource{
- userSkills(".dsh", "skills"),
+ userSkills("skills").within(dshDir),
userSkills(".agents", "skills"),
}},
- mcp: &mcpLayout{file: under(".dsh", "cordis.patch.yml"), store: &cordisStore{}},
+ mcp: &mcpLayout{file: in(dshDir, "cordis.patch.yml"), store: &cordisStore{}},
},
"openclaw": {
skills: &skillLayout{sources: []skillSource{
@@ -256,12 +274,12 @@ var layouts = map[string]layout{
// KIMI_CODE_HOME so the other agents keep reading them.
"kimi-code": {
skills: &skillLayout{sources: []skillSource{
- userSkills(".kimi-code", "skills"),
+ userSkills("skills").within(kimiDir),
userSkills(".agents", "skills"),
- pluginSkills(".kimi-code", "plugins"),
+ pluginSkills("plugins").within(kimiDir),
}},
- mcp: &mcpLayout{file: under(".kimi-code", "mcp.json"), store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
- prompt: promptFile("AGENTS.md", ".kimi-code"),
+ mcp: &mcpLayout{file: in(kimiDir, "mcp.json"), store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
+ prompt: promptFile("AGENTS.md").within(kimiDir),
},
"codebuddy": {
skills: &skillLayout{sources: []skillSource{
@@ -275,11 +293,11 @@ var layouts = map[string]layout{
var codexLayout = layout{
skills: &skillLayout{sources: []skillSource{
- userSkills(".codex", "skills"),
- pluginSkills(".codex", "plugins"),
+ userSkills("skills").within(codexDir),
+ pluginSkills("plugins").within(codexDir),
}},
- mcp: &mcpLayout{file: under(".codex", "config.toml"), store: &tomlStore{table: "mcp_servers"}},
- prompt: promptFile("AGENTS.md", ".codex"),
+ mcp: &mcpLayout{file: in(codexDir, "config.toml"), store: &tomlStore{table: "mcp_servers"}},
+ prompt: promptFile("AGENTS.md").within(codexDir),
}
// The opencode CLI and its desktop app read one ~/.config/opencode, on every
@@ -331,12 +349,76 @@ func McpConfigPath(agentId string, home string) (string, bool) {
return found.mcp.path(home), true
}
+// McpConfigFile is the same file for owner rather than for a home directory the
+// caller resolved, empty for an agent whose MCP configuration Gateway cannot
+// write. The page shows it so an operator can see where a connection landed.
+func McpConfigFile(agentId string, owner string) string {
+ found, ok := layouts[agentId]
+ if !ok || found.mcp == nil {
+ return ""
+ }
+ home, err := homeOf(owner)
+ if err != nil {
+ return ""
+ }
+ return found.mcp.path(home)
+}
+
func under(segments ...string) func(string) string {
return func(home string) string {
- return filepath.Join(append([]string{home}, segments...)...)
+ return joinIn(nil, home, segments)
+ }
+}
+
+// in names a file inside a directory the agent's home variable can move.
+func in(base agentDir, segments ...string) func(string) string {
+ return func(home string) string {
+ return joinIn(base, home, segments)
+ }
+}
+
+func joinIn(base agentDir, home string, segments []string) string {
+ root := home
+ if base != nil {
+ root = base(home)
}
+ return filepath.Join(append([]string{root}, segments...)...)
}
+// agentDir is where one agent keeps its own configuration under an account's
+// home directory.
+type agentDir func(home string) string
+
+// movedBy is such a directory when the agent lets its owner put it somewhere
+// else. Writing to the default while the agent reads the moved one is a write
+// that reports success and changes nothing, which is why the variable is read
+// here rather than assumed unset.
+//
+// The variable belongs to the account Gateway runs as, so it counts only when
+// home is that account's own; another account's dsh is not moved by this one's
+// environment.
+func movedBy(env string, name string) agentDir {
+ return func(home string) string {
+ configured := strings.TrimSpace(os.Getenv(env))
+ if configured != "" && filepath.IsAbs(configured) && isCurrentHome(home) {
+ return filepath.Clean(configured)
+ }
+ return filepath.Join(home, name)
+ }
+}
+
+func isCurrentHome(home string) bool {
+ current, err := os.UserHomeDir()
+ return err == nil && strings.EqualFold(filepath.Clean(current), filepath.Clean(home))
+}
+
+// The agents whose own directory an environment variable moves.
+var (
+ codexDir = movedBy("CODEX_HOME", ".codex")
+ dshDir = movedBy("DSH_HOME", ".dsh")
+ kimiDir = movedBy("KIMI_CODE_HOME", ".kimi-code")
+)
+
// opencodeConfig is the config file to edit. opencode reads config.json,
// opencode.json and opencode.jsonc in that order and merges them, so they are
// tried here in reverse: the last one present is the one whose settings win. A
diff --git a/controllers/connector.go b/controllers/connector.go
index bd37c7d..5a3759d 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -57,6 +57,9 @@ type ConnectorTarget struct {
AgentId string `json:"agentId"`
Name string `json:"name"`
Owner string `json:"owner"`
+ // McpFile is the file a connection is written into for this agent, shown so
+ // an operator can check it against the one their agent actually reads.
+ McpFile string `json:"mcpFile,omitempty"`
}
// ConnectorCatalog is everything the connectors page draws itself from.
@@ -152,6 +155,7 @@ func connectorTargets() ([]*ConnectorTarget, error) {
AgentId: installation.AgentId,
Name: installation.Name,
Owner: installation.Owner,
+ McpFile: agentconfig.McpConfigFile(installation.AgentId, installation.Owner),
})
}
diff --git a/object/connection_install.go b/object/connection_install.go
index 6365e56..b02e899 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -49,7 +49,7 @@ func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfi
request, err := proxyRequest(connection, found, agentId)
if err != nil {
planned = append(planned, &agentconfig.PlanItem{
- AgentId: agentId, Name: found.Server.Name, Action: "skip", Reason: err.Error(),
+ AgentId: agentId, Name: found.Server.Name, Action: agentconfig.ActionFailed, Reason: err.Error(),
})
continue
}
@@ -58,7 +58,7 @@ func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfi
items, err := agentconfig.AddMcp(request)
if err != nil {
planned = append(planned, &agentconfig.PlanItem{
- AgentId: agentId, Name: found.Server.Name, Action: "skip", Reason: err.Error(),
+ AgentId: agentId, Name: found.Server.Name, Action: agentconfig.ActionFailed, Reason: err.Error(),
})
continue
}
@@ -210,9 +210,9 @@ func ResolveConnection(owner string, name string) (*connector.Rendered, error) {
func removeFrom(connection *Connection, serverName string, agentIds []string) []*agentconfig.PlanItem {
planned := []*agentconfig.PlanItem{}
for _, agentId := range agentIds {
- item := &agentconfig.PlanItem{AgentId: agentId, Name: serverName, Action: "remove"}
+ item := &agentconfig.PlanItem{AgentId: agentId, Name: serverName, Action: agentconfig.ActionRemove}
if err := agentconfig.Delete(agentId, connection.Owner, agentconfig.KindMcp, serverName); err != nil {
- item.Action, item.Reason = "skip", err.Error()
+ item.Action, item.Reason = agentconfig.ActionSkip, err.Error()
}
planned = append(planned, item)
}
@@ -236,11 +236,12 @@ func dropped(before []string, after []string) []string {
}
// installed is the agents the write actually reached, so a connection never
-// claims an agent whose configuration could not be written.
+// claims an agent whose configuration could not be written. A skipped agent and
+// a failed one both count as not reached: neither has the entry.
func installed(planned []*agentconfig.PlanItem, agentIds []string) []string {
failed := map[string]bool{}
for _, item := range planned {
- if item.Action == "skip" {
+ if item.Action == agentconfig.ActionSkip || item.Action == agentconfig.ActionFailed {
failed[item.AgentId] = true
}
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 2679f12..8df8495 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -842,7 +842,6 @@
"Callback URL": "Callback URL",
"Callback URL hint": "Register this as the application's callback URL, exactly as shown.",
"Connect": "Connect",
- "Connected": "Connected",
"Connected in_one": "In 1 agent",
"Connected in_other": "In {{count}} agents",
"Connections": "Connections",
@@ -854,11 +853,13 @@
"Disconnected": "Disconnected",
"Documents": "Documents",
"Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.": "Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.",
+ "Failed": "not written",
"From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.": "From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.",
"From context7.com. It works without one, under a stricter rate limit.": "From context7.com. It works without one, under a stricter rate limit.",
"From the Amap open platform. Pick the web service key type, not the one for apps.": "From the Amap open platform. Pick the web service key type, not the one for apps.",
"Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.": "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.",
"Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.": "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.",
+ "In no agent": "In no agent",
"Install into": "Install into",
"Install into hint": "The agents this connection is written into. Unticking one takes the server out of it. Each of them reaches the application through Gateway, so a connection works only while Gateway is running.",
"Integration token": "Integration token",
@@ -873,6 +874,8 @@
"No agent found": "No agent on this machine takes MCP servers Gateway knows how to write.",
"No such connector": "This Gateway has no connector called {name}.",
"Not authorized": "Not authorized yet",
+ "Not written_one": "It could not be written into 1 of the agents you picked.",
+ "Not written_other": "It could not be written into {{count}} of the agents you picked.",
"Nothing matches": "Nothing here matches {query}.",
"Office": "Office",
"Orphan connection": "{name} is connected, but this version of Gateway no longer has that connector",
@@ -887,6 +890,7 @@
"Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.",
"Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.": "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.",
"Storage": "Storage",
+ "Taken out": "taken out of",
"Test": "Test",
"Test failed": "Test failed",
"Test hint": "Starts the server and asks what tools it offers. The first run installs it, which is slow.",
@@ -900,8 +904,12 @@
"Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.": "Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.",
"Waiting for approval": "Waiting for approval...",
"Web service API key": "Web service API key",
+ "Where it went": "Where it went",
"Where these come from": "Where these come from",
- "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.": "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app."
+ "Will remove": "Taking it out of: {names}.",
+ "Will write": "Writing it into: {names}.",
+ "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.": "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.",
+ "Written": "written to"
},
"general": {
"Action": "Action",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index e820733..df2c987 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -914,7 +914,6 @@
"Callback URL": "回调地址",
"Callback URL hint": "把这个地址原样填进应用的回调地址,多一个字符都不行。",
"Connect": "连接",
- "Connected": "已连接",
"Connected in_other": "已装入{{count}}个Agent",
"Connections": "连接",
"Connections detail": "连接常用的应用,让Agent帮你跨工具完成任务。一次连接保存在这里,并写入你勾选的每个Agent。",
@@ -925,11 +924,13 @@
"Disconnected": "已断开",
"Documents": "文档知识",
"Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.": "让Agent操作浏览器:打开页面、点击、填表单、截图、读取控制台和网络请求,用于端到端测试和网页取数。",
+ "Failed": "写入失败",
"From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.": "来自飞书开放平台上的企业自建应用,权限范围由你们的管理员审批。",
"From context7.com. It works without one, under a stricter rate limit.": "在context7.com注册后获取,留空也可使用,但速率限制更严。",
"From the Amap open platform. Pick the web service key type, not the one for apps.": "在高德开放平台申请,注意选择「Web服务」类型的Key,不是应用类型的。",
"Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.": "把一个指定目录交给Agent读写,范围仅限你填的那个路径,不影响机器上的其他文件。",
"Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.": "把Chrome开发者工具交给Agent:录制性能剖面、检查DOM、读取网络请求和控制台报错,用于排查真实页面的问题。",
+ "In no agent": "未装入任何Agent",
"Install into": "装入哪些Agent",
"Install into hint": "这条连接会写入下面勾选的Agent,取消勾选会把对应的MCP服务从它的配置里删掉。它们都通过Gateway访问这个应用,所以Gateway没运行时连接不可用。",
"Integration token": "集成令牌",
@@ -944,6 +945,7 @@
"No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。",
"No such connector": "这个Gateway里没有叫{name}的连接器。",
"Not authorized": "尚未授权",
+ "Not written_other": "有{{count}}个Agent没能写入。",
"Nothing matches": "没有匹配「{query}」的应用。",
"Office": "办公协同",
"Orphan connection": "{name}已连接,但当前版本的Gateway里已经没有这个连接器",
@@ -958,6 +960,7 @@
"Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "搜索地点、规划驾车与步行路线、查询天气,以及把地址转成坐标。",
"Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.": "在Settings→Developer settings→Personal access tokens生成,只勾选你愿意让Agent拥有的权限。",
"Storage": "网盘存储",
+ "Taken out": "已移出",
"Test": "测试连接",
"Test failed": "测试失败",
"Test hint": "启动这个服务并问它提供哪些工具。首次运行要先安装,会比较慢。",
@@ -970,8 +973,12 @@
"Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.": "为Agent提供第三方库的最新文档和代码示例,避免模型凭记忆写出过时的API调用。",
"Waiting for approval": "等待授权完成...",
"Web service API key": "Web服务API Key",
+ "Where it went": "写到了哪里",
"Where these come from": "去哪里获取这些凭据",
- "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.": "在Agent里直接操作飞书:消息与群组、云文档、多维表格、日历和通讯录,凭据来自你自己企业的自建应用。"
+ "Will remove": "将从{names}移出。",
+ "Will write": "将写入{names}。",
+ "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.": "在Agent里直接操作飞书:消息与群组、云文档、多维表格、日历和通讯录,凭据来自你自己企业的自建应用。",
+ "Written": "已写入"
},
"general": {
"Action": "操作",
diff --git a/web/src/pages/ConnectionsPage.tsx b/web/src/pages/ConnectionsPage.tsx
index 59fb756..2e3b9a9 100644
--- a/web/src/pages/ConnectionsPage.tsx
+++ b/web/src/pages/ConnectionsPage.tsx
@@ -14,7 +14,7 @@
import * as React from "react";
import {useSearchParams} from "react-router-dom";
-import {ExternalLink, Plug, Search, TriangleAlert} from "lucide-react";
+import {Check, ExternalLink, Plug, Search, TriangleAlert} from "lucide-react";
import i18next from "i18next";
import * as Setting from "@/Setting";
@@ -31,6 +31,7 @@ import {connectorText as text} from "@/lib/connectors";
import {cn} from "@/lib/utils";
import type {
Account,
+ AgentConfigPlanItem,
ConnectorCatalog,
ConnectorEntry,
ConnectorTarget,
@@ -55,6 +56,19 @@ function iconUrl(icon: string) {
return icon === "" ? "" : `https://www.google.com/s2/favicons?domain=${icon}&sz=64`;
}
+/** What one planned write ended up doing, as the result list reads it out. */
+function planLabel(item: AgentConfigPlanItem) {
+ switch (item.action) {
+ case "create":
+ case "overwrite":
+ return "connector:Written";
+ case "remove":
+ return "connector:Taken out";
+ default:
+ return "connector:Failed";
+ }
+}
+
/**
* The Connections page: every application an agent on this machine can be
* connected to. A connection is stored here once, credentials included, and
@@ -184,6 +198,7 @@ export default function ConnectionsPage({account}: {account: Account}) {
entry={editing}
targets={catalog?.targets ?? []}
onClose={() => setEditing(null)}
+ onRefresh={reload}
onDone={() => {
setEditing(null);
reload();
@@ -271,11 +286,11 @@ function ConnectorCard({entry, onConnect}: {entry: ConnectorEntry; onConnect: ()
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{text(entry.displayName)}</span>
{entry.connected ? (
- <Badge variant="secondary">
- {entry.agents.length > 0
- ? i18next.t("connector:Connected in", {count: entry.agents.length})
- : i18next.t("connector:Connected")}
- </Badge>
+ entry.agents.length > 0 ? (
+ <Badge variant="secondary">{i18next.t("connector:Connected in", {count: entry.agents.length})}</Badge>
+ ) : (
+ <Badge variant="destructive">{i18next.t("connector:In no agent")}</Badge>
+ )
) : null}
{entry.tools && entry.tools.length > 0 ? (
<Badge variant="outline">
@@ -307,29 +322,40 @@ function ConnectDialog({
entry,
targets,
onClose,
+ onRefresh,
onDone,
}: {
account: Account;
entry: ConnectorEntry;
targets: ConnectorTarget[];
onClose: () => void;
+ onRefresh: () => void;
onDone: () => void;
}) {
const fields = entry.auth.fields ?? [];
const isOauth = entry.auth.kind === "oauth2";
const [credentials, setCredentials] = React.useState<Record<string, string>>({});
const [agents, setAgents] = React.useState<string[]>(entry.agents);
+ // installed is the agents the server says this connection actually reached. A
+ // tick is what the operator is asking for; these are what is already there.
+ const [installed, setInstalled] = React.useState<string[]>(entry.agents);
+ const [plan, setPlan] = React.useState<AgentConfigPlanItem[] | null>(null);
const [submitting, setSubmitting] = React.useState(false);
const [authorized, setAuthorized] = React.useState(entry.authorized);
const [awaiting, setAwaiting] = React.useState(false);
const [redirectUri, setRedirectUri] = React.useState("");
+ // syncAgents is for the first read only: this also runs while the dialog is
+ // open, and resetting the ticks there would undo what is being edited.
const load = React.useCallback(
- () =>
+ (syncAgents = false) =>
ConnectorBackend.getConnection(account.name, entry.id).then(response => {
if (response.status === "ok" && response.data) {
setCredentials(response.data.credentials ?? {});
- setAgents(response.data.agents ?? []);
+ setInstalled(response.data.agents ?? []);
+ if (syncAgents) {
+ setAgents(response.data.agents ?? []);
+ }
return (response.data.credentials ?? {})["accessToken"] !== undefined;
}
return false;
@@ -339,7 +365,7 @@ function ConnectDialog({
React.useEffect(() => {
if (entry.connected) {
- load();
+ load(true);
}
}, [entry.connected, load]);
@@ -417,17 +443,35 @@ function ConnectDialog({
.finally(() => setTesting(false));
};
+ // wrote is what happened to an agent the operator asked for. An item about an
+ // agent being let go is not one of these: its entry is meant to go.
+ const wrote = (item: AgentConfigPlanItem) => item.action === "create" || item.action === "overwrite";
+ const missed = (item: AgentConfigPlanItem) => agents.includes(item.agentId) && !wrote(item);
+
const submit = () => {
setSubmitting(true);
+ setPlan(null);
ConnectorBackend.connect({owner: account.name, name: entry.id, credentials: credentials, agents: agents})
.then(response => {
- if (response.status === "ok") {
- Setting.showMessage("success", i18next.t("connector:Testing in background"));
- onDone();
- window.setTimeout(onDone, 12000);
- } else {
+ if (response.status !== "ok") {
Setting.showMessage("error", response.msg ?? "");
+ return;
+ }
+ // A connection that reached none of the agents used to close the dialog
+ // and look exactly like one that worked, so a failure keeps it open with
+ // the file each agent was written to.
+ const planned = response.data ?? [];
+ const failures = planned.filter(missed);
+ if (failures.length > 0) {
+ setPlan(planned);
+ load();
+ onRefresh();
+ Setting.showMessage("error", i18next.t("connector:Not written", {count: failures.length}));
+ return;
}
+ Setting.showMessage("success", i18next.t("connector:Testing in background"));
+ onDone();
+ window.setTimeout(onDone, 12000);
})
.catch(error => Setting.showMessage("error", error.message))
.finally(() => setSubmitting(false));
@@ -448,6 +492,18 @@ function ConnectDialog({
.finally(() => setSubmitting(false));
};
+ const nameOf = (agentId: string) => targets.find(target => target.agentId === agentId)?.name ?? agentId;
+ const adding = agents.filter(agentId => !installed.includes(agentId));
+ const removing = installed.filter(agentId => !agents.includes(agentId));
+ // What pressing Connect would change, so the button is not the only way to
+ // find out. Empty leaves the field's own hint in place.
+ const pending = [
+ adding.length > 0 ? i18next.t("connector:Will write").replace("{names}", adding.map(nameOf).join(", ")) : "",
+ removing.length > 0 ? i18next.t("connector:Will remove").replace("{names}", removing.map(nameOf).join(", ")) : "",
+ ]
+ .filter(part => part !== "")
+ .join(" ");
+
return (
<FormDialog
open={true}
@@ -572,26 +628,34 @@ function ConnectDialog({
<Field
label={i18next.t("connector:Install into")}
- hint={i18next.t("connector:Install into hint")}
+ hint={pending === "" ? i18next.t("connector:Install into hint") : pending}
error={agents.length === 0 ? i18next.t("connector:Pick at least one agent") : undefined}
>
<div className="flex flex-wrap gap-2">
{targets.map(target => {
const picked = agents.includes(target.agentId);
+ // A tick is what will be there; the check mark is what is there now,
+ // which is the difference between asking and having asked.
+ const has = installed.includes(target.agentId);
return (
<button
key={target.agentId}
type="button"
+ title={target.mcpFile}
onClick={() =>
setAgents(picked ? agents.filter(id => id !== target.agentId) : [...agents, target.agentId])
}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors",
- picked ? "border-foreground bg-foreground text-background" : "hover:bg-muted",
+ picked && has && "border-foreground bg-foreground text-background",
+ picked && !has && "border-foreground border-dashed",
+ !picked && has && "border-destructive/60 text-destructive border-dashed line-through",
+ !picked && !has && "hover:bg-muted",
)}
>
<AgentIcon agent={target.agentId} size={16} />
{target.name}
+ {has ? <Check className="size-3.5" /> : null}
</button>
);
})}
@@ -600,6 +664,25 @@ function ConnectDialog({
) : null}
</div>
</Field>
+
+ {plan && plan.length > 0 ? (
+ <Field label={i18next.t("connector:Where it went")}>
+ <ul className="space-y-1.5 text-xs">
+ {plan.map(item => (
+ <li key={`${item.agentId}-${item.action}`} className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
+ <span className="font-medium">{nameOf(item.agentId)}</span>
+ <span className={missed(item) ? "text-destructive" : "text-muted-foreground"}>
+ {i18next.t(planLabel(item))}
+ </span>
+ {item.path ? (
+ <code className="bg-muted min-w-0 rounded px-1.5 py-0.5 break-all">{item.path}</code>
+ ) : null}
+ {item.reason ? <span className="text-destructive break-words">{item.reason}</span> : null}
+ </li>
+ ))}
+ </ul>
+ </Field>
+ ) : null}
</FormDialog>
);
}
diff --git a/web/src/types.ts b/web/src/types.ts
index a109a60..ae47b27 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1420,7 +1420,7 @@ export type SkillInstallMode = "copy" | "link";
/** How an MCP server is reached: a spawned command, or an HTTP endpoint. */
export type McpTransport = "stdio" | "http";
-export type AgentConfigAction = "create" | "overwrite" | "skip" | "failed";
+export type AgentConfigAction = "create" | "overwrite" | "remove" | "skip" | "failed";
/** What a copy would do, or did, to one item at one target agent. */
export interface AgentConfigPlanItem {
@@ -1661,6 +1661,8 @@ export interface ConnectorTarget {
agentId: string;
name: string;
owner: string;
+ /** The file a connection is written into for this agent. */
+ mcpFile?: string;
}
export interface ConnectorCatalog {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org