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 9375b60 feat(agent): install and upgrade agent CLIs from the pages
9375b60 is described below
commit 9375b600238c247425ba77cdae4d97fe8fea914a
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 23:07:11 2026 +0800
feat(agent): install and upgrade agent CLIs from the pages
---
agent/agent.go | 37 ++++
agentinstall/job.go | 203 +++++++++++++++++++++
agentinstall/plan.go | 223 ++++++++++++++++++++++++
agentinstall/run_unix.go | 23 +++
agentinstall/run_windows.go | 28 +++
controllers/agent.go | 20 ++-
controllers/agent_install.go | 83 +++++++++
routers/router.go | 3 +
web/src/backend/AgentBackend.ts | 16 ++
web/src/components/AgentCatalog.tsx | 72 +++-----
web/src/components/ToolInstallRow.tsx | 107 ++++++++++++
web/src/components/ToolUpgradeConfirmDialog.tsx | 89 ++++++++++
web/src/lib/agents.ts | 102 +++++++++++
web/src/locales/en/data.json | 12 +-
web/src/locales/zh/data.json | 12 +-
web/src/pages/AgentDetailPage.tsx | 15 +-
web/src/pages/AgentsPage.tsx | 13 +-
web/src/pages/HomePage.tsx | 14 +-
web/src/types.ts | 38 ++++
19 files changed, 1059 insertions(+), 51 deletions(-)
diff --git a/agent/agent.go b/agent/agent.go
index 0c4fa99..b5aa980 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -84,3 +84,40 @@ func IsKnownAgentId(id string) bool {
}
return false
}
+
+// Packages are the package-manager ids one agent is published under, which is
+// what installing or upgrading it needs. Empty fields mean the agent is not
+// published there, so that manager cannot install it.
+type Packages struct {
+ Npm string
+ Winget string
+ HomebrewCask string
+ System string
+ InstallUrl string
+ Desktop bool
+}
+
+// PackagesOf reads the fingerprints rather than a host scan, so the packages
of
+// an agent are known while it is not installed - which is when installing it
is
+// what someone wants.
+func PackagesOf(id string) Packages {
+ for i := range fingerprints {
+ if fingerprints[i].ID != id {
+ continue
+ }
+ packages := Packages{
+ Npm: fingerprints[i].NpmPackage,
+ Winget: fingerprints[i].WingetPackage,
+ System: fingerprints[i].SystemPackage,
+ InstallUrl: fingerprints[i].InstallUrl,
+ Desktop: fingerprints[i].Desktop,
+ }
+ // The rest of the list are version-pinned aliases of the
first, which
+ // is the cask an install should ask for.
+ if casks := fingerprints[i].HomebrewCasks; len(casks) > 0 {
+ packages.HomebrewCask = casks[0]
+ }
+ return packages
+ }
+ return Packages{}
+}
diff --git a/agentinstall/job.go b/agentinstall/job.go
new file mode 100644
index 0000000..9b5c876
--- /dev/null
+++ b/agentinstall/job.go
@@ -0,0 +1,203 @@
+// 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 agentinstall
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/agent"
+)
+
+const (
+ // jobTimeout bounds one install: a package manager that is waiting for
+ // something is never going to get it here, since nothing is attached
to its
+ // input.
+ jobTimeout = 15 * time.Minute
+ // maxOutput is the tail of the manager's own output kept for the page.
The
+ // end is what says why an install failed.
+ maxOutput = 16 * 1024
+)
+
+// Job is one install or upgrade, running or finished. The page polls it rather
+// than holding a request open for as long as a package manager takes.
+type Job struct {
+ AgentId string `json:"agentId"`
+ Name string `json:"name"`
+ Action string `json:"action"`
+ Manager string `json:"manager"`
+ Command string `json:"command"`
+ Running bool `json:"running"`
+ // Ok is the outcome of a finished job, false while one runs.
+ Ok bool `json:"ok"`
+ // Output is the tail of what the package manager printed, on both
streams.
+ Output string `json:"output"`
+ Error string `json:"error,omitempty"`
+ StartTime string `json:"startTime"`
+ EndTime string `json:"endTime,omitempty"`
+}
+
+type job struct {
+ sync.Mutex
+ Job
+ output tail
+}
+
+var jobs = struct {
+ sync.Mutex
+ byAgent map[string]*job
+}{byAgent: map[string]*job{}}
+
+// Start runs a plan in the background and reports the job it started. One
agent
+// runs one job at a time: a second install of the same package would fight the
+// first over the same directory.
+func Start(plan Plan) (Job, error) {
+ if !plan.Available {
+ return Job{}, errors.New(plan.Detail)
+ }
+
+ jobs.Lock()
+ if running, ok := jobs.byAgent[plan.AgentId]; ok &&
running.snapshot().Running {
+ jobs.Unlock()
+ return Job{}, errors.New("this agent is already being installed
or upgraded")
+ }
+ started := &job{Job: Job{
+ AgentId: plan.AgentId,
+ Name: agent.DisplayNameOf(plan.AgentId),
+ Action: plan.Action,
+ Manager: plan.Manager,
+ Command: plan.Command,
+ Running: true,
+ StartTime: now(),
+ }}
+ jobs.byAgent[plan.AgentId] = started
+ jobs.Unlock()
+
+ go run(started, plan)
+ return started.snapshot(), nil
+}
+
+// JobOf is the running or last finished job of one agent.
+func JobOf(agentId string) (Job, bool) {
+ jobs.Lock()
+ defer jobs.Unlock()
+
+ found, ok := jobs.byAgent[agentId]
+ if !ok {
+ return Job{}, false
+ }
+ return found.snapshot(), true
+}
+
+// Jobs lists every job of this process, newest first.
+func Jobs() []Job {
+ jobs.Lock()
+ result := make([]Job, 0, len(jobs.byAgent))
+ for _, item := range jobs.byAgent {
+ result = append(result, item.snapshot())
+ }
+ jobs.Unlock()
+
+ sort.SliceStable(result, func(left, right int) bool {
+ return result[left].StartTime > result[right].StartTime
+ })
+ return result
+}
+
+func run(started *job, plan Plan) {
+ ctx, cancel := context.WithTimeout(context.Background(), jobTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, plan.program, plan.args...)
+ cmd.Stdout, cmd.Stderr = &started.output, &started.output
+ // A manager run from the agent's own tree would pick up a project's
+ // configuration; the home directory is the neutral one.
+ if home, err := os.UserHomeDir(); err == nil {
+ cmd.Dir = home
+ }
+ // Nothing is attached to the input, so a manager that asks a question
would
+ // hang until the timeout. These say to answer none.
+ cmd.Env = append(os.Environ(), "CI=1", "NO_COLOR=1",
"npm_config_yes=true", "HOMEBREW_NO_AUTO_UPDATE=1")
+ hideWindow(cmd)
+
+ err := cmd.Run()
+ if ctx.Err() != nil {
+ err = errors.New("the install did not finish within " +
jobTimeout.String())
+ }
+
+ started.Lock()
+ started.Running = false
+ started.Ok = err == nil
+ if err != nil {
+ started.Error = err.Error()
+ }
+ started.EndTime = now()
+ started.Unlock()
+
+ // Whatever the outcome, what is on disk changed: the next listing has
to be
+ // read from the host rather than from the scan that ran before this.
+ _, _ = agent.Scan(true)
+}
+
+func (j *job) snapshot() Job {
+ j.Lock()
+ defer j.Unlock()
+
+ result := j.Job
+ result.Output = j.output.String()
+ return result
+}
+
+// tail keeps the last maxOutput bytes of a stream, so an install that prints a
+// progress bar for ten minutes cannot grow without bound.
+type tail struct {
+ sync.Mutex
+ buffer bytes.Buffer
+}
+
+func (t *tail) Write(data []byte) (int, error) {
+ t.Lock()
+ defer t.Unlock()
+
+ written := len(data)
+ t.buffer.Write(data)
+ if t.buffer.Len() > maxOutput {
+ kept := t.buffer.Bytes()[t.buffer.Len()-maxOutput:]
+ trimmed := bytes.Buffer{}
+ trimmed.Write(kept)
+ t.buffer = trimmed
+ }
+ return written, nil
+}
+
+func (t *tail) String() string {
+ t.Lock()
+ defer t.Unlock()
+
+ // Carriage returns are how a progress bar overwrites its own line, and
they
+ // read as blank lines once the output is shown as text.
+ return strings.TrimSpace(strings.ReplaceAll(t.buffer.String(), "\r",
"\n"))
+}
+
+func now() string {
+ return time.Now().Format(time.RFC3339)
+}
diff --git a/agentinstall/plan.go b/agentinstall/plan.go
new file mode 100644
index 0000000..972ff44
--- /dev/null
+++ b/agentinstall/plan.go
@@ -0,0 +1,223 @@
+// 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 agentinstall installs and upgrades agent CLIs with the package
+// managers the host already has. Everything it runs is built from the
+// fingerprints compiled into Gateway, never from a request.
+package agentinstall
+
+import (
+ "os/exec"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/agent"
+)
+
+// The actions a plan describes.
+const (
+ ActionInstall = "install"
+ ActionUpgrade = "upgrade"
+)
+
+// The package managers Gateway can drive. They are also the install methods a
+// scan reports, which is how an upgrade finds the one that owns a tree.
+const (
+ ManagerNpm = "npm"
+ ManagerWinget = "winget"
+ ManagerHomebrew = "homebrew"
+)
+
+// Plan is the command that would install or upgrade one agent here, and, when
+// there is none, what is missing.
+type Plan struct {
+ AgentId string `json:"agentId"`
+ Action string `json:"action"`
+ Manager string `json:"manager,omitempty"`
+ // Command is the command line as it will run, shown before it does.
+ Command string `json:"command,omitempty"`
+ Available bool `json:"available"`
+ Detail string `json:"detail,omitempty"`
+ // InstallUrl is the vendor's own page, which is all that is left for an
+ // agent no package manager here can install.
+ InstallUrl string `json:"installUrl,omitempty"`
+
+ program string
+ args []string
+}
+
+// InstallPlan picks the manager that can install an agent this host does not
+// have: the one it is published on, whose own program is on PATH.
+func InstallPlan(agentId string) Plan {
+ packages := agent.PackagesOf(agentId)
+ plan := Plan{AgentId: agentId, Action: ActionInstall, InstallUrl:
packages.InstallUrl}
+
+ for _, manager := range installOrder() {
+ switch manager {
+ case ManagerNpm:
+ if packages.Npm == "" {
+ continue
+ }
+ if program := lookup("npm"); program != "" {
+ return fill(plan, ManagerNpm, program,
"install", "-g", packages.Npm+"@latest")
+ }
+ case ManagerHomebrew:
+ if packages.HomebrewCask == "" {
+ continue
+ }
+ if program := lookup("brew"); program != "" {
+ return fill(plan, ManagerHomebrew, program,
"install", "--cask", packages.HomebrewCask)
+ }
+ case ManagerWinget:
+ if packages.Winget == "" {
+ continue
+ }
+ if program := lookup("winget"); program != "" {
+ return fill(plan, ManagerWinget, program,
append([]string{"install", "--id", packages.Winget, "--exact"},
wingetFlags...)...)
+ }
+ }
+ }
+
+ plan.Detail = unavailableDetail(packages)
+ return plan
+}
+
+// UpgradePlan upgrades an installation in place, through the manager that put
+// it there: another one would install a second copy beside it.
+func UpgradePlan(agentId string, installMethod string) Plan {
+ packages := agent.PackagesOf(agentId)
+ plan := Plan{AgentId: agentId, Action: ActionUpgrade, InstallUrl:
packages.InstallUrl}
+
+ switch installMethod {
+ case ManagerNpm:
+ if packages.Npm == "" {
+ break
+ }
+ if program := lookup("npm"); program != "" {
+ return fill(plan, ManagerNpm, program, "install", "-g",
packages.Npm+"@latest")
+ }
+ plan.Detail = "npm installed this agent but is not on Gateway's
PATH"
+ return plan
+ case ManagerHomebrew:
+ if packages.HomebrewCask == "" {
+ break
+ }
+ if program := lookup("brew"); program != "" {
+ return fill(plan, ManagerHomebrew, program, "upgrade",
"--cask", packages.HomebrewCask)
+ }
+ plan.Detail = "Homebrew installed this agent but is not on
Gateway's PATH"
+ return plan
+ case ManagerWinget:
+ if packages.Winget == "" {
+ break
+ }
+ if program := lookup("winget"); program != "" {
+ return fill(plan, ManagerWinget, program,
append([]string{"upgrade", "--id", packages.Winget, "--exact"},
wingetFlags...)...)
+ }
+ plan.Detail = "winget installed this agent but is not on
Gateway's PATH"
+ return plan
+ }
+
+ plan.Detail = "this agent was installed as \"" + installMethod + "\",
which Gateway cannot upgrade; use its own updater"
+ return plan
+}
+
+// wingetFlags keep an install unattended: winget otherwise stops on a licence
+// prompt no one is there to answer.
+var wingetFlags = []string{
+ "--silent",
+ "--accept-package-agreements",
+ "--accept-source-agreements",
+ "--disable-interactivity",
+}
+
+// installOrder prefers the manager that installs into the user's own account,
+// since Gateway does not run as an administrator: npm with a user prefix on
+// Windows and Linux, Homebrew on macOS, where it owns its own tree.
+func installOrder() []string {
+ switch runtime.GOOS {
+ case "darwin":
+ return []string{ManagerHomebrew, ManagerNpm}
+ case "windows":
+ return []string{ManagerNpm, ManagerWinget}
+ default:
+ return []string{ManagerNpm, ManagerHomebrew}
+ }
+}
+
+func fill(plan Plan, manager string, program string, args ...string) Plan {
+ plan.Manager = manager
+ plan.program = program
+ plan.args = args
+ plan.Command = manager + " " + strings.Join(args, " ")
+ plan.Available = true
+ return plan
+}
+
+// unavailableDetail says which of the two is missing: the package, or the
+// manager that would install it.
+func unavailableDetail(packages agent.Packages) string {
+ var managers []string
+ if packages.Npm != "" {
+ managers = append(managers, "npm")
+ }
+ if packages.HomebrewCask != "" && runtime.GOOS != "windows" {
+ managers = append(managers, "Homebrew")
+ }
+ if packages.Winget != "" && runtime.GOOS == "windows" {
+ managers = append(managers, "winget")
+ }
+ if len(managers) == 0 {
+ if packages.Desktop {
+ return "this is a desktop app, downloaded from its
vendor rather than installed by a package manager"
+ }
+ return "no package manager on this platform publishes this
agent"
+ }
+ return "install " + strings.Join(managers, " or ") + " first, or
install the agent from its vendor's page"
+}
+
+// lookupTTL bounds how long a PATH search is trusted, so a manager installed
+// after Gateway started is picked up without a restart.
+const lookupTTL = 30 * time.Second
+
+var lookups = struct {
+ sync.Mutex
+ found map[string]lookupResult
+}{found: map[string]lookupResult{}}
+
+type lookupResult struct {
+ program string
+ at time.Time
+}
+
+// lookup resolves a manager to an absolute program, which is what the job
runs:
+// a bare name would be looked up again in whatever directory it is started in.
+// Every agent listing asks for the same few managers, and a PATH search walks
+// every directory on it, so the answers are held briefly.
+func lookup(name string) string {
+ lookups.Lock()
+ defer lookups.Unlock()
+
+ if cached, ok := lookups.found[name]; ok && time.Since(cached.at) <
lookupTTL {
+ return cached.program
+ }
+ program, err := exec.LookPath(name)
+ if err != nil {
+ program = ""
+ }
+ lookups.found[name] = lookupResult{program: program, at: time.Now()}
+ return program
+}
diff --git a/agentinstall/run_unix.go b/agentinstall/run_unix.go
new file mode 100644
index 0000000..32a232f
--- /dev/null
+++ b/agentinstall/run_unix.go
@@ -0,0 +1,23 @@
+// 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 agentinstall
+
+import "os/exec"
+
+// hideWindow has nothing to hide off Windows: a package manager started
without
+// a terminal opens no window of its own.
+func hideWindow(*exec.Cmd) {}
diff --git a/agentinstall/run_windows.go b/agentinstall/run_windows.go
new file mode 100644
index 0000000..af6bcf0
--- /dev/null
+++ b/agentinstall/run_windows.go
@@ -0,0 +1,28 @@
+// 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 agentinstall
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// hideWindow keeps the console a package manager would open to itself: the
+// output is read from the pipes and shown on the page instead.
+func hideWindow(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 46a1deb..227f748 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -26,6 +26,7 @@ import (
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agenthistory"
"github.com/apache/casbin-gateway/agenthome"
+ "github.com/apache/casbin-gateway/agentinstall"
"github.com/apache/casbin-gateway/agentmonitor"
"github.com/apache/casbin-gateway/agentpatch"
"github.com/apache/casbin-gateway/agentprovider"
@@ -50,6 +51,9 @@ type discoveredAgent struct {
// page cannot build it from its own address: an agent that runs in a
sandbox
// is given this host's network address rather than loopback.
ProxyBaseUrl string `json:"proxyBaseUrl"`
+ // Upgrade is the command that would update this installation in place,
+ // through the package manager that installed it.
+ Upgrade agentinstall.Plan `json:"upgrade"`
}
// GetAgents scans known installation locations and returns the AI agents
@@ -85,6 +89,7 @@ func (c *ApiController) GetAgents() {
Fallbacks: []string{},
Mode: object.ModeGateway,
ProviderConfig:
agentprovider.StatusOf(providerTarget(target)),
+ Upgrade:
agentinstall.UpgradePlan(installation.AgentId, installation.InstallMethod),
}
baseUrl, ok := baseUrls[installation.AgentId]
if !ok {
@@ -114,7 +119,20 @@ func (c *ApiController) GetAgentCatalog() {
return
}
- c.ResponseOk(agent.KnownAgents())
+ catalog := agent.KnownAgents()
+ result := make([]*catalogEntry, 0, len(catalog))
+ for _, known := range catalog {
+ result = append(result, &catalogEntry{Known: known, Install:
agentinstall.InstallPlan(known.AgentId)})
+ }
+ c.ResponseOk(result)
+}
+
+// catalogEntry is one known agent with the command that would install it here,
+// so a machine missing an agent is offered the install rather than only told
+// where to read about it.
+type catalogEntry struct {
+ agent.Known
+ Install agentinstall.Plan `json:"install"`
}
// UpdateAgentRouting binds one agent to the provider its requests are
forwarded
diff --git a/controllers/agent_install.go b/controllers/agent_install.go
new file mode 100644
index 0000000..571346d
--- /dev/null
+++ b/controllers/agent_install.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/agentinstall"
+)
+
+// InstallAgent installs an agent this machine does not have, with a package
+// manager it does. The command comes from the fingerprint of the requested
+// agent id and nothing else, so a request names an agent, never a command.
+func (c *ApiController) InstallAgent() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var form struct {
+ AgentId string `json:"agentId"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if !agent.IsKnownAgentId(form.AgentId) {
+ c.ResponseError("unknown agent: " + form.AgentId)
+ return
+ }
+
+ job, err := agentinstall.Start(agentinstall.InstallPlan(form.AgentId))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(job)
+}
+
+// UpgradeAgent updates one discovered installation through the package manager
+// that installed it. The manager runs as the account Gateway runs as, so an
+// installation found in another user's home is updated only where that account
+// shares the same global prefix.
+func (c *ApiController) UpgradeAgent() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ installation, ok := c.readAgentInstallation()
+ if !ok {
+ return
+ }
+
+ job, err :=
agentinstall.Start(agentinstall.UpgradePlan(installation.AgentId,
installation.InstallMethod))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(job)
+}
+
+// GetAgentInstallJobs reports the installs and upgrades of this process, the
+// ones still running included. A package manager takes minutes, so the page
+// polls this instead of waiting on the request that started the job.
+func (c *ApiController) GetAgentInstallJobs() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ c.ResponseOk(agentinstall.Jobs())
+}
diff --git a/routers/router.go b/routers/router.go
index f13bcb5..2a366cd 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -46,6 +46,9 @@ func initAPI() {
beego.Router("/api/get-agents", &controllers.ApiController{},
"GET:GetAgents")
beego.Router("/api/get-agent-catalog", &controllers.ApiController{},
"GET:GetAgentCatalog")
+ beego.Router("/api/get-agent-install-jobs",
&controllers.ApiController{}, "GET:GetAgentInstallJobs")
+ beego.Router("/api/install-agent", &controllers.ApiController{},
"POST:InstallAgent")
+ beego.Router("/api/upgrade-agent", &controllers.ApiController{},
"POST:UpgradeAgent")
beego.Router("/api/patch-agent", &controllers.ApiController{},
"POST:PatchAgent")
beego.Router("/api/unpatch-agent", &controllers.ApiController{},
"POST:UnpatchAgent")
beego.Router("/api/update-agent-routing", &controllers.ApiController{},
"POST:UpdateAgentRouting")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 7c5f7f3..db1d342 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -16,6 +16,7 @@ import {query, request} from "@/backend/request";
import type {
Agent,
AgentCatalogEntry,
+ AgentInstallJob,
AgentProviderConfig,
AgentProviderFile,
AgentRecord,
@@ -41,6 +42,21 @@ export function getAgentCatalog() {
return request<AgentCatalogEntry[]>("/api/get-agent-catalog");
}
+/** Installs an agent this machine lacks; the job runs on past this response.
*/
+export function installAgent(agentId: string) {
+ return request<AgentInstallJob>("/api/install-agent", "POST", {agentId:
agentId});
+}
+
+/** Updates one installation through the package manager that installed it. */
+export function upgradeAgent(target: PatchTarget) {
+ return request<AgentInstallJob>("/api/upgrade-agent", "POST", target);
+}
+
+/** Every install and upgrade this Gateway process has run, newest first. */
+export function getAgentInstallJobs() {
+ return request<AgentInstallJob[]>("/api/get-agent-install-jobs");
+}
+
export function patchAgent(target: PatchTarget) {
return request<{followup?: string}>("/api/patch-agent", "POST", target);
}
diff --git a/web/src/components/AgentCatalog.tsx
b/web/src/components/AgentCatalog.tsx
index 32cbd0c..55c418f 100644
--- a/web/src/components/AgentCatalog.tsx
+++ b/web/src/components/AgentCatalog.tsx
@@ -12,20 +12,33 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {Bot, Download} from "lucide-react";
import i18next from "i18next";
-import {AgentIcon} from "@/components/AgentIcon";
-import {useAgentCatalog} from "@/lib/agents";
+import {ToolInstallRow} from "@/components/ToolInstallRow";
+import {useAgentCatalog, type useAgentInstall} from "@/lib/agents";
import type {Agent} from "@/types";
/**
- * The agents Gateway supports that this machine has none of, each linking to
- * the vendor's own install page. Without them a fresh machine says only that
+ * The agents Gateway supports that this machine has none of, each with the
+ * click that installs it here. Without them a fresh machine says only that
* nothing was found, which is the one thing it cannot act on.
*/
-export function AgentCatalog({agents, enabled = true}: {agents: Agent[];
enabled?: boolean}) {
+export function AgentCatalog({
+ agents,
+ enabled = true,
+ installer,
+}: {
+ agents: Agent[];
+ enabled?: boolean;
+ /**
+ * 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
+ * would report the same finished job twice.
+ */
+ installer: ReturnType<typeof useAgentInstall>;
+}) {
const missing = useAgentCatalog(agents, enabled);
+ const {jobs, busyId, install} = installer;
if (missing.length === 0) {
return null;
@@ -39,44 +52,15 @@ export function AgentCatalog({agents, enabled = true}:
{agents: Agent[]; enabled
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3
2xl:grid-cols-4">
- {missing.map(item => {
- const inside = (
- <>
- {/* The icons are keyed by vendor name, which every entry has.
*/}
- <AgentIcon
- agent={item.name}
- size={22}
- fallback={<Bot className="text-muted-foreground size-5" />}
- />
- <span className="min-w-0 truncate text-sm
font-medium">{item.name}</span>
- {item.installUrl ? (
- <span className="text-muted-foreground ml-auto inline-flex
shrink-0 items-center gap-1 text-xs">
- <Download className="size-3.5" />
- {i18next.t("agent:Install")}
- </span>
- ) : null}
- </>
- );
-
- return item.installUrl ? (
- <a
- key={item.agentId}
- href={item.installUrl}
- target="_blank"
- rel="noreferrer"
- className="hover:border-primary hover:text-primary flex
items-center gap-2.5 rounded-lg border p-3 transition-colors"
- >
- {inside}
- </a>
- ) : (
- <div
- key={item.agentId}
- className="text-muted-foreground flex items-center gap-2.5
rounded-lg border p-3"
- >
- {inside}
- </div>
- );
- })}
+ {missing.map(item => (
+ <ToolInstallRow
+ key={item.agentId}
+ entry={item}
+ job={jobs[item.agentId]}
+ busy={busyId === item.agentId}
+ onInstall={install}
+ />
+ ))}
</div>
</section>
);
diff --git a/web/src/components/ToolInstallRow.tsx
b/web/src/components/ToolInstallRow.tsx
new file mode 100644
index 0000000..5cedb20
--- /dev/null
+++ b/web/src/components/ToolInstallRow.tsx
@@ -0,0 +1,107 @@
+// 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 {Bot, Download, ExternalLink} from "lucide-react";
+import i18next from "i18next";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {InstallOutput} from "@/components/ToolUpgradeConfirmDialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import type {AgentCatalogEntry, AgentInstallJob} from "@/types";
+
+/**
+ * One agent this machine does not have, with the click that installs it. The
+ * install runs on the host with the package manager that is already there, so
+ * the row shows which one it will use and what that manager is printing; an
+ * agent no manager here publishes keeps the link to its vendor, which is then
+ * all there is.
+ */
+export function ToolInstallRow({
+ entry,
+ job,
+ busy,
+ onInstall,
+}: {
+ entry: AgentCatalogEntry;
+ /** The running or last install of this agent, when there was one. */
+ job?: AgentInstallJob;
+ busy: boolean;
+ onInstall: (agentId: string) => void;
+}) {
+ const plan = entry.install;
+ const running = job?.running === true;
+ const failed = job !== undefined && !job.running && !job.ok;
+
+ return (
+ <div className="space-y-2 rounded-lg border p-3">
+ <div className="flex items-center gap-2.5">
+ {/* The icons are keyed by vendor name, which every entry has. */}
+ <AgentIcon
+ agent={entry.name}
+ size={22}
+ fallback={<Bot className="text-muted-foreground size-5" />}
+ />
+ <span className="min-w-0 flex-1 truncate text-sm
font-medium">{entry.name}</span>
+
+ {entry.installUrl ? (
+ <SimpleTooltip title={i18next.t("agent:Install page")}>
+ <a
+ href={entry.installUrl}
+ target="_blank"
+ rel="noreferrer"
+ className="text-muted-foreground hover:text-primary shrink-0"
+ aria-label={i18next.t("agent:Install page")}
+ >
+ <ExternalLink className="size-4" />
+ </a>
+ </SimpleTooltip>
+ ) : null}
+
+ <SimpleTooltip
+ title={plan.available ? plan.command : plan.detail}
+ >
+ <span className="shrink-0">
+ <Button
+ size="sm"
+ variant={failed ? "outline" : "default"}
+ disabled={!plan.available || running}
+ loading={busy || running}
+ onClick={() => onInstall(entry.agentId)}
+ >
+ <Download />
+ {i18next.t(
+ running ? "agent:Installing" : failed ? "agent:Retry" :
"agent:Install",
+ )}
+ </Button>
+ </span>
+ </SimpleTooltip>
+ </div>
+
+ <div className="flex flex-wrap items-center gap-2">
+ {plan.available ? (
+ <Badge variant="muted">{plan.manager}</Badge>
+ ) : (
+ <span className="text-muted-foreground text-xs">{plan.detail}</span>
+ )}
+ {plan.available ? (
+ <code className="text-muted-foreground min-w-0 truncate
text-xs">{plan.command}</code>
+ ) : null}
+ </div>
+
+ <InstallOutput job={job} />
+ </div>
+ );
+}
diff --git a/web/src/components/ToolUpgradeConfirmDialog.tsx
b/web/src/components/ToolUpgradeConfirmDialog.tsx
new file mode 100644
index 0000000..36a6b2c
--- /dev/null
+++ b/web/src/components/ToolUpgradeConfirmDialog.tsx
@@ -0,0 +1,89 @@
+// 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 {ArrowUpCircle} from "lucide-react";
+import i18next from "i18next";
+
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Button} from "@/components/ui/button";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import type {Agent, AgentInstallJob} from "@/types";
+
+/** The tail of what a package manager printed, while it works and after. */
+export function InstallOutput({job}: {job?: AgentInstallJob}) {
+ if (!job || job.output === "") {
+ return null;
+ }
+
+ return (
+ <pre className="bg-muted text-muted-foreground max-h-32 overflow-auto
rounded p-2 text-xs whitespace-pre-wrap">
+ {job.output}
+ </pre>
+ );
+}
+
+/**
+ * The guard in front of an upgrade. Unlike a provider switch this replaces the
+ * program on disk while the agent may be running, so the command that will run
+ * is shown before it does and the click is confirmed.
+ */
+export function ToolUpgradeConfirmDialog({
+ agent,
+ job,
+ busy,
+ onConfirm,
+}: {
+ agent: Agent;
+ /** The running or last upgrade of this agent, when there was one. */
+ job?: AgentInstallJob;
+ busy: boolean;
+ onConfirm: () => void;
+}) {
+ const plan = agent.upgrade;
+ const running = job?.running === true;
+
+ const button = (
+ <Button size="sm" variant="outline" disabled={!plan?.available || running}
loading={busy || running}>
+ <ArrowUpCircle />
+ {i18next.t(running ? "agent:Upgrading" : "agent:Upgrade")}
+ </Button>
+ );
+
+ // Nothing to confirm for an agent whose install method Gateway cannot drive:
+ // the button says why instead of opening a dialog with no command in it.
+ if (!plan?.available) {
+ return (
+ <SimpleTooltip title={plan?.detail}>
+ <span>{button}</span>
+ </SimpleTooltip>
+ );
+ }
+
+ return (
+ <ConfirmDialog
+ title={i18next.t("agent:Upgrade {agent}?").replace("{agent}",
agent.name)}
+ description={
+ <span className="space-y-2">
+ <span className="block">{i18next.t("agent:Upgrade hint")}</span>
+ <code className="bg-muted block rounded p-2 text-xs
break-all">{plan.command}</code>
+ </span>
+ }
+ confirmText={i18next.t("agent:Upgrade")}
+ variant="default"
+ onConfirm={onConfirm}
+ >
+ {button}
+ </ConfirmDialog>
+ );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index f1d7943..b1d8318 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -22,6 +22,7 @@ import {providerProtocol, servesResponsesApi} from
"@/lib/providers";
import type {
Agent,
AgentCatalogEntry,
+ AgentInstallJob,
AgentRuntime,
AgentSession,
AgentUsage,
@@ -458,6 +459,107 @@ export function useAgentCatalog(agents: Agent[], enabled
= true) {
);
}
+/** How often a running install is asked about while the manager works. */
+const installPollMs = 1500;
+
+/**
+ * useAgentInstall drives the installs and upgrades Gateway runs with the
host's
+ * own package managers. A package manager takes minutes, so the request that
+ * starts one returns at once and the job is polled until it ends: the button
+ * that started it keeps showing what is happening, and a reload does too.
+ *
+ * `onFinished` is called after every job that ends, which is when a rescan
will
+ * see the agent that was just installed.
+ */
+export function useAgentInstall(enabled = true, onFinished?: () => void) {
+ const [jobs, setJobs] = React.useState<Record<string, AgentInstallJob>>({});
+ const [busyId, setBusyId] = React.useState("");
+ // The jobs this page is waiting on, so the one that finishes is reported
+ // once - and one left running by an earlier page is picked up on load.
+ const watched = React.useRef<Record<string, boolean>>({});
+ const finished = React.useRef(onFinished);
+ finished.current = onFinished;
+
+ const load = React.useCallback(() => {
+ if (!enabled) {
+ return;
+ }
+
+ AgentBackend.getAgentInstallJobs()
+ .then(res => {
+ const list = res.status === "ok" ? (res.data ?? []) : [];
+ setJobs(Object.fromEntries(list.map(job => [job.agentId, job])));
+ })
+ .catch(() => undefined);
+ }, [enabled]);
+
+ React.useEffect(() => load(), [load]);
+
+ const running = Object.values(jobs).some(job => job.running);
+
+ React.useEffect(() => {
+ if (!running) {
+ return;
+ }
+
+ const timer = window.setInterval(load, installPollMs);
+ return () => window.clearInterval(timer);
+ }, [running, load]);
+
+ React.useEffect(() => {
+ Object.values(jobs).forEach(job => {
+ if (job.running) {
+ watched.current[job.agentId] = true;
+ return;
+ }
+ if (!watched.current[job.agentId]) {
+ return;
+ }
+ delete watched.current[job.agentId];
+ Setting.showMessage(
+ job.ok ? "success" : "error",
+ job.ok
+ ? `${i18next.t(job.action === "upgrade" ? "agent:Agent upgraded" :
"agent:Agent installed")}: ${job.name}`
+ : `${i18next.t("agent:Failed to install the agent")}: ${job.error ||
job.name}`,
+ );
+ finished.current?.();
+ });
+ }, [jobs]);
+
+ const start = React.useCallback(
+ (agentId: string, call: () => Promise<{status: string; msg?: string;
data?: AgentInstallJob}>) => {
+ setBusyId(agentId);
+ call()
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ watched.current[agentId] = true;
+ setJobs(current => ({...current, [agentId]: res.data as
AgentInstallJob}));
+ } else {
+ Setting.showMessage("error", res.msg || i18next.t("agent:Failed to
install the agent"));
+ }
+ })
+ .catch(err => Setting.showMessage("error", err.message || String(err)))
+ .then(() => setBusyId(""));
+ },
+ [],
+ );
+
+ const install = React.useCallback(
+ (agentId: string) => start(agentId, () =>
AgentBackend.installAgent(agentId)),
+ [start],
+ );
+
+ const upgrade = React.useCallback(
+ (agent: Agent) =>
+ start(agent.agentId, () =>
+ AgentBackend.upgradeAgent({agentId: agent.agentId, path: agent.path,
owner: agent.owner}),
+ ),
+ [start],
+ );
+
+ return {jobs, busyId, install, upgrade, reload: load};
+}
+
/** The run state of one installation, before the first listing has landed. */
export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent)
{
return runtime[agentKey(agent)];
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 59e361c..6e4cd8f 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -25,9 +25,11 @@
"Agent Sessions": "Sessions",
"Agent configuration": "Agent configuration",
"Agent installation not found": "Agent installation not found",
+ "Agent installed": "Agent installed",
"Agent path": "Agent path",
"Agent started": "Agent started",
"Agent stopped": "Agent stopped",
+ "Agent upgraded": "Agent upgraded",
"Agents": "Agents",
"Agents on this machine": "Agents on this machine",
"All agents": "All agents",
@@ -59,6 +61,7 @@
"Event": "Event",
"Failed to get agent records": "Failed to get agent records",
"Failed to get agent sessions": "Failed to get agent sessions",
+ "Failed to install the agent": "Failed to install the agent",
"Failed to read this transcript": "Failed to read this transcript",
"Failed to scan agents": "Failed to scan agents",
"Failed to start the agent": "Failed to start the agent",
@@ -80,7 +83,9 @@
"Install": "Install",
"Install Method": "Install Method",
"Install an AI agent on this machine, then scan again": "Install an AI
agent on this machine, then scan again",
+ "Install page": "The vendor's own install page",
"Installation": "Installation",
+ "Installing": "Installing",
"Last activity": "Last activity",
"Last {count} records": "Last {count} records",
"MCP target": "MCP target",
@@ -96,7 +101,7 @@
"No supported agents found": "No supported agents found",
"No usage yet": "No requests have been relayed for this agent yet",
"Not installed": "Not installed",
- "Not installed hint": "Agents Gateway works with that were not found on
this machine. Open one to install it from its vendor, then scan again.",
+ "Not installed hint": "Agents Gateway works with that were not found on
this machine. Install one with a package manager this host already has, or open
its vendor's page.",
"Not patched": "Not monitored",
"Not recorded": "not recorded",
"Not supported": "Not supported yet",
@@ -121,6 +126,7 @@
"Restore configuration": "Restore configuration",
"Restore confirm hint": "Puts back the provider settings this agent had
before Gateway first wrote them, and leaves every other setting untouched.",
"Restore the configuration of {agent}?": "Restore the configuration of
{agent}?",
+ "Retry": "Retry",
"Rewrite configuration": "Rewrite configuration",
"Route through Gateway": "Route through Gateway",
"Run Status": "Run Status",
@@ -153,6 +159,10 @@
"Turn on monitoring to collect activity": "Turn on monitoring to collect
activity",
"Unknown": "Unknown",
"Unpatch": "Turn off monitoring",
+ "Upgrade": "Upgrade",
+ "Upgrade hint": "Replaces this installation with the latest version,
through the package manager that installed it. Restart the agent afterwards to
run the new one.",
+ "Upgrade {agent}?": "Upgrade {agent}?",
+ "Upgrading": "Upgrading",
"Usage": "Usage",
"User": "User",
"Version": "Version",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index b64c62f..c747fa0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -25,9 +25,11 @@
"Agent Sessions": "会话",
"Agent configuration": "Agent 配置文件",
"Agent installation not found": "没有找到这个智能体的安装",
+ "Agent installed": "安装完成",
"Agent path": "Agent 路径",
"Agent started": "已启动",
"Agent stopped": "已停止",
+ "Agent upgraded": "升级完成",
"Agents": "智能体",
"Agents on this machine": "本机的智能体",
"All agents": "全部 Agent",
@@ -59,6 +61,7 @@
"Event": "事件",
"Failed to get agent records": "获取 Agent 记录失败",
"Failed to get agent sessions": "获取 Agent 会话失败",
+ "Failed to install the agent": "安装失败",
"Failed to read this transcript": "读取该会话记录失败",
"Failed to scan agents": "探测 Agent 失败",
"Failed to start the agent": "启动 Agent 失败",
@@ -80,7 +83,9 @@
"Install": "安装",
"Install Method": "安装方式",
"Install an AI agent on this machine, then scan again": "在本机安装一个 AI
智能体后,再扫描一次",
+ "Install page": "厂商的安装页面",
"Installation": "安装信息",
+ "Installing": "安装中",
"Last activity": "最后活动",
"Last {count} records": "最近 {count} 条记录",
"MCP target": "MCP 目标",
@@ -96,7 +101,7 @@
"No supported agents found": "没有发现本机安装的智能体",
"No usage yet": "该 Agent 还没有经过网关的请求",
"Not installed": "未安装",
- "Not installed hint": "Gateway 支持、但本机未发现的智能体。点击可打开官方安装页面,安装后重新扫描即可。",
+ "Not installed hint": "本机未找到、但 Gateway 支持的代理。可直接用本机已有的包管理器安装,或打开厂商页面。",
"Not patched": "未开启",
"Not recorded": "未保留",
"Not supported": "暂不支持",
@@ -121,6 +126,7 @@
"Restore configuration": "还原配置",
"Restore confirm hint": "把该 Agent 在 Gateway 首次写入之前的供应商配置还原回去,其他配置保持不变。",
"Restore the configuration of {agent}?": "还原 {agent} 的配置?",
+ "Retry": "重试",
"Rewrite configuration": "重新写入配置",
"Route through Gateway": "经由 Gateway 路由",
"Run Status": "运行状态",
@@ -153,6 +159,10 @@
"Turn on monitoring to collect activity": "开启监控后才会收集活动",
"Unknown": "未知",
"Unpatch": "关闭监控",
+ "Upgrade": "升级",
+ "Upgrade hint": "通过当初安装它的包管理器,将该安装替换为最新版本。升级后需重启代理才能用上新版本。",
+ "Upgrade {agent}?": "升级 {agent}?",
+ "Upgrading": "升级中",
"Usage": "用量",
"User": "用户",
"Version": "版本",
diff --git a/web/src/pages/AgentDetailPage.tsx
b/web/src/pages/AgentDetailPage.tsx
index 229de0c..c5e5eb7 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -24,6 +24,7 @@ import * as LlmRecordBackend from
"@/backend/LlmRecordBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
import {RunBadge, RunButton} from "@/components/AgentRunControl";
+import {InstallOutput, ToolUpgradeConfirmDialog} from
"@/components/ToolUpgradeConfirmDialog";
import {DataTable, type Column} from "@/components/shared/data-table";
import {ProviderCard} from "@/components/ProviderCard";
import {ResultScreen, UnauthorizedResult} from "@/components/shared/misc";
@@ -40,6 +41,7 @@ import {
getOutcomeVariant,
monitorAgentId,
runtimeOf,
+ useAgentInstall,
useAgents,
useAgentSessions,
} from "@/lib/agents";
@@ -231,6 +233,8 @@ export default function AgentDetailPage({account}:
{account: Account}) {
setRouting,
writeProvider,
} = useAgents(isAdmin);
+ // The upgrade rescans when it ends, so the version above is the new one.
+ const installer = useAgentInstall(isAdmin, () => scan(true));
const [tab, setTab] = React.useState<Tab>("Agent Sessions");
const [records, setRecords] = React.useState<AgentRecord[]>([]);
const [recordError, setRecordError] = React.useState("");
@@ -481,7 +485,15 @@ export default function AgentDetailPage({account}:
{account: Account}) {
{agent.version || i18next.t("agent:Unknown")}
</InfoRow>
<InfoRow label={i18next.t("agent:Install Method")}>
- {agent.installMethod || "-"}
+ <span className="flex flex-wrap items-center gap-2">
+ {agent.installMethod || "-"}
+ <ToolUpgradeConfirmDialog
+ agent={agent}
+ job={installer.jobs[agent.agentId]}
+ busy={installer.busyId === agent.agentId}
+ onConfirm={() => installer.upgrade(agent)}
+ />
+ </span>
</InfoRow>
<InfoRow label={i18next.t("general:Owner")}>{agent.owner}</InfoRow>
<InfoRow label={i18next.t("general:Path")}>
@@ -503,6 +515,7 @@ export default function AgentDetailPage({account}:
{account: Account}) {
</SimpleTooltip>
</span>
</InfoRow>
+ <InstallOutput job={installer.jobs[agent.agentId]} />
</CardContent>
</Card>
</div>
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index c8541ab..5285313 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -20,6 +20,7 @@ import * as Setting from "@/Setting";
import {AgentCatalog} from "@/components/AgentCatalog";
import {AgentIcon} from "@/components/AgentIcon";
import {RunBadge, RunButton} from "@/components/AgentRunControl";
+import {ToolUpgradeConfirmDialog} from "@/components/ToolUpgradeConfirmDialog";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {DataTable, type Column} from "@/components/shared/data-table";
import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
@@ -35,6 +36,7 @@ import {
directMode,
monitorAgentId,
runtimeOf,
+ useAgentInstall,
useAgents,
} from "@/lib/agents";
import type {Account, Agent} from "@/types";
@@ -55,6 +57,9 @@ export default function AgentsPage({account}: {account:
Account}) {
toggleRunning,
togglePatch,
} = useAgents(isAdmin);
+ // One installer for the page: it installs what is missing below the table
and
+ // upgrades what is in it.
+ const installer = useAgentInstall(isAdmin, () => scan(true));
if (!isAdmin) {
return <UnauthorizedResult />;
@@ -174,6 +179,12 @@ export default function AgentsPage({account}: {account:
Account}) {
busy={runBusyKey === agentKey(record)}
onToggle={toggleRunning}
/>
+ <ToolUpgradeConfirmDialog
+ agent={record}
+ job={installer.jobs[record.agentId]}
+ busy={installer.busyId === record.agentId}
+ onConfirm={() => installer.upgrade(record)}
+ />
{patchButton(record)}
</div>
),
@@ -256,7 +267,7 @@ export default function AgentsPage({account}: {account:
Account}) {
}
/>
- <AgentCatalog agents={agents} enabled={scanned} />
+ <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
</PageContainer>
);
}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 32b1706..a2e36e5 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -30,7 +30,15 @@ import {PageContainer, PageHeader} from
"@/components/shared/page-header";
import {MessageAlert} from "@/components/ui/alert";
import {Button} from "@/components/ui/button";
import {Card} from "@/components/ui/card";
-import {activityOf, agentKey, runtimeOf, useAgents, useAgentSessions, usageOf}
from "@/lib/agents";
+import {
+ activityOf,
+ agentKey,
+ runtimeOf,
+ useAgentInstall,
+ useAgents,
+ useAgentSessions,
+ usageOf,
+} from "@/lib/agents";
import type {
Account,
AgentUsage,
@@ -74,6 +82,8 @@ export default function HomePage({account}: {account:
Account}) {
togglePatch,
activateProvider,
} = useAgents(isAdmin);
+ // Installs the agents this machine is missing, listed under the cards.
+ const installer = useAgentInstall(isAdmin, () => scan(true));
const [providers, setProviders] = React.useState<Provider[]>([]);
const [health, setHealth] = React.useState<ProviderHealth[]>([]);
const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
@@ -285,7 +295,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} />
+ <AgentCatalog agents={agents} enabled={scanned} installer={installer} />
</PageContainer>
);
}
diff --git a/web/src/types.ts b/web/src/types.ts
index cd0c887..9097830 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -149,6 +149,42 @@ export interface Agent {
providerConfig: AgentProviderConfig;
/** Where this agent reaches its bound provider, as the server resolved it.
*/
proxyBaseUrl?: string;
+ /** The command that would update this installation where it stands. */
+ upgrade?: AgentInstallPlan;
+}
+
+/**
+ * What installing or upgrading one agent would run here. The command is built
+ * on the server from what Gateway knows about the agent, so the page shows it
+ * and asks for it by agent, never by command.
+ */
+export interface AgentInstallPlan {
+ agentId: string;
+ /** "install" for an agent this machine lacks, "upgrade" for one it has. */
+ action: string;
+ /** The package manager that would do it: npm, winget or homebrew. */
+ manager?: string;
+ command?: string;
+ available: boolean;
+ /** Why there is no command, when there is none. */
+ detail?: string;
+ installUrl?: string;
+}
+
+/** One install or upgrade, polled while the package manager works. */
+export interface AgentInstallJob {
+ agentId: string;
+ name: string;
+ action: string;
+ manager: string;
+ command: string;
+ running: boolean;
+ ok: boolean;
+ /** The tail of what the package manager printed, on both streams. */
+ output: string;
+ error?: string;
+ startTime: string;
+ endTime?: string;
}
/**
@@ -161,6 +197,8 @@ export interface AgentCatalogEntry {
/** The vendor's own install page, opened in a new tab. */
installUrl?: string;
desktop?: boolean;
+ /** The command that would install it here, when a package manager can. */
+ install: AgentInstallPlan;
}
/** The live processes of one agent installation, keyed by owner and path. */
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]