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 de5e9c2 fix(agents): find the CLI a desktop app downloads for itself
de5e9c2 is described below
commit de5e9c2432b8f91142eac037faa49998d2ba72e8
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 21:43:33 2026 +0800
fix(agents): find the CLI a desktop app downloads for itself
---
agent/fingerprint.go | 11 +++-
agent/fingerprints/claude-code.json | 3 +
agent/scan_bundled.go | 101 +++++++++++++++++++++++++++++++++
agent/scan_darwin.go | 7 +++
agent/scan_linux.go | 7 +++
agent/scan_windows.go | 5 ++
agentinstall/plan.go | 4 ++
web/src/components/AgentGridCard.tsx | 3 +
web/src/components/AgentRunControl.tsx | 71 ++++++++++++++++-------
web/src/locales/en/data.json | 1 +
web/src/locales/zh/data.json | 1 +
web/src/pages/AgentDetailPage.tsx | 5 +-
web/src/pages/AgentsPage.tsx | 1 +
web/src/pages/HomePage.tsx | 1 +
14 files changed, 198 insertions(+), 23 deletions(-)
diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 8f7c830..7fcded4 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -59,9 +59,14 @@ type Fingerprint struct {
WingetPackage string `json:"wingetPackage,omitempty"`
// MsStorePackage is the Microsoft Store product id, which is how winget
// installs an agent published nowhere else.
- MsStorePackage string `json:"msStorePackage,omitempty"`
- MSIXFamily string `json:"msixFamily,omitempty"`
- DesktopInstallerDir string `json:"desktopInstallerDir,omitempty"`
+ MsStorePackage string `json:"msStorePackage,omitempty"`
+ MSIXFamily string `json:"msixFamily,omitempty"`
+ DesktopInstallerDir string `json:"desktopInstallerDir,omitempty"`
+ // BundledDirs are directories another app keeps its own copy of this
agent
+ // in, under the account's application data - %APPDATA% on Windows,
+ // ~/Library/Application Support on macOS, ~/.config elsewhere. A "*"
segment
+ // is the directory these apps keep one of per release.
+ BundledDirs []string `json:"bundledDirs,omitempty"`
WindowsProgramDirs []string `json:"windowsProgramDirs,omitempty"`
WindowsUserDirs []string `json:"windowsUserDirs,omitempty"`
HomeDirs []string `json:"homeDirs,omitempty"`
diff --git a/agent/fingerprints/claude-code.json
b/agent/fingerprints/claude-code.json
index b699619..34cd951 100644
--- a/agent/fingerprints/claude-code.json
+++ b/agent/fingerprints/claude-code.json
@@ -6,6 +6,9 @@
"instanceEnv": "CLAUDE_CONFIG_DIR",
"stateDir": "claude",
"npmPackage": "@anthropic-ai/claude-code",
+ "bundledDirs": [
+ "Claude/claude-code/*"
+ ],
"wingetPackage": "Anthropic.ClaudeCode",
"homebrewCasks": [
"claude-code",
diff --git a/agent/scan_bundled.go b/agent/scan_bundled.go
new file mode 100644
index 0000000..3d86fb9
--- /dev/null
+++ b/agent/scan_bundled.go
@@ -0,0 +1,101 @@
+// 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 agent
+
+import (
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+)
+
+// InstallMethodBundled marks a copy of an agent that another app downloaded
for
+// its own use, under that app's data directory. It runs like any other copy,
+// but the app that put it there is what updates and removes it.
+const InstallMethodBundled = "bundled"
+
+// scanBundledDirs reports such a copy. Without it a desktop app that ships the
+// CLI leaves nothing but the state directory the CLI writes, and an agent used
+// every day is listed as one Gateway cannot start.
+func scanBundledDirs(fingerprint *Fingerprint, home homeDir) []Installation {
+ root := appDataRoot(home)
+ if fingerprint.ExecName == "" || root == "" {
+ return nil
+ }
+
+ name := fingerprint.ExecName
+ if runtime.GOOS == "windows" {
+ name += ".exe"
+ }
+
+ var result []Installation
+ for _, dir := range fingerprint.BundledDirs {
+ matches, _ := filepath.Glob(filepath.Join(root,
filepath.FromSlash(dir), name))
+ launcher, version := newestRelease(matches)
+ if launcher == "" {
+ continue
+ }
+ result = append(result, Installation{
+ Name: fingerprint.DisplayName, Version: version, Path:
launcher,
+ InstallMethod: InstallMethodBundled, Owner: home.owner,
+ })
+ }
+ return result
+}
+
+// newestRelease picks the launcher with the highest version among copies these
+// apps keep one directory of per release, which is the one the app itself
runs.
+func newestRelease(paths []string) (string, string) {
+ launcher, version := "", ""
+ for _, path := range paths {
+ if !isPathExecutable(path) {
+ continue
+ }
+ release := releaseVersion(filepath.Base(filepath.Dir(path)))
+ if launcher == "" || compareReleases(release, version) > 0 {
+ launcher, version = path, release
+ }
+ }
+ return launcher, version
+}
+
+// releaseVersion is the version a release directory is named after. A launcher
+// sitting in a directory named anything else has no version to read here.
+func releaseVersion(name string) string {
+ version := sanitizeVersion(name)
+ if version == "" || version[0] < '0' || version[0] > '9' {
+ return ""
+ }
+ return version
+}
+
+// compareReleases orders two versions by their numbers, left to right.
+func compareReleases(left string, right string) int {
+ leftParts, rightParts := strings.Split(left, "."), strings.Split(right,
".")
+ for i := 0; i < len(leftParts) || i < len(rightParts); i++ {
+ if diff := releaseSegment(leftParts, i) -
releaseSegment(rightParts, i); diff != 0 {
+ return diff
+ }
+ }
+ return 0
+}
+
+func releaseSegment(parts []string, index int) int {
+ if index >= len(parts) {
+ return 0
+ }
+ number, _ := strconv.Atoi(parts[index])
+ return number
+}
diff --git a/agent/scan_darwin.go b/agent/scan_darwin.go
index f9c4dd2..6ae4402 100644
--- a/agent/scan_darwin.go
+++ b/agent/scan_darwin.go
@@ -43,6 +43,7 @@ func scan(ctx context.Context) []Installation {
return installations
}
installations = append(installations,
scanNative(fingerprint, home)...)
+ installations = append(installations,
scanBundledDirs(fingerprint, home)...)
installations = append(installations,
scanHomeDirs(fingerprint, home)...)
installations = append(installations,
scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path),
home.owner, fileOwner)...)
}
@@ -75,6 +76,12 @@ func scan(ctx context.Context) []Installation {
return result
}
+// appDataRoot is where an app keeps the data of one account, which is where a
+// copy of an agent it ships goes.
+func appDataRoot(home homeDir) string {
+ return filepath.Join(home.path, "Library", "Application Support")
+}
+
func darwinHomes() []homeDir {
seen := map[string]bool{}
var homes []homeDir
diff --git a/agent/scan_linux.go b/agent/scan_linux.go
index 5139a32..5feb311 100644
--- a/agent/scan_linux.go
+++ b/agent/scan_linux.go
@@ -44,6 +44,7 @@ func scan(ctx context.Context) []Installation {
if ctx.Err() != nil {
return installations
}
+ installations = append(installations,
scanBundledDirs(fingerprint, home)...)
installations = append(installations,
scanNative(fingerprint, home)...)
installations = append(installations,
scanHomeDirs(fingerprint, home)...)
installations = append(installations,
scanNpmPatterns(ctx, fingerprint, userNpmPatterns(fingerprint, home.path),
home.owner, fileOwner)...)
@@ -81,6 +82,12 @@ func scan(ctx context.Context) []Installation {
return result
}
+// appDataRoot is where an app keeps the data of one account, which is where a
+// copy of an agent it ships goes.
+func appDataRoot(home homeDir) string {
+ return filepath.Join(home.path, ".config")
+}
+
func readHomes(passwdPath string) []homeDir {
data, err := os.ReadFile(passwdPath)
if err != nil {
diff --git a/agent/scan_windows.go b/agent/scan_windows.go
index f40d24d..32fb577 100644
--- a/agent/scan_windows.go
+++ b/agent/scan_windows.go
@@ -53,6 +53,7 @@ func scan(ctx context.Context) []Installation {
return installations
}
installations = append(installations,
scanWindowsNative(fingerprint, home)...)
+ installations = append(installations,
scanBundledDirs(fingerprint, home)...)
installations = append(installations,
scanWindowsWinget(ctx, fingerprint, home)...)
installations = append(installations,
scanWindowsNpm(ctx, fingerprint, home)...)
installations = append(installations,
scanWindowsUserPrograms(ctx, fingerprint, home)...)
@@ -179,6 +180,10 @@ func appDataDir(home homeDir, kind, variable string)
string {
func localAppData(home homeDir) string { return appDataDir(home, "Local",
"LOCALAPPDATA") }
func roamingAppData(home homeDir) string { return appDataDir(home, "Roaming",
"APPDATA") }
+// appDataRoot is where an app keeps the data of one account, which is where a
+// copy of an agent it ships goes.
+func appDataRoot(home homeDir) string { return roamingAppData(home) }
+
func scanHermesWindows(home homeDir) []Installation {
root := filepath.Join(hermes.Home(home.path), hermes.ProjectDir)
launcher := filepath.Join(root, "venv", "Scripts",
hermes.ExecName+".exe")
diff --git a/agentinstall/plan.go b/agentinstall/plan.go
index 19c57bf..312b0a2 100644
--- a/agentinstall/plan.go
+++ b/agentinstall/plan.go
@@ -184,6 +184,10 @@ func resolve(installation agent.Installation, action
string, version string) Pla
plan.Detail = "Gateway was pointed at this program rather than
installing it, so it is removed from the list rather than from disk"
return plan
}
+ if installation.InstallMethod == agent.InstallMethodBundled {
+ plan.Detail = "This copy belongs to the app that downloaded it,
which is what updates and removes it"
+ return plan
+ }
notes := []string{}
spec := target{installation: installation, packages: packages,
diff --git a/web/src/components/AgentGridCard.tsx
b/web/src/components/AgentGridCard.tsx
index 757008a..607415a 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -195,6 +195,8 @@ export function AgentGridCard({
busy: boolean;
runBusy: boolean;
onEnable: (providerId: string) => void;
+ /** Called once a program is picked for an agent nothing here can start. */
+ onLocated?: () => void;
onToggleRunning: (agent: Agent, running: boolean) => void;
onTogglePatch: () => void;
}) {
@@ -365,6 +367,7 @@ export function AgentGridCard({
status={status}
busy={runBusy}
className="h-7 px-2 text-xs"
+ onLocated={onLocated}
onToggle={onToggleRunning}
/>
diff --git a/web/src/components/AgentRunControl.tsx
b/web/src/components/AgentRunControl.tsx
index 25d1924..3812592 100644
--- a/web/src/components/AgentRunControl.tsx
+++ b/web/src/components/AgentRunControl.tsx
@@ -12,9 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {Play, Square} from "lucide-react";
+import {FolderSearch, Play, Square} from "lucide-react";
import i18next from "i18next";
+import {AgentPathDialog} from "@/components/AgentPathDialog";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
@@ -22,6 +23,15 @@ import {SimpleTooltip} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
import type {Agent, AgentRuntime} from "@/types";
+/**
+ * Why an installation is not running. A missing program is the one answer the
+ * user can act on, so it is said in their own language rather than passed
+ * through from the host.
+ */
+function stoppedHint(status: AgentRuntime) {
+ return status.canStart ? status.detail : i18next.t("agent:No program hint");
+}
+
/** Whether the installation has live processes, with its pids on hover. */
export function RunBadge({status}: {status?: AgentRuntime}) {
if (!status) {
@@ -29,7 +39,7 @@ export function RunBadge({status}: {status?: AgentRuntime}) {
}
if (!status.running) {
return (
- <SimpleTooltip title={status.detail}>
+ <SimpleTooltip title={stoppedHint(status)}>
<span>
<Badge variant="muted">{i18next.t("agent:Stopped")}</Badge>
</span>
@@ -57,7 +67,7 @@ export function RunDot({status}: {status?: AgentRuntime}) {
const state = i18next.t(status.running ? "agent:Running" : "agent:Stopped");
const detail = status.running
? `${i18next.t("agent:Processes")}: ${status.pids.join(", ")}`
- : status.detail;
+ : stoppedHint(status);
return (
<SimpleTooltip title={detail ? `${state} · ${detail}` : state}>
@@ -74,12 +84,17 @@ export function RunDot({status}: {status?: AgentRuntime}) {
/**
* The start/stop control. Starting is harmless enough to happen on the click,
* while stopping ends work in progress and is confirmed first.
+ *
+ * An installation found by the state directory it left behind has no program
to
+ * run, and a greyed-out button is a dead end there: it offers the picker that
+ * points Gateway at the program instead.
*/
export function RunButton({
agent,
status,
busy,
className,
+ onLocated,
onToggle,
}: {
agent: Agent;
@@ -87,6 +102,8 @@ export function RunButton({
busy: boolean;
/** The cards run it smaller than a table row does. */
className?: string;
+ /** Called once a program is picked, which is when a rescan can start it. */
+ onLocated?: () => void;
onToggle: (agent: Agent, running: boolean) => void;
}) {
if (status?.running) {
@@ -105,22 +122,38 @@ export function RunButton({
);
}
- const startable = status?.canStart === true;
+ // Only once the status is in: until then nothing is known about a launcher.
+ if (status !== undefined && !status.canStart) {
+ return (
+ <SimpleTooltip title={i18next.t("agent:No program hint")}>
+ <span>
+ <AgentPathDialog
+ agentId={agent.agentId}
+ name={agent.name}
+ onAdded={onLocated}
+ trigger={
+ <Button size="sm" variant="outline" className={className}>
+ <FolderSearch />
+ {i18next.t("agent:Locate")}
+ </Button>
+ }
+ />
+ </span>
+ </SimpleTooltip>
+ );
+ }
+
return (
- <SimpleTooltip title={startable ? "" : status?.detail}>
- <span>
- <Button
- size="sm"
- variant="outline"
- className={className}
- disabled={!startable}
- loading={busy}
- onClick={() => onToggle(agent, false)}
- >
- <Play />
- {i18next.t("agent:Start")}
- </Button>
- </span>
- </SimpleTooltip>
+ <Button
+ size="sm"
+ variant="outline"
+ className={className}
+ disabled={status === undefined}
+ loading={busy}
+ onClick={() => onToggle(agent, false)}
+ >
+ <Play />
+ {i18next.t("agent:Start")}
+ </Button>
);
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 675b034..db4389b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -190,6 +190,7 @@
"No instances yet": "No instances yet - add one to sign in with a second
account",
"No other provider speaks this API": "No other provider speaks this API",
"No output yet": "No output yet",
+ "No program hint": "Gateway found this agent's files but not its program,
so there is nothing here to start. Pick the program and it can be started from
here.",
"No provider yet": "No provider has been added yet.",
"No rule yet": "no rule yet",
"No supported agents found": "No supported agents found",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index f93ef9d..2fc8d83 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -190,6 +190,7 @@
"No instances yet": "暂无实例 - 添加一个即可用第二个账号登录",
"No other provider speaks this API": "没有其他供应商使用相同的 API 协议",
"No output yet": "暂无输出",
+ "No program hint": "网关找到了该智能体的数据目录,但没有找到它的可执行程序,因此无法从这里启动。选择程序后即可启动。",
"No provider yet": "还没有添加任何供应商。",
"No rule yet": "还没有任何规则",
"No supported agents found": "没有发现本机安装的智能体",
diff --git a/web/src/pages/AgentDetailPage.tsx
b/web/src/pages/AgentDetailPage.tsx
index 92b7fc7..a9b80c0 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -147,11 +147,13 @@ function RuntimeCard({
agent,
status,
busy,
+ onLocated,
onToggle,
}: {
agent: Agent;
status?: AgentRuntime;
busy: boolean;
+ onLocated: () => void;
onToggle: (agent: Agent, running: boolean) => void;
}) {
return (
@@ -174,7 +176,7 @@ function RuntimeCard({
</p>
)}
- <RunButton agent={agent} status={status} busy={busy}
onToggle={onToggle} />
+ <RunButton agent={agent} status={status} busy={busy}
onLocated={onLocated} onToggle={onToggle} />
</CardContent>
</Card>
);
@@ -474,6 +476,7 @@ export default function AgentDetailPage({account}:
{account: Account}) {
agent={agent}
status={runtimeOf(runtime, agent)}
busy={runBusyKey === agentKey(agent)}
+ onLocated={() => scan(true)}
onToggle={toggleRunning}
/>
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 7e00e76..68ad762 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -205,6 +205,7 @@ export default function AgentsPage({account}: {account:
Account}) {
agent={record}
status={runtimeOf(runtime, record)}
busy={runBusyKey === agentKey(record)}
+ onLocated={() => scan(true)}
onToggle={toggleRunning}
/>
<ToolUpgradeConfirmDialog
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index ee763a0..faca348 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -303,6 +303,7 @@ export default function HomePage({account}: {account:
Account}) {
busy={busyKey === agentKey(agent)}
runBusy={runBusyKey === agentKey(agent)}
onEnable={providerId => activateProvider(agent, providerId)}
+ onLocated={() => scan(true)}
onToggleRunning={toggleRunning}
onTogglePatch={() => togglePatch(agent)}
/>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]