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/04 13:21:28 UTC

(casbin-gateway) branch master updated: feat(agents): scan PATH and let a program be picked by hand

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 bce8006  feat(agents): scan PATH and let a program be picked by hand
bce8006 is described below

commit bce8006fc58eb34d5c499090edc055cdc7a7698a
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Fri Sep 4 21:21:20 2026 +0800

    feat(agents): scan PATH and let a program be picked by hand
---
 README.md                              |   2 +-
 README_zh.md                           |   2 +-
 agent/manual.go                        | 175 +++++++++++++++++++++++++
 agent/remove.go                        |   5 +-
 agent/scan.go                          |   8 ++
 agent/scan_darwin.go                   |   5 +
 agent/scan_linux.go                    |   5 +
 agent/scan_path.go                     | 110 ++++++++++++++++
 agent/scan_path_unix.go                |  55 ++++++++
 agent/scan_path_windows.go             |  88 +++++++++++++
 agent/scan_windows.go                  |   5 +
 agentinstall/plan.go                   |   4 +
 controllers/agent_path.go              |  83 ++++++++++++
 routers/router.go                      |   3 +
 util/browse.go                         | 115 ++++++++++++++++
 util/browse_unix.go                    |  27 ++++
 util/browse_windows.go                 |  42 ++++++
 web/src/backend/AgentBackend.ts        |  16 +++
 web/src/components/AgentCatalog.tsx    |   4 +
 web/src/components/AgentPathDialog.tsx | 232 +++++++++++++++++++++++++++++++++
 web/src/lib/agents.ts                  |   3 +
 web/src/locales/en/data.json           |  14 ++
 web/src/locales/zh/data.json           |  14 ++
 web/src/pages/AgentsPage.tsx           |   7 +-
 web/src/pages/HomePage.tsx             |   2 +-
 web/src/types.ts                       |  22 ++++
 26 files changed, 1043 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 9ced36a..a4d9c65 100644
--- a/README.md
+++ b/README.md
@@ -208,7 +208,7 @@ Gateway installed some other way, or in a directory it cannot write to, says so
 
 | Page | What you get | What it needs |
 | --- | --- | --- |
-| **Agents** | Every AI coding agent installed on this machine — Claude Code, Codex CLI, Cursor, the Gemini CLI, opencode and more — four cards to a row, each naming the account it is signed in to, the provider it answers to, what it has spent — day by day over the last month, wherever its own transcripts carry that much — and whether it is running right now. Start or stop one from its card, or run several **instances** of the same agent at once, each with a state directory and an accoun [...]
+| **Agents** | Every AI coding agent installed on this machine — Claude Code, Codex CLI, Cursor, the Gemini CLI, opencode and more — four cards to a row, each naming the account it is signed in to, the provider it answers to, what it has spent — day by day over the last month, wherever its own transcripts carry that much — and whether it is running right now. Start or stop one from its card, or run several **instances** of the same agent at once, each with a state directory and an accoun [...]
 | **Agent versions** | Every agent Gateway knows in one table: the build on this machine, the release its package manager publishes, and whether the two differ. Install one, upgrade it, put it back on an older release, or remove it, from the row it is on - in one click, whichever way it was installed: a package manager, the Microsoft Store, the uninstaller the app registered with Windows, the agent's own updater, or the vendor's own install command. The command is shown before it runs, t [...]
 | **Skills, MCP & Prompts** | Every skill, MCP server and instruction file of every agent in one table. Install skills from a GitHub repository, a `.zip` or `.tar.gz`, or a folder on this machine, into one agent or several at once. Add an MCP server the same way, edit the instructions an agent reads before every session, open one, delete it, or copy it into another agent. | Nothing |
 | **Sessions** | Every session those agents have had, read from the transcripts they leave on disk: the whole conversation, message by message. How many there are, how many ran today and how many came from a transcript rather than from monitoring, then filtered down to one agent or one of those two sources. | Nothing |
diff --git a/README_zh.md b/README_zh.md
index 83cdcff..301b6f8 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -206,7 +206,7 @@ Gateway 会在自己的窗口里打开 —— 不用登录:它只服务本机
 
 | 页面 | 你能得到什么 | 需要什么 |
 | --- | --- | --- |
-| **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor、Gemini CLI、opencode 等等 —— 一行四张卡片,每张写明它登录的是哪个账号、接的是哪个 Provider、花了多少(会话记录够长的话,还会按天画出最近一个月的曲线)、此刻是不是在跑。可以在卡片上直接启动或停止,也可以给同一个 Agent 开多个**实例**,每个实例有自己的状态目录和自己的登录账号,同时运行。本机没装的 Agent 也会列出来,可以直接用宿主机已有的包管理器安装或升级。同一个包管理器还会告诉你哪些安装有新版本可用,可以切换到它仍在发布的任意版本 —— 新版本出了问题就退回旧版 —— 也可以卸载,Agent 自己的登录状态和历史记录会原样保留。 | 无 |
+| **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor、Gemini CLI、opencode 等等 —— 一行四张卡片,每张写明它登录的是哪个账号、接的是哪个 Provider、花了多少(会话记录够长的话,还会按天画出最近一个月的曲线)、此刻是不是在跑。可以在卡片上直接启动或停止,也可以给同一个 Agent 开多个**实例**,每个实例有自己的状态目录和自己的登录账号,同时运行。本机没装的 Agent 也会列出来,可以直接用宿主机已有的包管理器安装或升级。同一个包管理器还会告诉你哪些安装有新版本可用,可以切换到它仍在发布的任意版本 —— 新版本出了问题就退回旧版 —— 也可以卸载,Agent 自己的登录状态和历史记录会原样保留。手动解压安装的 Agent 也能发现:除了各安装器写出的目录布局,扫描还会遍历 PATH;两者都覆盖不到的,可�
 ��用**选择程序**在本机浏览并直接指定它的可执行文件,Gateway 会把它作为一个安装列出,不移动也不修改任何文件。 | 无 |
 | **Agent versions** | Gateway 认识的每一个 Agent 都在这张表里:本机装的是哪个版本、包管理器发布到了哪个版本、两者差在哪里。安装、升级、退回旧版本、卸载,都在这一行上一键完成 —— 不管当初是怎么装的:包管理器、微软商店、应用向 Windows 注册的卸载程序、Agent 自带的更新命令,或厂商自己的安装命令。命令运行前先摆出来,运行时进度就在那一行上,它打印的所有内容点一下就能看。 | 无 |
 | **Skills, MCP & Prompts** | 所有 Agent 的所有技能、MCP 服务器和提示词文件汇总在一张表里。可以从 GitHub 仓库、`.zip` / `.tar.gz` 压缩包,或本机的一个目录安装技能,一次装进一个或多个 Agent。MCP 服务器也一样,还能编辑某个 Agent 每次会话前读到的提示词,或者打开、删除、复制到另一个 Agent。 | 无 |
 | **Sessions** | 这些 Agent 的每一次会话,从它们留在磁盘上的会话记录里读出来:完整的对话,一条一条。一共多少次、今天跑了多少次、其中多少条是从会话记录里读来而不是监控到的,都在页头;再按 Agent 或按这两种来源筛。 | 无 |
