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]

Reply via email to