diff --git a/agent/manual.go b/agent/manual.go
new file mode 100644
index 0000000..6a0ded6
--- /dev/null
+++ b/agent/manual.go
@@ -0,0 +1,175 @@
+// 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 (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"sync"
+
+	"github.com/apache/casbin-gateway/conf"
+)
+
+// InstallMethodManual marks an agent someone pointed Gateway at themselves,
+// which is all that is left for one no layout and no PATH describes.
+const InstallMethodManual = "manual"
+
+type manualEntry struct {
+	AgentId string `json:"agentId"`
+	Path    string `json:"path"`
+	Owner   string `json:"owner,omitempty"`
+}
+
+type manualStore struct {
+	Installations []manualEntry `json:"installations"`
+}
+
+var manualLock sync.Mutex
+
+// manualFile records the chosen programs, beside the other local agent state.
+func manualFile() string {
+	return filepath.Join(conf.GetAgentPatchStateDir(), "manual-agents.json")
+}
+
+// ManualInstallations are the chosen programs that are still there. One since
+// deleted is left out, but its entry stays: restoring the file brings it back.
+func ManualInstallations() []Installation {
+	manualLock.Lock()
+	defer manualLock.Unlock()
+
+	var result []Installation
+	for _, entry := range readManual().Installations {
+		if !IsKnownAgentId(entry.AgentId) || !isPathExecutable(entry.Path) {
+			continue
+		}
+		result = append(result, Installation{
+			AgentId: entry.AgentId, Name: DisplayNameOf(entry.AgentId), Path: entry.Path,
+			InstallMethod: InstallMethodManual, Owner: entry.Owner,
+		})
+	}
+	return result
+}
+
+// AddManualInstallation records one program as an installation of an agent. Only
+// what the host confirms is accepted: a known agent, and a file that can be run.
+func AddManualInstallation(agentId string, path string) (Installation, error) {
+	if !IsKnownAgentId(agentId) {
+		return Installation{}, fmt.Errorf("unknown agent: %s", agentId)
+	}
+	path = strings.Trim(strings.TrimSpace(path), `"`)
+	if path == "" {
+		return Installation{}, errors.New("the program path is empty")
+	}
+	if !filepath.IsAbs(path) {
+		return Installation{}, fmt.Errorf("the program path must be absolute: %s", path)
+	}
+	path = filepath.Clean(path)
+	info, err := os.Stat(path)
+	if err != nil {
+		return Installation{}, fmt.Errorf("the program was not found: %s", path)
+	}
+	if info.IsDir() {
+		return Installation{}, fmt.Errorf("this is a directory, not a program: %s", path)
+	}
+	if !isPathExecutable(path) {
+		return Installation{}, fmt.Errorf("this file cannot be run: %s", path)
+	}
+
+	installation := Installation{
+		AgentId: agentId, Name: DisplayNameOf(agentId), Path: path,
+		InstallMethod: InstallMethodManual, Owner: currentOwner(),
+	}
+
+	manualLock.Lock()
+	defer manualLock.Unlock()
+
+	store := readManual()
+	entries := make([]manualEntry, 0, len(store.Installations)+1)
+	for _, entry := range store.Installations {
+		if entry.AgentId != agentId || !samePath(entry.Path, path) {
+			entries = append(entries, entry)
+		}
+	}
+	// One agent may be installed more than once, so this joins the others.
+	entries = append(entries, manualEntry{AgentId: agentId, Path: path, Owner: installation.Owner})
+	if err := writeManual(manualStore{Installations: entries}); err != nil {
+		return Installation{}, err
+	}
+	invalidateScanCache()
+	return installation, nil
+}
+
+// RemoveManualInstallation forgets one chosen program. Nothing on disk is
+// touched: Gateway was told where it is, not asked to install it.
+func RemoveManualInstallation(agentId string, path string) error {
+	manualLock.Lock()
+	defer manualLock.Unlock()
+
+	store := readManual()
+	entries := make([]manualEntry, 0, len(store.Installations))
+	removed := false
+	for _, entry := range store.Installations {
+		if entry.AgentId == agentId && (path == "" || samePath(entry.Path, path)) {
+			removed = true
+			continue
+		}
+		entries = append(entries, entry)
+	}
+	if !removed {
+		return fmt.Errorf("this agent was not added by hand: %s", agentId)
+	}
+	if err := writeManual(manualStore{Installations: entries}); err != nil {
+		return err
+	}
+	invalidateScanCache()
+	return nil
+}
+
+func readManual() manualStore {
+	store := manualStore{}
+	data, err := os.ReadFile(manualFile())
+	if err != nil {
+		return store
+	}
+	if json.Unmarshal(data, &store) != nil {
+		return manualStore{}
+	}
+	return store
+}
+
+func writeManual(store manualStore) error {
+	path := manualFile()
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return err
+	}
+	data, err := json.MarshalIndent(store, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, append(data, '\n'), 0o600)
+}
+
+func samePath(left, right string) bool {
+	left, right = filepath.Clean(left), filepath.Clean(right)
+	if runtime.GOOS == "windows" {
+		return strings.EqualFold(left, right)
+	}
+	return left == right
+}
diff --git a/agent/remove.go b/agent/remove.go
index 83df02b..da91cd8 100644
--- a/agent/remove.go
+++ b/agent/remove.go
@@ -35,8 +35,11 @@ var sharedInstallDirs = map[string]bool{
 // fingerprint declares for this agent, or, where that directory is shared, the
 // launcher alone. The second result marks which of the two it is. An agent's
 // state directory is never part of the answer.
+// A program someone pointed Gateway at is never deleted: forgetting the row is
+// what removing it means.
 func RemovablePathOf(installation Installation) (string, bool) {
-	if installation.Path == "" || installation.InstallMethod == InstallMethodConfig {
+	if installation.Path == "" || installation.InstallMethod == InstallMethodConfig ||
+		installation.InstallMethod == InstallMethodManual {
 		return "", false
 	}
 
diff --git a/agent/scan.go b/agent/scan.go
index 6ac0fc1..0529be5 100644
--- a/agent/scan.go
+++ b/agent/scan.go
@@ -72,6 +72,14 @@ func Scan(forceRefresh bool) ([]Installation, error) {
 	return cloneInstallations(current.result), current.err
 }
 
+// invalidateScanCache drops the cached result so a change Gateway made itself
+// shows up without waiting the cache out.
+func invalidateScanCache() {
+	installationCache.Lock()
+	installationCache.updatedAt = time.Time{}
+	installationCache.Unlock()
+}
+
 func scanWithTimeout() ([]Installation, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
 	defer cancel()
diff --git a/agent/scan_darwin.go b/agent/scan_darwin.go
index f4bcc97..f9c4dd2 100644
--- a/agent/scan_darwin.go
+++ b/agent/scan_darwin.go
@@ -29,6 +29,7 @@ import (
 // neither executes discovered binaries nor walks arbitrary filesystem roots.
 func scan(ctx context.Context) []Installation {
 	homes := darwinHomes()
+	paths := newPathIndex()
 
 	var installations []Installation
 	for i := range fingerprints {
@@ -49,6 +50,7 @@ func scan(ctx context.Context) []Installation {
 			installations = append(installations, scanDarwinHomebrew(fingerprint, prefix)...)
 			installations = append(installations, scanDarwinSystemNpm(ctx, fingerprint, prefix)...)
 		}
+		installations = append(installations, scanPathDirs(fingerprint, paths, installations[mark:])...)
 		stampAgentId(installations, mark, fingerprint.ID)
 		installations = append(installations, scanStateDirs(fingerprint, homes, installations[mark:])...)
 		fillMissingVersions(installations, mark, fingerprint)
@@ -65,6 +67,9 @@ func scan(ctx context.Context) []Installation {
 	// Last, so that an agent found both on disk and by its port keeps the
 	// richer install-layout row when the two resolve to the same executable.
 	installations = append(installations, scanLocalServers(ctx)...)
+	// After every layout, so a chosen program yields to the same one found where
+	// its installer puts it.
+	installations = append(installations, ManualInstallations()...)
 	result := expandSharedCodexInstallations(dedupeInstallations(installations), homes)
 	fillAccounts(result, homes)
 	return result
diff --git a/agent/scan_linux.go b/agent/scan_linux.go
index 0a9d074..5139a32 100644
--- a/agent/scan_linux.go
+++ b/agent/scan_linux.go
@@ -31,6 +31,7 @@ import (
 // discovered binary or traversing arbitrary filesystem roots.
 func scan(ctx context.Context) []Installation {
 	homes := readHomes("/etc/passwd")
+	paths := newPathIndex()
 
 	var installations []Installation
 	for i := range fingerprints {
@@ -50,6 +51,7 @@ func scan(ctx context.Context) []Installation {
 		installations = append(installations, scanSystemNpm(ctx, fingerprint)...)
 		installations = append(installations, scanSystemPackages(ctx, fingerprint)...)
 		installations = append(installations, scanHomebrew(ctx, fingerprint)...)
+		installations = append(installations, scanPathDirs(fingerprint, paths, installations[mark:])...)
 		stampAgentId(installations, mark, fingerprint.ID)
 		installations = append(installations, scanStateDirs(fingerprint, homes, installations[mark:])...)
 		fillMissingVersions(installations, mark, fingerprint)
@@ -71,6 +73,9 @@ func scan(ctx context.Context) []Installation {
 	// Last, so that an agent found both on disk and by its port keeps the
 	// richer install-layout row when the two resolve to the same executable.
 	installations = append(installations, scanLocalServers(ctx)...)
+	// After every layout, so a chosen program yields to the same one found where
+	// its installer puts it.
+	installations = append(installations, ManualInstallations()...)
 	result := expandSharedCodexInstallations(dedupeInstallations(installations), homes)
 	fillAccounts(result, homes)
 	return result
diff --git a/agent/scan_path.go b/agent/scan_path.go
new file mode 100644
index 0000000..eaf9d8f
--- /dev/null
+++ b/agent/scan_path.go
@@ -0,0 +1,110 @@
+// 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 (
+	"os"
+	"os/user"
+	"path/filepath"
+	"runtime"
+	"strings"
+)
+
+// InstallMethodPath marks an agent found by name on PATH, unpacked by hand into
+// a directory no fingerprint can describe.
+const InstallMethodPath = "path"
+
+// pathIndex maps a lower-case launcher name to the first copy on PATH.
+type pathIndex map[string]string
+
+// newPathIndex reads PATH once for a whole scan.
+func newPathIndex() pathIndex {
+	wanted := map[string]bool{}
+	for i := range fingerprints {
+		for _, name := range pathExecNames(&fingerprints[i]) {
+			wanted[name] = true
+		}
+	}
+	if len(wanted) == 0 {
+		return pathIndex{}
+	}
+
+	index := pathIndex{}
+	for _, dir := range pathDirs() {
+		// Nobody unpacks an agent into the system's own program directories, and
+		// an unrelated program of the same short name does live there.
+		if ownedByOS(dir) {
+			continue
+		}
+		entries, err := os.ReadDir(dir)
+		if err != nil {
+			continue
+		}
+		for _, entry := range entries {
+			name := strings.ToLower(entry.Name())
+			if entry.IsDir() || !wanted[name] || index[name] != "" {
+				continue
+			}
+			path := filepath.Join(dir, entry.Name())
+			if isPathExecutable(path) {
+				index[name] = path
+			}
+		}
+	}
+	return index
+}
+
+// pathExecNames are the names one launcher goes by, program before shim. A
+// windowed app is started from a menu, and an agent with a scan of its own is
+// left to it: that scan reads the program before believing its name.
+func pathExecNames(fingerprint *Fingerprint) []string {
+	if fingerprint.ExecName == "" || fingerprint.Desktop || fingerprint.CustomScan {
+		return nil
+	}
+	name := strings.ToLower(fingerprint.ExecName)
+	if runtime.GOOS != "windows" {
+		return []string{name}
+	}
+	return []string{name + ".exe", name + ".cmd", name + ".bat", name + ".com"}
+}
+
+// scanPathDirs reports an agent found on PATH, only where every other layout
+// came up empty: PATH cannot say which manager owns a tree. Nothing is executed,
+// so a program is identified by its name alone.
+func scanPathDirs(fingerprint *Fingerprint, index pathIndex, found []Installation) []Installation {
+	if len(found) > 0 {
+		return nil
+	}
+	for _, name := range pathExecNames(fingerprint) {
+		path := index[name]
+		if path == "" {
+			continue
+		}
+		return []Installation{{
+			AgentId: fingerprint.ID, Name: fingerprint.DisplayName, Path: path,
+			InstallMethod: InstallMethodPath, Owner: currentOwner(),
+		}}
+	}
+	return nil
+}
+
+// currentOwner is the account Gateway runs as, whose PATH it reads.
+func currentOwner() string {
+	account, err := user.Current()
+	if err != nil {
+		return ""
+	}
+	return account.Username
+}
diff --git a/agent/scan_path_unix.go b/agent/scan_path_unix.go
new file mode 100644
index 0000000..36c81ed
--- /dev/null
+++ b/agent/scan_path_unix.go
@@ -0,0 +1,55 @@
+// 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 agent
+
+import (
+	"os"
+	"path/filepath"
+)
+
+// pathDirs are the absolute directories PATH names, in its own order.
+func pathDirs() []string {
+	var dirs []string
+	seen := map[string]bool{}
+	for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
+		if dir == "" {
+			continue
+		}
+		dir = filepath.Clean(dir)
+		if !filepath.IsAbs(dir) || seen[dir] {
+			continue
+		}
+		seen[dir] = true
+		dirs = append(dirs, dir)
+	}
+	return dirs
+}
+
+// osOwnedDirs are what the system package manager owns. "/usr/local/bin" is not
+// one of them: that is where a person's own install goes.
+var osOwnedDirs = map[string]bool{
+	"/bin": true, "/sbin": true, "/usr/bin": true, "/usr/sbin": true,
+}
+
+func ownedByOS(dir string) bool {
+	return osOwnedDirs[filepath.ToSlash(dir)]
+}
+
+func isPathExecutable(path string) bool {
+	info, err := os.Stat(path)
+	return err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0
+}
diff --git a/agent/scan_path_windows.go b/agent/scan_path_windows.go
new file mode 100644
index 0000000..e44d382
--- /dev/null
+++ b/agent/scan_path_windows.go
@@ -0,0 +1,88 @@
+// 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 agent
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/sys/windows/registry"
+)
+
+// pathDirs are this process's PATH first, then the ones Windows records for the
+// account and the machine: a directory added after the last sign-in is on the
+// stored PATH while being on no environment Gateway inherited.
+func pathDirs() []string {
+	var dirs []string
+	seen := map[string]bool{}
+	add := func(value string) {
+		for _, dir := range filepath.SplitList(value) {
+			dir = strings.Trim(strings.TrimSpace(dir), `"`)
+			if dir == "" {
+				continue
+			}
+			if expanded, err := registry.ExpandString(dir); err == nil {
+				dir = expanded
+			}
+			dir = filepath.Clean(os.ExpandEnv(dir))
+			if !filepath.IsAbs(dir) {
+				continue
+			}
+			key := strings.ToLower(dir)
+			if seen[key] {
+				continue
+			}
+			seen[key] = true
+			dirs = append(dirs, dir)
+		}
+	}
+
+	add(os.Getenv("PATH"))
+	add(storedPath(registry.CURRENT_USER, `Environment`))
+	add(storedPath(registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`))
+	return dirs
+}
+
+func storedPath(root registry.Key, path string) string {
+	key, err := registry.OpenKey(root, path, registry.QUERY_VALUE)
+	if err != nil {
+		return ""
+	}
+	defer key.Close()
+
+	value, _, err := key.GetStringValue("Path")
+	if err != nil {
+		return ""
+	}
+	return value
+}
+
+// ownedByOS covers the Windows directory, which belongs to the system.
+func ownedByOS(dir string) bool {
+	root := os.Getenv("SystemRoot")
+	if root == "" {
+		return false
+	}
+	root = filepath.Clean(root) + string(filepath.Separator)
+	return strings.HasPrefix(strings.ToLower(dir)+string(filepath.Separator), strings.ToLower(root))
+}
+
+func isPathExecutable(path string) bool {
+	info, err := os.Stat(path)
+	return err == nil && info.Mode().IsRegular()
+}
diff --git a/agent/scan_windows.go b/agent/scan_windows.go
index 29de797..e61609e 100644
--- a/agent/scan_windows.go
+++ b/agent/scan_windows.go
@@ -37,6 +37,7 @@ type homeDir struct {
 // neither executes discovered binaries nor walks arbitrary filesystem roots.
 func scan(ctx context.Context) []Installation {
 	homes := windowsHomes(ctx)
+	paths := newPathIndex()
 
 	var installations []Installation
 	for i := range fingerprints {
@@ -58,6 +59,7 @@ func scan(ctx context.Context) []Installation {
 		installations = append(installations, scanWindowsDesktop(ctx, fingerprint, homes)...)
 		installations = append(installations, scanMachineWinget(ctx, fingerprint)...)
 		installations = append(installations, scanMachinePrograms(ctx, fingerprint)...)
+		installations = append(installations, scanPathDirs(fingerprint, paths, installations[mark:])...)
 		stampAgentId(installations, mark, fingerprint.ID)
 		installations = append(installations, scanStateDirs(fingerprint, homes, installations[mark:])...)
 		fillMissingVersions(installations, mark, fingerprint)
@@ -73,6 +75,9 @@ func scan(ctx context.Context) []Installation {
 	// Last, so that an agent found both on disk and by its port keeps the
 	// richer install-layout row when the two resolve to the same executable.
 	installations = append(installations, scanLocalServers(ctx)...)
+	// After every layout, so a chosen program yields to the same one found where
+	// its installer puts it.
+	installations = append(installations, ManualInstallations()...)
 
 	installations = expandSharedCodexWindowsInstallations(dedupeInstallations(installations), homes)
 	// Whatever layout an installation came from, its launcher may carry a
diff --git a/agentinstall/plan.go b/agentinstall/plan.go
index 024ae46..19c57bf 100644
--- a/agentinstall/plan.go
+++ b/agentinstall/plan.go
@@ -180,6 +180,10 @@ func resolve(installation agent.Installation, action string, version string) Pla
 		plan.Detail = "Gateway found this agent's configuration directory but not its program, so there is nothing here to remove"
 		return plan
 	}
+	if action == ActionUninstall && installation.InstallMethod == agent.InstallMethodManual {
+		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
+	}
 
 	notes := []string{}
 	spec := target{installation: installation, packages: packages,
diff --git a/controllers/agent_path.go b/controllers/agent_path.go
new file mode 100644
index 0000000..01caffe
--- /dev/null
+++ b/controllers/agent_path.go
@@ -0,0 +1,83 @@
+// 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 controllers
+
+import (
+	"encoding/json"
+
+	"github.com/apache/casbin-gateway/agent"
+	"github.com/apache/casbin-gateway/util"
+)
+
+// BrowseLocalPath lists one directory of this host, for the file picker that
+// says where an agent no scan can find is installed.
+func (c *ApiController) BrowseLocalPath() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	listing, err := util.BrowseDir(c.GetString("path"))
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(listing)
+}
+
+// AddAgentPath records a program someone chose as an installation of an agent.
+// The host decides whether it is one, not the request.
+func (c *ApiController) AddAgentPath() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	var form struct {
+		AgentId string `json:"agentId"`
+		Path    string `json:"path"`
+	}
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	installation, err := agent.AddManualInstallation(form.AgentId, form.Path)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(installation)
+}
+
+// RemoveAgentPath forgets one. The program stays where it is.
+func (c *ApiController) RemoveAgentPath() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	var form struct {
+		AgentId string `json:"agentId"`
+		Path    string `json:"path"`
+	}
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	if err := agent.RemoveManualInstallation(form.AgentId, form.Path); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+	c.ResponseOk(form.AgentId)
+}
diff --git a/routers/router.go b/routers/router.go
index 4574bca..9714398 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -67,6 +67,9 @@ func initAPI() {
 	beego.Router("/api/set-agent-version", &controllers.ApiController{}, "POST:SetAgentVersion")
 	beego.Router("/api/uninstall-agent", &controllers.ApiController{}, "POST:UninstallAgent")
 	beego.Router("/api/get-agent-versions", &controllers.ApiController{}, "GET:GetAgentVersions")
+	beego.Router("/api/browse-local-path", &controllers.ApiController{}, "GET:BrowseLocalPath")
+	beego.Router("/api/add-agent-path", &controllers.ApiController{}, "POST:AddAgentPath")
+	beego.Router("/api/remove-agent-path", &controllers.ApiController{}, "POST:RemoveAgentPath")
 	beego.Router("/api/get-agent-updates", &controllers.ApiController{}, "GET:GetAgentUpdates")
 	beego.Router("/api/patch-agent", &controllers.ApiController{}, "POST:PatchAgent")
 	beego.Router("/api/unpatch-agent", &controllers.ApiController{}, "POST:UnpatchAgent")
diff --git a/util/browse.go b/util/browse.go
new file mode 100644
index 0000000..c4b82f3
--- /dev/null
+++ b/util/browse.go
@@ -0,0 +1,115 @@
+// 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 util
+
+import (
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+// browseLimit bounds one listing: nobody picks a program out of a longer one.
+const browseLimit = 2000
+
+// BrowseEntry is one file or directory of a listing.
+type BrowseEntry struct {
+	Name string `json:"name"`
+	Path string `json:"path"`
+	Dir  bool   `json:"dir"`
+	Size int64  `json:"size,omitempty"`
+	// Executable marks a file this host would run, the only kind worth choosing.
+	Executable bool `json:"executable,omitempty"`
+}
+
+// BrowseListing is one directory as a file picker needs it.
+type BrowseListing struct {
+	Path string `json:"path"`
+	// Parent is empty at the top of a tree, where there is nowhere to go up to.
+	Parent string `json:"parent"`
+	Home   string `json:"home"`
+	// Roots are where a walk can start: the drives on Windows, "/" elsewhere.
+	Roots   []string      `json:"roots"`
+	Entries []BrowseEntry `json:"entries"`
+	// Truncated marks a directory with more in it than was listed.
+	Truncated bool `json:"truncated,omitempty"`
+}
+
+// BrowseDir lists one directory of this host. An empty path starts in the home
+// of the account Gateway runs as.
+func BrowseDir(path string) (*BrowseListing, error) {
+	home, _ := os.UserHomeDir()
+	path = strings.Trim(strings.TrimSpace(path), `"`)
+	if path == "" {
+		path = home
+	}
+	path = filepath.Clean(path)
+	if !filepath.IsAbs(path) {
+		abs, err := filepath.Abs(path)
+		if err != nil {
+			return nil, err
+		}
+		path = abs
+	}
+
+	entries, err := os.ReadDir(path)
+	if err != nil {
+		return nil, err
+	}
+
+	listing := &BrowseListing{Path: path, Home: home, Roots: browseRoots(),
+		Entries: []BrowseEntry{}}
+	if parent := filepath.Dir(path); parent != path {
+		listing.Parent = parent
+	}
+	for _, entry := range entries {
+		if len(listing.Entries) >= browseLimit {
+			listing.Truncated = true
+			break
+		}
+		item := BrowseEntry{Name: entry.Name(), Path: filepath.Join(path, entry.Name()),
+			Dir: entry.IsDir()}
+		if !item.Dir {
+			info, err := entry.Info()
+			if err != nil {
+				continue
+			}
+			// A symlink to a program is worth choosing; a socket or device is not.
+			if !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 {
+				continue
+			}
+			if info.Mode()&os.ModeSymlink != 0 {
+				target, err := os.Stat(item.Path)
+				if err != nil {
+					continue
+				}
+				item.Dir = target.IsDir()
+				info = target
+			}
+			item.Size = info.Size()
+			item.Executable = !item.Dir && isExecutableFile(item.Path, info)
+		}
+		listing.Entries = append(listing.Entries, item)
+	}
+
+	sort.Slice(listing.Entries, func(i, j int) bool {
+		left, right := listing.Entries[i], listing.Entries[j]
+		if left.Dir != right.Dir {
+			return left.Dir
+		}
+		return strings.ToLower(left.Name) < strings.ToLower(right.Name)
+	})
+	return listing, nil
+}
diff --git a/util/browse_unix.go b/util/browse_unix.go
new file mode 100644
index 0000000..b166648
--- /dev/null
+++ b/util/browse_unix.go
@@ -0,0 +1,27 @@
+// 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 util
+
+import "os"
+
+func browseRoots() []string {
+	return []string{"/"}
+}
+
+func isExecutableFile(_ string, info os.FileInfo) bool {
+	return info.Mode()&0o111 != 0
+}
diff --git a/util/browse_windows.go b/util/browse_windows.go
new file mode 100644
index 0000000..ca97ccc
--- /dev/null
+++ b/util/browse_windows.go
@@ -0,0 +1,42 @@
+// 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 util
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// browseRoots are the drives a Windows walk can start from.
+func browseRoots() []string {
+	roots := []string{}
+	for letter := 'A'; letter <= 'Z'; letter++ {
+		root := string(letter) + `:\`
+		if _, err := os.Stat(root); err == nil {
+			roots = append(roots, root)
+		}
+	}
+	return roots
+}
+
+// windowsExecutables are the extensions Windows runs, minus the script ones.
+var windowsExecutables = map[string]bool{".exe": true, ".cmd": true, ".bat": true, ".com": true}
+
+func isExecutableFile(path string, _ os.FileInfo) bool {
+	return windowsExecutables[strings.ToLower(filepath.Ext(path))]
+}
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 90644e3..e59ef25 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -27,6 +27,7 @@ import type {
   AgentUpdate,
   AgentUsage,
   AgentVersionCatalog,
+  BrowseListing,
 } from "@/types";
 
 export interface PatchTarget {
@@ -68,6 +69,21 @@ export function uninstallAgent(target: PatchTarget) {
   return request<AgentInstallJob>("/api/uninstall-agent", "POST", target);
 }
 
+/** Lists one directory of the host, for picking a program out of it. */
+export function browseLocalPath(path = "") {
+  return request<BrowseListing>(`/api/browse-local-path${query({path: path})}`);
+}
+
+/** Records a program someone chose as an installation of an agent. */
+export function addAgentPath(agentId: string, path: string) {
+  return request<Agent>("/api/add-agent-path", "POST", {agentId: agentId, path: path});
+}
+
+/** Forgets one such program, leaving what is on disk alone. */
+export function removeAgentPath(agentId: string, path: string) {
+  return request<string>("/api/remove-agent-path", "POST", {agentId: agentId, path: path});
+}
+
 /** The releases one agent's package manager publishes, newest first. */
 export function getAgentVersions(agentId: string, installMethod = "", forceRefresh = false) {
   return request<AgentVersionCatalog>(
diff --git a/web/src/components/AgentCatalog.tsx b/web/src/components/AgentCatalog.tsx
index df8cd55..e699a4d 100644
--- a/web/src/components/AgentCatalog.tsx
+++ b/web/src/components/AgentCatalog.tsx
@@ -27,9 +27,12 @@ export function AgentCatalog({
   agents,
   enabled = true,
   installer,
+  onLocated,
 }: {
   agents: Agent[];
   enabled?: boolean;
+  /** Called once a program is picked, which is when a rescan drops its row. */
+  onLocated?: () => void;
   /**
    * The page's own installer. It is passed in rather than made here: the page
    * upgrades the agents it already has through the same one, and two of them
@@ -59,6 +62,7 @@ export function AgentCatalog({
             job={jobs[item.agentId]}
             busy={busyId === item.agentId}
             onInstall={install}
+            onLocated={onLocated}
           />
         ))}
       </div>
diff --git a/web/src/components/AgentPathDialog.tsx b/web/src/components/AgentPathDialog.tsx
new file mode 100644
index 0000000..17d7d26
--- /dev/null
+++ b/web/src/components/AgentPathDialog.tsx
@@ -0,0 +1,232 @@
+// 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 * as React from "react";
+import {ChevronUp, EyeOff, FileCode2, Folder, FolderSearch, HardDrive, Home} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as Setting from "@/Setting";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Input} from "@/components/ui/input";
+import type {Agent, BrowseListing} from "@/types";
+
+/**
+ * The picker for an agent no layout and no PATH entry describes. The host does
+ * the listing: a browser cannot read a real path out of a file input, and the
+ * machine being looked at is the one Gateway runs on.
+ */
+export function AgentPathDialog({
+  agentId,
+  name,
+  trigger,
+  onAdded,
+}: {
+  agentId: string;
+  name: string;
+  /** The control that opens it, for a caller whose row has its own shape. */
+  trigger?: React.ReactNode;
+  onAdded?: () => void;
+}) {
+  const [open, setOpen] = React.useState(false);
+  const [listing, setListing] = React.useState<BrowseListing>();
+  const [loading, setLoading] = React.useState(false);
+  const [error, setError] = React.useState("");
+  const [chosen, setChosen] = React.useState("");
+  const [saving, setSaving] = React.useState(false);
+
+  const browse = React.useCallback((path: string) => {
+    setLoading(true);
+    setError("");
+    AgentBackend.browseLocalPath(path)
+      .then(res => {
+        if (res.status === "ok" && res.data) {
+          setListing(res.data);
+        } else {
+          setError(res.msg ?? "");
+        }
+      })
+      .catch(() => setError(i18next.t("agent:This folder could not be read")))
+      .then(() => setLoading(false));
+  }, []);
+
+  React.useEffect(() => {
+    if (open) {
+      setChosen("");
+      browse("");
+    }
+  }, [open, browse]);
+
+  // The path is passed in: a double-click fires before the click that chose it
+  // has rendered.
+  function save(path = chosen) {
+    setSaving(true);
+    AgentBackend.addAgentPath(agentId, path)
+      .then(res => {
+        if (res.status !== "ok") {
+          Setting.showMessage("error", res.msg ?? "");
+          return;
+        }
+        Setting.showMessage("success", i18next.t("agent:Agent program added"));
+        setOpen(false);
+        onAdded?.();
+      })
+      .catch(error => Setting.showMessage("error", String(error)))
+      .then(() => setSaving(false));
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        {trigger ?? (
+          <Button size="sm" variant="outline">
+            <FolderSearch />
+            {i18next.t("agent:Locate")}
+          </Button>
+        )}
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>{i18next.t("agent:Locate {agent}").replace("{agent}", name)}</DialogTitle>
+          <DialogDescription>{i18next.t("agent:Locate hint")}</DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-2 text-sm">
+          <div className="flex flex-wrap items-center gap-1.5">
+            <Button
+              size="sm"
+              variant="outline"
+              disabled={!listing?.parent || loading}
+              onClick={() => browse(listing?.parent ?? "")}
+            >
+              <ChevronUp />
+              {i18next.t("agent:Up")}
+            </Button>
+            <Button size="sm" variant="ghost" disabled={loading} onClick={() => browse("")}>
+              <Home />
+              {i18next.t("agent:Home folder")}
+            </Button>
+            {(listing?.roots ?? []).map(root => (
+              <Button
+                key={root}
+                size="sm"
+                variant="ghost"
+                disabled={loading}
+                onClick={() => browse(root)}
+              >
+                <HardDrive />
+                {root}
+              </Button>
+            ))}
+          </div>
+
+          <code className="text-muted-foreground block truncate text-xs">{listing?.path ?? ""}</code>
+
+          <div className="h-64 overflow-y-auto rounded-md border">
+            {(listing?.entries ?? []).map(entry => (
+              <button
+                key={entry.path}
+                type="button"
+                disabled={!entry.dir && !entry.executable}
+                className={`hover:bg-muted flex w-full items-center gap-2 px-2 py-1 text-left disabled:opacity-40 ${
+                  chosen === entry.path ? "bg-muted" : ""
+                }`}
+                onClick={() => (entry.dir ? browse(entry.path) : setChosen(entry.path))}
+                onDoubleClick={() => (!entry.dir && entry.executable ? save(entry.path) : undefined)}
+              >
+                {entry.dir ? (
+                  <Folder className="text-muted-foreground size-4 shrink-0" />
+                ) : (
+                  <FileCode2 className="text-muted-foreground size-4 shrink-0" />
+                )}
+                <span className="min-w-0 flex-1 truncate text-xs">{entry.name}</span>
+              </button>
+            ))}
+            {!loading && (listing?.entries.length ?? 0) === 0 ? (
+              <p className="text-muted-foreground p-3 text-xs">
+                {error || i18next.t("agent:This folder is empty")}
+              </p>
+            ) : null}
+          </div>
+
+          {error && listing?.entries.length ? (
+            <p className="text-destructive text-xs">{error}</p>
+          ) : null}
+
+          {/* A program nobody wants to walk to is pasted instead. */}
+          <Input
+            value={chosen}
+            placeholder={i18next.t("agent:Program path placeholder")}
+            onChange={event => setChosen(event.target.value)}
+          />
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => setOpen(false)}>
+            {i18next.t("general:Cancel")}
+          </Button>
+          <Button disabled={!chosen.trim()} loading={saving} onClick={() => save()}>
+            {i18next.t("agent:Use this program")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/** The way out for a chosen program: it forgets the row, and deletes nothing. */
+export function AgentPathForgetButton({
+  agent,
+  onRemoved,
+}: {
+  agent: Agent;
+  onRemoved?: () => void;
+}) {
+  function forget() {
+    return AgentBackend.removeAgentPath(agent.agentId, agent.path)
+      .then(res => {
+        if (res.status !== "ok") {
+          Setting.showMessage("error", res.msg ?? "");
+          return;
+        }
+        Setting.showMessage("success", i18next.t("agent:Agent program forgotten"));
+        onRemoved?.();
+      })
+      .catch(error => Setting.showMessage("error", String(error)));
+  }
+
+  return (
+    <ConfirmDialog
+      title={i18next.t("agent:Forget {agent}?").replace("{agent}", agent.name)}
+      description={i18next.t("agent:Forget hint")}
+      confirmText={i18next.t("agent:Forget")}
+      onConfirm={forget}
+    >
+      <Button size="sm" variant="outline">
+        <EyeOff />
+        {i18next.t("agent:Forget")}
+      </Button>
+    </ConfirmDialog>
+  );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 94c7f72..c061cc0 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -42,6 +42,9 @@ export const builtinProvider = "-";
 export const gatewayMode = "gateway";
 export const directMode = "direct";
 
+/** A program someone pointed Gateway at, forgotten rather than uninstalled. */
+export const manualMethod = "manual";
+
 /**
  * What the agent talks to with nothing bound: the model its own configuration
  * names, or the service it signs in to. Binding nothing puts it back on this.
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index b84fd5d..675b034 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -34,6 +34,8 @@
     "Agent installed": "Agent installed",
     "Agent moved to an older version": "Agent moved to an older version",
     "Agent path": "Agent path",
+    "Agent program added": "The program was added",
+    "Agent program forgotten": "The program was removed from the list",
     "Agent started": "Agent started",
     "Agent stopped": "Agent stopped",
     "Agent uninstalled": "Agent uninstalled",
@@ -128,6 +130,9 @@
     "Find files by name": "Find files by name",
     "Finished": "Finished",
     "First activity": "First activity",
+    "Forget": "Forget",
+    "Forget hint": "This only removes the row. The program stays where it is, since Gateway did not install it.",
+    "Forget {agent}?": "Forget {agent}?",
     "From the transcript": "From the transcript",
     "Gateway": "Gateway",
     "Gateway mode hint": "The agent is pointed at the local proxy, so switching to another provider takes effect on the next request without restarting the agent.",
@@ -135,6 +140,7 @@
     "Gemini CLI config hint": "The Gemini CLI reads no base URL from a settings file: the endpoint is GOOGLE_GEMINI_BASE_URL in ~/.gemini/.env, with GEMINI_API_KEY beside it and \"security.auth.selectedType\": \"gemini-api-key\" in ~/.gemini/settings.json. A project of its own .env is read first, and that one wins.",
     "Gemini gateway only hint": "The Gemini CLI speaks only Google's generateContent API, which no provider here serves. Gateway serves it by translating to the API the provider answers, so the CLI cannot be pointed at a provider directly.",
     "Getting started": "Getting started",
+    "Home folder": "Home",
     "Image": "Image",
     "In use": "In use",
     "Install": "Install",
@@ -161,6 +167,9 @@
     "Latest version": "Latest version",
     "Leave plan mode": "Leave plan mode",
     "List a directory": "List a directory",
+    "Locate": "Locate",
+    "Locate hint": "Pick the agent's program on this machine. Gateway lists it as an installation of its own, without moving or changing anything.",
+    "Locate {agent}": "Locate {agent}",
     "Log": "Log",
     "Look at an image": "Look at an image",
     "MCP servers": "MCP servers",
@@ -217,6 +226,7 @@
     "Preview": "Preview",
     "Preview hint": "What Gateway would write, with the keys masked. Everything else in these files is left as it is.",
     "Processes": "Processes",
+    "Program path placeholder": "Or paste the full path to the program",
     "Prompt ID": "Prompt ID",
     "Provider": "Provider",
     "Provider enabled": "Provider enabled",
@@ -283,6 +293,8 @@
     "Terminal": "Terminal",
     "The internet": "The internet",
     "Thinking": "Thinking",
+    "This folder could not be read": "This folder could not be read",
+    "This folder is empty": "This folder is empty",
     "This transcript holds no conversation": "This transcript holds no conversation",
     "Time": "Time",
     "Token hint": "Gateway authenticates upstream with the provider's own key. This value is Gateway's own relay token, which a request from another machine has to carry.",
@@ -304,6 +316,7 @@
     "Unrestricted": "Unrestricted",
     "Unrestricted hint": "This agent may ask for any model, reach any provider and use every tool.",
     "Unrouted permission hint": "This agent's own configuration does not point at Gateway, so its requests never reach it and none of these rules apply. Bind it to a provider on the Agents page.",
+    "Up": "Up",
     "Up to date": "Up to date",
     "Update manually": "Update manually",
     "Updates available": "Updates available",
@@ -312,6 +325,7 @@
     "Upgrade {agent}?": "Upgrade {agent}?",
     "Upgrading": "Upgrading",
     "Usage": "Usage",
+    "Use this program": "Use this program",
     "User": "User",
     "Vendor page": "Vendor page",
     "Version": "Version",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 63d04ac..f93ef9d 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -34,6 +34,8 @@
     "Agent installed": "安装完成",
     "Agent moved to an older version": "已降级 Agent",
     "Agent path": "Agent 路径",
+    "Agent program added": "已添加该程序",
+    "Agent program forgotten": "已从列表中移除该程序",
     "Agent started": "已启动",
     "Agent stopped": "已停止",
     "Agent uninstalled": "已卸载 Agent",
@@ -128,6 +130,9 @@
     "Find files by name": "按文件名查找",
     "Finished": "已完成",
     "First activity": "首次活动",
+    "Forget": "移出列表",
+    "Forget hint": "只会移除这一行。程序仍留在原处,Gateway 并没有安装它。",
+    "Forget {agent}?": "将 {agent} 移出列表?",
     "From the transcript": "来自会话记录",
     "Gateway": "网关",
     "Gateway mode hint": "把 Agent 指向本地代理,切换供应商在下一次请求即生效,无需重启 Agent。",
@@ -135,6 +140,7 @@
     "Gemini CLI config hint": "Gemini CLI 不从配置文件读取 Base URL:端点是 ~/.gemini/.env 中的 GOOGLE_GEMINI_BASE_URL,旁边是 GEMINI_API_KEY,同时 ~/.gemini/settings.json 中写入 \"security.auth.selectedType\": \"gemini-api-key\"。项目自带的 .env 会被优先读取并覆盖它。",
     "Gemini gateway only hint": "Gemini CLI 只会说 Google 的 generateContent API,这里没有供应商提供该 API。Gateway 会把它翻译成供应商应答的 API,所以 Gemini CLI 无法直连供应商。",
     "Getting started": "快速上手",
+    "Home folder": "主目录",
     "Image": "图片",
     "In use": "使用中",
     "Install": "安装",
@@ -161,6 +167,9 @@
     "Latest version": "最新版本",
     "Leave plan mode": "退出计划模式",
     "List a directory": "列出目录",
+    "Locate": "选择程序",
+    "Locate hint": "在本机选择该智能体的可执行程序。Gateway 会把它作为一个安装列出,不会移动或修改任何文件。",
+    "Locate {agent}": "选择 {agent} 的程序",
     "Log": "日志",
     "Look at an image": "查看图片",
     "MCP servers": "MCP 服务",
@@ -217,6 +226,7 @@
     "Preview": "预览",
     "Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
     "Processes": "进程",
+    "Program path placeholder": "或直接粘贴程序的完整路径",
     "Prompt ID": "Prompt ID",
     "Provider": "供应商",
     "Provider enabled": "已启用供应商",
@@ -283,6 +293,8 @@
     "Terminal": "终端",
     "The internet": "访问网络",
     "Thinking": "思考",
+    "This folder could not be read": "无法读取该目录",
+    "This folder is empty": "该目录为空",
     "This transcript holds no conversation": "该会话记录里没有对话内容",
     "Time": "时间",
     "Token hint": "Gateway 是用供应商自己的密钥去认证上游的。这里的值是 Gateway 自己的中继令牌,来自其他机器的请求必须带上它。",
@@ -304,6 +316,7 @@
     "Unrestricted": "不受限制",
     "Unrestricted hint": "该智能体可以使用任意模型、任意供应商和全部工具。",
     "Unrouted permission hint": "该智能体自己的配置没有指向网关,请求不会经过网关,这里的规则不会生效。请先在「智能体」页面为它绑定一个供应商。",
+    "Up": "上一级",
     "Up to date": "已是最新",
     "Update manually": "手动更新",
     "Updates available": "可更新",
@@ -312,6 +325,7 @@
     "Upgrade {agent}?": "升级 {agent}?",
     "Upgrading": "升级中",
     "Usage": "用量",
+    "Use this program": "使用该程序",
     "User": "用户",
     "Vendor page": "官方安装页",
     "Version": "版本",
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 485ba13..7e00e76 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -19,6 +19,7 @@ import i18next from "i18next";
 import * as Setting from "@/Setting";
 import {AgentCatalog} from "@/components/AgentCatalog";
 import {AgentIcon} from "@/components/AgentIcon";
+import {AgentPathForgetButton} from "@/components/AgentPathDialog";
 import {RunBadge, RunButton} from "@/components/AgentRunControl";
 import {
   AgentUpdateBadge,
@@ -39,6 +40,7 @@ import {
   agentDetailPath,
   agentKey,
   directMode,
+  manualMethod,
   monitorAgentId,
   runtimeOf,
   updateOf,
@@ -228,6 +230,9 @@ export default function AgentsPage({account}: {account: Account}) {
             onConfirm={() => installer.uninstall(record)}
           />
           {patchButton(record)}
+          {record.installMethod === manualMethod ? (
+            <AgentPathForgetButton agent={record} onRemoved={() => scan(true)} />
+          ) : null}
         </div>
       ),
     },
@@ -320,7 +325,7 @@ export default function AgentsPage({account}: {account: Account}) {
         }
       />
 
-      <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
+      <AgentCatalog agents={agents} enabled={scanned} installer={installer} onLocated={() => scan(true)} />
     </PageContainer>
   );
 }
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 2dcd4f9..ee763a0 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -311,7 +311,7 @@ export default function HomePage({account}: {account: Account}) {
       )}
 
       {/* What is not here yet, so an empty machine has somewhere to go. */}
-      <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
+      <AgentCatalog agents={agents} enabled={scanned} installer={installer} onLocated={() => scan(true)} />
     </PageContainer>
   );
 }
diff --git a/web/src/types.ts b/web/src/types.ts
index 74919a3..75c2ce6 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -362,6 +362,28 @@ export interface AgentCatalogEntry {
   install: AgentInstallPlan;
 }
 
+/** One file or directory of a host listing, for the program picker. */
+export interface BrowseEntry {
+  name: string;
+  path: string;
+  dir: boolean;
+  size?: number;
+  /** True for a file this host would run, the only kind to pick. */
+  executable?: boolean;
+}
+
+/** One directory of the host, listed for choosing a program in it. */
+export interface BrowseListing {
+  path: string;
+  /** Empty at the top of a tree. */
+  parent: string;
+  home: string;
+  /** Where a walk can start: the drives on Windows, "/" elsewhere. */
+  roots: string[];
+  entries: BrowseEntry[];
+  truncated?: boolean;
+}
+
 /** The live processes of one agent installation, keyed by owner and path. */
 export interface AgentRuntime {
   agentId: string;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org