This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 8a7cbf7ec9543be953ddefda859c150bcd697694 Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 20:27:47 2026 +0800 feat(agents): install, upgrade and uninstall every install method --- README.md | 38 +-- README_zh.md | 36 +-- agent/agent.go | 32 ++ agent/fingerprint.go | 27 ++ agent/fingerprints/claude-code.json | 10 +- agent/fingerprints/claude-desktop.json | 1 + agent/fingerprints/codex-cli.json | 13 +- agent/fingerprints/codex.json | 1 + agent/fingerprints/crush.json | 5 +- agent/fingerprints/cursor-agent.json | 11 +- agent/fingerprints/cursor.json | 9 +- agent/fingerprints/hermes-agent.json | 8 +- agent/fingerprints/kimi-code.json | 6 +- agent/fingerprints/opencode-desktop.json | 5 +- agent/fingerprints/opencode.json | 8 +- agent/fingerprints/trae.json | 9 +- agent/fingerprints/windsurf.json | 9 +- agent/fingerprints/zed.json | 13 +- agent/remove.go | 109 +++++++ agent/scan_windows.go | 14 +- agentinstall/host.go | 116 +++++++ agentinstall/host_unix.go | 45 +++ agentinstall/host_windows.go | 363 +++++++++++++++++++++ agentinstall/job.go | 29 +- agentinstall/plan.go | 410 ++++++++++++++++++------ agentinstall/versions.go | 30 +- controllers/agent.go | 4 +- controllers/agent_install.go | 8 +- web/src/components/AgentInstallJob.tsx | 256 +++++++++++++++ web/src/components/AgentVersionDialog.tsx | 48 +-- web/src/components/ToolInstallRow.tsx | 44 +-- web/src/components/ToolUpgradeConfirmDialog.tsx | 201 ++++++++++-- web/src/index.css | 7 + web/src/locales/en/data.json | 17 +- web/src/locales/zh/data.json | 17 +- web/src/pages/AgentDetailPage.tsx | 1 + web/src/pages/AgentVersionsPage.tsx | 125 +++----- web/src/pages/AgentsPage.tsx | 1 + web/src/types.ts | 12 +- 39 files changed, 1734 insertions(+), 364 deletions(-) diff --git a/README.md b/README.md index d497ef7..9ced36a 100644 --- a/README.md +++ b/README.md @@ -102,33 +102,33 @@ | Agent | Monitoring | Provider | MCP | Skills | Prompt | Sessions | Install | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- | -| <img src="https://www.google.com/s2/favicons?domain=claude.com&sz=64" width="16" height="16" alt=""> **Claude Code** | ✅ | ✅ Anthropic | ✅ | ✅ | ✅ | ✅ | npm · brew · winget | -| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | ✅ | — | -| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · brew | -| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **ChatGPT Desktop (Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | — | +| <img src="https://www.google.com/s2/favicons?domain=claude.com&sz=64" width="16" height="16" alt=""> **Claude Code** | ✅ | ✅ Anthropic | ✅ | ✅ | ✅ | ✅ | npm · brew · winget · script | +| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | ✅ | winget | +| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · brew · winget · self | +| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **ChatGPT Desktop (Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | store | | <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | ✅ | npm | -| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | ✅ | brew | -| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | — | -| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64" width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew | +| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | ✅ | brew · winget | +| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | script · self | +| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64" width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew · winget | | <img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" height="16" alt=""> **Cline** | — | ✅ OpenAI | ✅ | ✅ | — | — | npm | | <img src="https://www.google.com/s2/favicons?domain=qwen.ai&sz=64" width="16" height="16" alt=""> **Qwen Code** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm | | <img src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64" width="16" height="16" alt=""> **iFlow CLI** | — | ✅ OpenAI | ✅ | — | ✅ | — | npm | -| <img src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64" width="16" height="16" alt=""> **Kimi Code CLI** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm | +| <img src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64" width="16" height="16" alt=""> **Kimi Code CLI** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm · winget | | <img src="https://www.google.com/s2/favicons?domain=codebuddy.ai&sz=64" width="16" height="16" alt=""> **CodeBuddy Code** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm | | <img src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64" width="16" height="16" alt=""> **Roo Code** | — | — | — | — | — | — | — | | <img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" height="16" alt=""> **Copilot CLI** | — | — | — | — | — | — | npm | | <img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" height="16" alt=""> **Continue** | — | ✅ OpenAI | — | — | — | — | npm | -| <img src="https://www.google.com/s2/favicons?domain=zed.dev&sz=64" width="16" height="16" alt=""> **Zed** | — | ✅ OpenAI | — | — | — | — | brew | +| <img src="https://www.google.com/s2/favicons?domain=zed.dev&sz=64" width="16" height="16" alt=""> **Zed** | — | ✅ OpenAI | — | — | — | — | brew · winget | | <img src="https://www.google.com/s2/favicons?domain=aider.chat&sz=64" width="16" height="16" alt=""> **Aider** | — | ✅ OpenAI | — | — | — | — | — | | <img src="https://www.google.com/s2/favicons?domain=block.github.io/goose&sz=64" width="16" height="16" alt=""> **goose** | — | ✅ OpenAI | — | — | — | — | brew | -| <img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" height="16" alt=""> **Crush** | — | — | — | — | — | — | npm | +| <img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" height="16" alt=""> **Crush** | — | — | — | — | — | — | npm · winget | | <img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" height="16" alt=""> **Droid** | — | ✅ OpenAI | — | — | — | — | — | -| <img src="https://www.google.com/s2/favicons?domain=trae.ai&sz=64" width="16" height="16" alt=""> **Trae** | — | — | — | — | — | — | brew | -| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm | -| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode Desktop** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | — | +| <img src="https://www.google.com/s2/favicons?domain=trae.ai&sz=64" width="16" height="16" alt=""> **Trae** | — | — | — | — | — | — | brew · winget | +| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · winget · self | +| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode Desktop** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | winget | | <img src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64" width="16" height="16" alt=""> **OpenAgent** | ✅ | — | — | — | — | — | — | | <img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64" width="16" height="16" alt=""> **OpenClaw** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm | -| <img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" height="16" alt=""> **Hermes Agent** | ✅ | ✅ OpenAI | — | — | — | — | — | +| <img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" height="16" alt=""> **Hermes Agent** | ✅ | ✅ OpenAI | — | — | — | — | self | | <img src="https://www.google.com/s2/favicons?domain=deepseek.com&sz=64" width="16" height="16" alt=""> **DeepSeek Harness** | ✅ | ✅ OpenAI | ✅ | ✅ | — | ✅ | npm | | <img src="https://www.google.com/s2/favicons?domain=pi.dev&sz=64" width="16" height="16" alt=""> **Pi** | — | ✅ OpenAI | — | ✅ | ✅ | — | npm | @@ -136,7 +136,7 @@ - **Provider** — Gateway writes the agent's own configuration to point it at a bound provider, in the wire format that agent's client speaks. An agent without it still reaches Gateway through the environment variables the UI shows. - **MCP · Skills · Prompt** — read, compare and copy MCP servers, skills and the instruction file between agents. - **Sessions** — prompts and token usage read straight from the agent's own transcripts, including what never went through Gateway. -- **Install** — the package managers Gateway installs and upgrades it with. Everything else is a download from its vendor's page. +- **Install** — what Gateway installs, upgrades and removes it with: a package manager (npm, brew, winget, the Microsoft **store**), the agent's own updater (**self**), or the vendor's own install command (**script**). An app that arrived as a setup program is still removed with the uninstaller it registered. Everything else is a download from its vendor's page. ## Features @@ -144,7 +144,7 @@ - **[Switch every agent's provider from one place](#send-an-agents-traffic-through-gateway)** — change an API key or base URL once, and every agent pointed at Gateway picks it up. - **[Add a provider, an MCP server, a prompt or a skill from a link](#import-from-a-link)** — click a vendor's "add this" button on the web and Gateway opens with what the link carries, before any of it is written. - **[Run several instances of one agent side by side](#what-to-do-next)** — e.g. multiple Claude Desktop instances, each signed in to a different account. -- **[Install, upgrade and roll back the agents themselves](#what-to-do-next)** — which build each agent is on against what its package manager publishes, and back to an older release when an update broke something. +- **[Install, upgrade and roll back the agents themselves](#what-to-do-next)** — which build each agent is on against what its package manager publishes, and back to an older release when an update broke something. One click whichever way it was installed, with the command shown first, its progress on the row and its console output a click away. - **[See the whole request, not just a count](#recording-prompts)** — every prompt, message and tool schema an agent sent, kept on this machine. - **[Say what each agent may do](#what-each-agent-is-allowed-to-do)** — around forty switches per agent, in groups, over its tools, models and providers, enforced by Casbin on every request it relays. - **[Know what every agent spent, even off Gateway](#what-the-agents-spend-including-what-never-went-through-gateway)** — read straight from the agents' own transcripts. @@ -160,7 +160,7 @@ | What each agent is allowed to do | Which build each agent is on | | :---: | :---: | | [](https://cdn.casbin.org/img/casbin-gateway-permissions.png) | [](https://cdn.casbin.org/img/casbin-gateway-versions.png) | -| Around forty switches over the agent's tools, models and providers, compiled to a Casbin policy and enforced on every request it relays | The build on this machine against the one its package manager publishes, installed, upgraded, rolled back or removed from the row it is on | +| Around forty switches over the agent's tools, models and providers, compiled to a Casbin policy and enforced on every request it relays | The build on this machine against the one its package manager publishes, installed, upgraded, rolled back or removed from the row it is on, whichever way it was installed | | What the agents spent | One endpoint per model vendor | | :---: | :---: | @@ -208,8 +208,8 @@ 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 [...] -| **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. | Nothing | +| **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 | | **Activity** | What a monitored agent is doing as it does it — each tool call, its target and how long it took. | Monitoring on for an agent | diff --git a/README_zh.md b/README_zh.md index 7082983..83cdcff 100644 --- a/README_zh.md +++ b/README_zh.md @@ -102,33 +102,33 @@ | Agent | 行为监控 | 换供应商 | MCP | 技能 | 提示词 | 会话 | 安装 | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- | -| <img src="https://www.google.com/s2/favicons?domain=claude.com&sz=64" width="16" height="16" alt=""> **Claude Code** | ✅ | ✅ Anthropic | ✅ | ✅ | ✅ | ✅ | npm · brew · winget | -| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | ✅ | — | -| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · brew | -| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **ChatGPT 桌面版(Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | — | +| <img src="https://www.google.com/s2/favicons?domain=claude.com&sz=64" width="16" height="16" alt=""> **Claude Code** | ✅ | ✅ Anthropic | ✅ | ✅ | ✅ | ✅ | npm · brew · winget · script | +| <img src="https://www.google.com/s2/favicons?domain=claude.ai&sz=64" width="16" height="16" alt=""> **Claude Desktop** | ✅ | — | ✅ | — | — | ✅ | winget | +| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **Codex CLI** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · brew · winget · self | +| <img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" height="16" alt=""> **ChatGPT 桌面版(Codex)** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | store | | <img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" height="16" alt=""> **Gemini CLI** | ✅ | ✅ Gemini | ✅ | ✅ | ✅ | ✅ | npm | -| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | ✅ | brew | -| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | — | -| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64" width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew | +| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor** | ✅ | — | ✅ | ✅ | — | ✅ | brew · winget | +| <img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" height="16" alt=""> **Cursor Agent** | ✅ | — | ✅ | ✅ | — | — | script · self | +| <img src="https://www.google.com/s2/favicons?domain=windsurf.com&sz=64" width="16" height="16" alt=""> **Windsurf** | ✅ | — | ✅ | — | ✅ | — | brew · winget | | <img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" height="16" alt=""> **Cline** | — | ✅ OpenAI | ✅ | ✅ | — | — | npm | | <img src="https://www.google.com/s2/favicons?domain=qwen.ai&sz=64" width="16" height="16" alt=""> **Qwen Code** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm | | <img src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64" width="16" height="16" alt=""> **iFlow CLI** | — | ✅ OpenAI | ✅ | — | ✅ | — | npm | -| <img src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64" width="16" height="16" alt=""> **Kimi Code CLI** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm | +| <img src="https://www.google.com/s2/favicons?domain=kimi.com&sz=64" width="16" height="16" alt=""> **Kimi Code CLI** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm · winget | | <img src="https://www.google.com/s2/favicons?domain=codebuddy.ai&sz=64" width="16" height="16" alt=""> **CodeBuddy Code** | — | ✅ OpenAI | ✅ | ✅ | ✅ | — | npm | | <img src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64" width="16" height="16" alt=""> **Roo Code** | — | — | — | — | — | — | — | | <img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" height="16" alt=""> **Copilot CLI** | — | — | — | — | — | — | npm | | <img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" height="16" alt=""> **Continue** | — | ✅ OpenAI | — | — | — | — | npm | -| <img src="https://www.google.com/s2/favicons?domain=zed.dev&sz=64" width="16" height="16" alt=""> **Zed** | — | ✅ OpenAI | — | — | — | — | brew | +| <img src="https://www.google.com/s2/favicons?domain=zed.dev&sz=64" width="16" height="16" alt=""> **Zed** | — | ✅ OpenAI | — | — | — | — | brew · winget | | <img src="https://www.google.com/s2/favicons?domain=aider.chat&sz=64" width="16" height="16" alt=""> **Aider** | — | ✅ OpenAI | — | — | — | — | — | | <img src="https://www.google.com/s2/favicons?domain=block.github.io/goose&sz=64" width="16" height="16" alt=""> **goose** | — | ✅ OpenAI | — | — | — | — | brew | -| <img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" height="16" alt=""> **Crush** | — | — | — | — | — | — | npm | +| <img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" height="16" alt=""> **Crush** | — | — | — | — | — | — | npm · winget | | <img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" height="16" alt=""> **Droid** | — | ✅ OpenAI | — | — | — | — | — | -| <img src="https://www.google.com/s2/favicons?domain=trae.ai&sz=64" width="16" height="16" alt=""> **Trae** | — | — | — | — | — | — | brew | -| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm | -| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode 桌面版** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | — | +| <img src="https://www.google.com/s2/favicons?domain=trae.ai&sz=64" width="16" height="16" alt=""> **Trae** | — | — | — | — | — | — | brew · winget | +| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm · winget · self | +| <img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" height="16" alt=""> **opencode 桌面版** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | winget | | <img src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64" width="16" height="16" alt=""> **OpenAgent** | ✅ | — | — | — | — | — | — | | <img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64" width="16" height="16" alt=""> **OpenClaw** | ✅ | ✅ OpenAI | ✅ | ✅ | ✅ | ✅ | npm | -| <img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" height="16" alt=""> **Hermes Agent** | ✅ | ✅ OpenAI | — | — | — | — | — | +| <img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" height="16" alt=""> **Hermes Agent** | ✅ | ✅ OpenAI | — | — | — | — | self | | <img src="https://www.google.com/s2/favicons?domain=deepseek.com&sz=64" width="16" height="16" alt=""> **DeepSeek Harness** | ✅ | ✅ OpenAI | ✅ | ✅ | — | ✅ | npm | | <img src="https://www.google.com/s2/favicons?domain=pi.dev&sz=64" width="16" height="16" alt=""> **Pi** | — | ✅ OpenAI | — | ✅ | ✅ | — | npm | @@ -136,7 +136,7 @@ - **换供应商** —— Gateway 按该 Agent 客户端自己说的协议格式,改写它的配置文件,把它指向绑定的供应商。不支持的 Agent 仍可以用界面上给出的环境变量接进来。 - **MCP · 技能 · 提示词** —— 在 Agent 之间读取、对比和复制 MCP 服务器、技能和指令文件。 - **会话** —— 直接从 Agent 自己的会话记录里读提示词和 token 用量,包括没走 Gateway 的那部分。 -- **安装** —— Gateway 能用来安装和升级它的包管理器。其余的都只能去厂商页面下载。 +- **安装** —— Gateway 用什么方式安装、升级和卸载它:包管理器(npm、brew、winget、微软商店 **store**)、Agent 自带的更新命令(**self**),或厂商自己的安装命令(**script**)。用安装包装的应用,则用它向 Windows 注册的卸载程序删除。其余的才只能去厂商页面下载。 ## 功能特性 @@ -144,7 +144,7 @@ - **[一个地方切换所有 Agent 的 API 供应商](#让-agent-的流量走-gateway)** —— 改一次 Key 或 base URL,接进 Gateway 的每个 Agent 都跟着换。 - **[一条链接就能导入 Provider、MCP、提示词或技能](#从一条链接导入)** —— 在网页上点厂商的「添加到」按钮,Gateway 就带着链接里的东西打开,写进去之前先给你看。 - **[同一个 Agent 开多个实例](#接下来做什么)** —— 比如同时跑好几个 Claude Desktop,各自登录不同账号。 -- **[安装、升级和回退 Agent 本身](#接下来做什么)** —— 本机装的是哪个版本,包管理器又发布到了哪个版本;升级坏了还能退回旧版。 +- **[安装、升级和回退 Agent 本身](#接下来做什么)** —— 本机装的是哪个版本,包管理器又发布到了哪个版本;升级坏了还能退回旧版。不管当初是怎么装的,都是一键完成:命令先摆出来,进度就在那一行上,控制台输出点一下就能看。 - **[看到完整的请求,而不只是一个数字](#记录提示词)** —— 每一条 prompt、消息和工具 schema,都留在这台机器上。 - **[规定每个 Agent 能做什么](#每个-agent-能做什么)** —— 每个 Agent 四十来个开关,分组管理,覆盖工具、模型和供应商,每个转发的请求都由 Casbin 判定。 - **[统计每个 Agent 花了多少,包括没走 Gateway 的部分](#每个-agent-花了多少包括没走-gateway-的那部分)** —— 直接读 Agent 自己写的会话记录。 @@ -160,7 +160,7 @@ | 每个 Agent 能做什么 | 每个 Agent 装的是哪个版本 | | :---: | :---: | | [](https://cdn.casbin.org/img/casbin-gateway-permissions.png) | [](https://cdn.casbin.org/img/casbin-gateway-versions.png) | -| 四十来个开关,覆盖该 Agent 的工具、模型和供应商,编译成 Casbin 策略,转发的每个请求都按它判定 | 本机装的版本对上包管理器发布的版本,安装、升级、回退和卸载都在这一行上完成 | +| 四十来个开关,覆盖该 Agent 的工具、模型和供应商,编译成 Casbin 策略,转发的每个请求都按它判定 | 本机装的版本对上包管理器发布的版本;不管当初怎么装的,安装、升级、回退和卸载都在这一行上完成 | | 每个 Agent 花了多少 | 每个模型厂商一个入口 | | :---: | :---: | @@ -207,7 +207,7 @@ Gateway 会在自己的窗口里打开 —— 不用登录:它只服务本机 | 页面 | 你能得到什么 | 需要什么 | | --- | --- | --- | | **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor、Gemini CLI、opencode 等等 —— 一行四张卡片,每张写明它登录的是哪个账号、接的是哪个 Provider、花了多少(会话记录够长的话,还会按天画出最近一个月的曲线)、此刻是不是在跑。可以在卡片上直接启动或停止,也可以给同一个 Agent 开多个**实例**,每个实例有自己的状态目录和自己的登录账号,同时运行。本机没装的 Agent 也会列出来,可以直接用宿主机已有的包管理器安装或升级。同一个包管理器还会告诉你哪些安装有新版本可用,可以切换到它仍在发布的任意版本 —— 新版本出了问题就退回旧版 —— 也可以卸载,Agent 自己的登录状态和历史记录会原样保留。 | 无 | -| **Agent versions** | Gateway 认识的每一个 Agent 都在这张表里:本机装的是哪个版本、包管理器发布到了哪个版本、两者差在哪里。安装、升级、退回旧版本、卸载,都在这一行上完成。 | 无 | +| **Agent versions** | Gateway 认识的每一个 Agent 都在这张表里:本机装的是哪个版本、包管理器发布到了哪个版本、两者差在哪里。安装、升级、退回旧版本、卸载,都在这一行上一键完成 —— 不管当初是怎么装的:包管理器、微软商店、应用向 Windows 注册的卸载程序、Agent 自带的更新命令,或厂商自己的安装命令。命令运行前先摆出来,运行时进度就在那一行上,它打印的所有内容点一下就能看。 | 无 | | **Skills, MCP & Prompts** | 所有 Agent 的所有技能、MCP 服务器和提示词文件汇总在一张表里。可以从 GitHub 仓库、`.zip` / `.tar.gz` 压缩包,或本机的一个目录安装技能,一次装进一个或多个 Agent。MCP 服务器也一样,还能编辑某个 Agent 每次会话前读到的提示词,或者打开、删除、复制到另一个 Agent。 | 无 | | **Sessions** | 这些 Agent 的每一次会话,从它们留在磁盘上的会话记录里读出来:完整的对话,一条一条。一共多少次、今天跑了多少次、其中多少条是从会话记录里读来而不是监控到的,都在页头;再按 Agent 或按这两种来源筛。 | 无 | | **Activity** | 被监控的 Agent 正在做什么:每一次工具调用、调用的目标,以及耗时。 | 给某个 Agent 打开监控 | diff --git a/agent/agent.go b/agent/agent.go index b58a32f..071a83a 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -15,6 +15,8 @@ // Package agent detects AI agents in known installation locations. package agent +import "runtime" + // Installation describes an AI agent installation found on the host. type Installation struct { AgentId string `json:"agentId"` @@ -94,10 +96,19 @@ func IsKnownAgentId(id string) bool { type Packages struct { Npm string Winget string + MsStore string + MsixFamily string HomebrewCask string System string InstallUrl string Desktop bool + // UpdateArgs and RemoveArgs are what the agent's own launcher takes to + // update and to remove itself, for one that ships its own updater. + UpdateArgs []string + RemoveArgs []string + // Script is the vendor's own installer command on this platform, empty + // where the vendor publishes none. + Script string } // PackagesOf reads the fingerprints rather than a host scan, so the packages of @@ -111,9 +122,14 @@ func PackagesOf(id string) Packages { packages := Packages{ Npm: fingerprints[i].NpmPackage, Winget: fingerprints[i].WingetPackage, + MsStore: fingerprints[i].MsStorePackage, + MsixFamily: fingerprints[i].MSIXFamily, System: fingerprints[i].SystemPackage, InstallUrl: fingerprints[i].InstallUrl, Desktop: fingerprints[i].Desktop, + UpdateArgs: fingerprints[i].UpdateArgs, + RemoveArgs: fingerprints[i].RemoveArgs, + Script: installScriptOf(fingerprints[i].InstallScript), } // The rest of the list are version-pinned aliases of the first, which // is the cask an install should ask for. @@ -124,3 +140,19 @@ func PackagesOf(id string) Packages { } return Packages{} } + +// installScriptOf is the vendor's installer command for the platform Gateway +// runs on, empty where the vendor publishes none for it. +func installScriptOf(script *InstallScript) string { + if script == nil { + return "" + } + switch runtime.GOOS { + case "windows": + return script.Windows + case "darwin": + return script.Darwin + default: + return script.Linux + } +} diff --git a/agent/fingerprint.go b/agent/fingerprint.go index 3b47f6a..8f7c830 100644 --- a/agent/fingerprint.go +++ b/agent/fingerprint.go @@ -57,6 +57,9 @@ type Fingerprint struct { ExtraUnixNpmDirs []string `json:"extraUnixNpmDirs,omitempty"` ExtraWindowsNpmDirs []string `json:"extraWindowsNpmDirs,omitempty"` WingetPackage string `json:"wingetPackage,omitempty"` + // MsStorePackage is the Microsoft Store product id, which is how winget + // installs an agent published nowhere else. + MsStorePackage string `json:"msStorePackage,omitempty"` MSIXFamily string `json:"msixFamily,omitempty"` DesktopInstallerDir string `json:"desktopInstallerDir,omitempty"` WindowsProgramDirs []string `json:"windowsProgramDirs,omitempty"` @@ -67,6 +70,11 @@ type Fingerprint struct { BuildInfoModule string `json:"buildInfoModule,omitempty"` BuildInfoVersionVar string `json:"buildInfoVersionVar,omitempty"` VersionFile string `json:"versionFile,omitempty"` + // IgnoreExecutableVersion marks a launcher whose Windows version resource + // belongs to whatever packaged it rather than to the agent, which is every + // single-file Node build: it carries the packager's number, and reading it + // would report a release the vendor never published. + IgnoreExecutableVersion bool `json:"ignoreExecutableVersion,omitempty"` // StateVersionGlob matches JSONL files under the state directory whose // records carry the version of the agent that wrote them. It is the only // version an installation found by its configuration alone can report. @@ -79,4 +87,23 @@ type Fingerprint struct { // directory holding nothing else is not evidence of an installation. StateIgnore []string `json:"stateIgnore,omitempty"` LocalServer *localserver.Server `json:"localServer,omitempty"` + + // UpdateArgs are what the agent's own launcher takes to update itself, for + // one that ships an updater. It is the only way to move an installation no + // package manager owns, which is what a vendor's install script leaves. + UpdateArgs []string `json:"updateArgs,omitempty"` + // RemoveArgs are the same for removing it. + RemoveArgs []string `json:"removeArgs,omitempty"` + // InstallScript is the vendor's own documented one-line installer, which + // both installs and upgrades. It runs through the platform shell, so it is + // shown in full before anyone clicks it. + InstallScript *InstallScript `json:"installScript,omitempty"` +} + +// InstallScript is the vendor's installer command per platform. An empty +// platform means the vendor publishes no script for it. +type InstallScript struct { + Windows string `json:"windows,omitempty"` + Darwin string `json:"darwin,omitempty"` + Linux string `json:"linux,omitempty"` } diff --git a/agent/fingerprints/claude-code.json b/agent/fingerprints/claude-code.json index 465fb4f..b699619 100644 --- a/agent/fingerprints/claude-code.json +++ b/agent/fingerprints/claude-code.json @@ -12,5 +12,13 @@ "claude-code@latest" ], "systemPackage": "claude-code", - "stateVersionGlob": "projects/*/*.jsonl" + "stateVersionGlob": "projects/*/*.jsonl", + "updateArgs": [ + "update" + ], + "installScript": { + "windows": "irm https://claude.ai/install.ps1 | iex", + "darwin": "curl -fsSL https://claude.ai/install.sh | bash", + "linux": "curl -fsSL https://claude.ai/install.sh | bash" + } } diff --git a/agent/fingerprints/claude-desktop.json b/agent/fingerprints/claude-desktop.json index 294f671..7223a7b 100644 --- a/agent/fingerprints/claude-desktop.json +++ b/agent/fingerprints/claude-desktop.json @@ -7,6 +7,7 @@ "sandboxed": true, "instanceArg": "--user-data-dir", "linkScheme": "claude", + "wingetPackage": "Anthropic.Claude", "msixFamily": "Claude_pzs8sxrjxfjjc", "desktopInstallerDir": "AnthropicClaude" } diff --git a/agent/fingerprints/codex-cli.json b/agent/fingerprints/codex-cli.json index 09f5616..8f3a755 100644 --- a/agent/fingerprints/codex-cli.json +++ b/agent/fingerprints/codex-cli.json @@ -6,6 +6,15 @@ "instanceEnv": "CODEX_HOME", "stateDir": "codex", "npmPackage": "@openai/codex", - "homebrewCasks": ["codex", "codex@latest"], - "windowsUserDirs": ["OpenAI/Codex/bin"] + "wingetPackage": "OpenAI.Codex", + "windowsUserDirs": [ + "OpenAI/Codex/bin" + ], + "homebrewCasks": [ + "codex", + "codex@latest" + ], + "updateArgs": [ + "update" + ] } diff --git a/agent/fingerprints/codex.json b/agent/fingerprints/codex.json index 7e72851..930bcb5 100644 --- a/agent/fingerprints/codex.json +++ b/agent/fingerprints/codex.json @@ -4,5 +4,6 @@ "installUrl": "https://openai.com/chatgpt/download/", "execName": "ChatGPT", "desktop": true, + "msStorePackage": "9PLM9XGG6VKS", "msixFamily": "OpenAI.Codex_2p2nqsd0c76g0" } diff --git a/agent/fingerprints/crush.json b/agent/fingerprints/crush.json index 9d236d9..2000928 100644 --- a/agent/fingerprints/crush.json +++ b/agent/fingerprints/crush.json @@ -4,5 +4,8 @@ "installUrl": "https://github.com/charmbracelet/crush", "execName": "crush", "npmPackage": "@charmland/crush", - "homeDirs": [".local/bin"] + "wingetPackage": "charmbracelet.crush", + "homeDirs": [ + ".local/bin" + ] } diff --git a/agent/fingerprints/cursor-agent.json b/agent/fingerprints/cursor-agent.json index 4590313..00a92b6 100644 --- a/agent/fingerprints/cursor-agent.json +++ b/agent/fingerprints/cursor-agent.json @@ -4,5 +4,14 @@ "installUrl": "https://cursor.com/docs/cli/installation", "execName": "cursor-agent", "stateDir": "cursor-agent", - "windowsProgramDirs": ["cursor-agent"] + "windowsProgramDirs": [ + "cursor-agent" + ], + "updateArgs": [ + "update" + ], + "installScript": { + "darwin": "curl https://cursor.com/install -fsS | bash", + "linux": "curl https://cursor.com/install -fsS | bash" + } } diff --git a/agent/fingerprints/cursor.json b/agent/fingerprints/cursor.json index fa674ff..9d34002 100644 --- a/agent/fingerprints/cursor.json +++ b/agent/fingerprints/cursor.json @@ -5,6 +5,11 @@ "execName": "Cursor", "desktop": true, "instanceArg": "--user-data-dir", - "windowsProgramDirs": ["cursor"], - "homebrewCasks": ["cursor"] + "wingetPackage": "Anysphere.Cursor", + "windowsProgramDirs": [ + "cursor" + ], + "homebrewCasks": [ + "cursor" + ] } diff --git a/agent/fingerprints/hermes-agent.json b/agent/fingerprints/hermes-agent.json index c3abab8..a44da9d 100644 --- a/agent/fingerprints/hermes-agent.json +++ b/agent/fingerprints/hermes-agent.json @@ -3,5 +3,11 @@ "displayName": "Hermes Agent", "installUrl": "https://hermes-agent.nousresearch.com/docs/getting-started/installation", "execName": "hermes", - "customScan": true + "customScan": true, + "updateArgs": [ + "update" + ], + "removeArgs": [ + "uninstall" + ] } diff --git a/agent/fingerprints/kimi-code.json b/agent/fingerprints/kimi-code.json index dc9cf66..b34fd68 100644 --- a/agent/fingerprints/kimi-code.json +++ b/agent/fingerprints/kimi-code.json @@ -6,5 +6,9 @@ "instanceEnv": "KIMI_CODE_HOME", "stateDir": "kimi-code", "npmPackage": "@moonshot-ai/kimi-code", - "homeDirs": [".kimi-code/bin"] + "wingetPackage": "MoonshotAI.KimiCodeCLI", + "homeDirs": [ + ".kimi-code/bin" + ], + "ignoreExecutableVersion": true } diff --git a/agent/fingerprints/opencode-desktop.json b/agent/fingerprints/opencode-desktop.json index 30ee0b5..871e26a 100644 --- a/agent/fingerprints/opencode-desktop.json +++ b/agent/fingerprints/opencode-desktop.json @@ -4,5 +4,8 @@ "installUrl": "https://opencode.ai/download", "execName": "OpenCode", "desktop": true, - "windowsProgramDirs": ["@opencode-aidesktop"] + "wingetPackage": "SST.OpenCodeDesktop", + "windowsProgramDirs": [ + "@opencode-aidesktop" + ] } diff --git a/agent/fingerprints/opencode.json b/agent/fingerprints/opencode.json index 94747a3..37cacf6 100644 --- a/agent/fingerprints/opencode.json +++ b/agent/fingerprints/opencode.json @@ -4,5 +4,11 @@ "installUrl": "https://opencode.ai/docs/", "execName": "opencode", "npmPackage": "opencode-ai", - "homeDirs": [".opencode/bin"] + "wingetPackage": "SST.opencode", + "homeDirs": [ + ".opencode/bin" + ], + "updateArgs": [ + "upgrade" + ] } diff --git a/agent/fingerprints/trae.json b/agent/fingerprints/trae.json index c3e167c..89e1e11 100644 --- a/agent/fingerprints/trae.json +++ b/agent/fingerprints/trae.json @@ -5,6 +5,11 @@ "execName": "Trae", "desktop": true, "instanceArg": "--user-data-dir", - "windowsProgramDirs": ["Trae"], - "homebrewCasks": ["trae"] + "wingetPackage": "ByteDance.Trae", + "windowsProgramDirs": [ + "Trae" + ], + "homebrewCasks": [ + "trae" + ] } diff --git a/agent/fingerprints/windsurf.json b/agent/fingerprints/windsurf.json index d438466..04d2247 100644 --- a/agent/fingerprints/windsurf.json +++ b/agent/fingerprints/windsurf.json @@ -5,6 +5,11 @@ "execName": "Windsurf", "desktop": true, "instanceArg": "--user-data-dir", - "windowsProgramDirs": ["Windsurf"], - "homebrewCasks": ["windsurf"] + "wingetPackage": "Codeium.Windsurf", + "windowsProgramDirs": [ + "Windsurf" + ], + "homebrewCasks": [ + "windsurf" + ] } diff --git a/agent/fingerprints/zed.json b/agent/fingerprints/zed.json index 379a59f..86e4b4d 100644 --- a/agent/fingerprints/zed.json +++ b/agent/fingerprints/zed.json @@ -4,7 +4,14 @@ "installUrl": "https://zed.dev/download", "execName": "zed", "desktop": true, - "windowsProgramDirs": ["Zed"], - "homeDirs": [".local/zed.app/libexec"], - "homebrewCasks": ["zed"] + "wingetPackage": "ZedIndustries.Zed", + "windowsProgramDirs": [ + "Zed" + ], + "homeDirs": [ + ".local/zed.app/libexec" + ], + "homebrewCasks": [ + "zed" + ] } diff --git a/agent/remove.go b/agent/remove.go new file mode 100644 index 0000000..83df02b --- /dev/null +++ b/agent/remove.go @@ -0,0 +1,109 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agent + +import ( + "path/filepath" + "strings" +) + +// sharedInstallDirs are the declared directories an agent shares with whatever +// else the host put there. A program in one of those is removed on its own; the +// directory around it belongs to nobody in particular. +var sharedInstallDirs = map[string]bool{ + ".local/bin": true, + "bin": true, + ".local": true, + "usr/bin": true, + "usr/local": true, +} + +// RemovablePathOf is what an uninstall may delete for an installation no +// package manager and no registered uninstaller owns: the directory the +// 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. +func RemovablePathOf(installation Installation) (string, bool) { + if installation.Path == "" || installation.InstallMethod == InstallMethodConfig { + return "", false + } + + fingerprint := fingerprintOf(installation.AgentId) + if fingerprint == nil { + return installation.Path, false + } + + directory := filepath.Dir(installation.Path) + for _, declared := range declaredInstallDirs(fingerprint) { + if !endsWithDir(directory, declared) { + continue + } + if sharedInstallDirs[strings.ToLower(declared)] { + return installation.Path, false + } + return directory, true + } + return installation.Path, false +} + +func fingerprintOf(id string) *Fingerprint { + for i := range fingerprints { + if fingerprints[i].ID == id { + return &fingerprints[i] + } + } + return nil +} + +// declaredInstallDirs are every layout a fingerprint says this agent's program +// is installed into, relative to whichever root the scan looked under. +func declaredInstallDirs(fingerprint *Fingerprint) []string { + dirs := make([]string, 0, 8) + dirs = append(dirs, fingerprint.HomeDirs...) + dirs = append(dirs, fingerprint.WindowsUserDirs...) + dirs = append(dirs, fingerprint.WindowsProgramDirs...) + if fingerprint.DesktopInstallerDir != "" { + dirs = append(dirs, fingerprint.DesktopInstallerDir) + } + return dirs +} + +// endsWithDir reports whether a directory ends in the declared relative path, +// matching whole segments so that "cursor" never matches "cursor-agent". +func endsWithDir(directory string, declared string) bool { + want := splitSegments(filepath.FromSlash(declared)) + have := splitSegments(directory) + if len(want) == 0 || len(want) > len(have) { + return false + } + have = have[len(have)-len(want):] + for i := range want { + if !strings.EqualFold(want[i], have[i]) { + return false + } + } + return true +} + +func splitSegments(path string) []string { + parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) + kept := parts[:0] + for _, part := range parts { + if part != "" { + kept = append(kept, part) + } + } + return kept +} diff --git a/agent/scan_windows.go b/agent/scan_windows.go index 9210cb9..29de797 100644 --- a/agent/scan_windows.go +++ b/agent/scan_windows.go @@ -76,12 +76,13 @@ func scan(ctx context.Context) []Installation { installations = expandSharedCodexWindowsInstallations(dedupeInstallations(installations), homes) // Whatever layout an installation came from, its launcher may carry a - // version resource, so fall back to that rather than reporting no version. + // version resource, so fall back to that rather than reporting no version - + // except where the fingerprint says that resource is the packager's. for i := range installations { if ctx.Err() != nil { return installations } - if installations[i].Version == "" { + if installations[i].Version == "" && !ignoresExecutableVersion(installations[i].AgentId) { installations[i].Version = executableVersion(installations[i].Path) } } @@ -89,6 +90,15 @@ func scan(ctx context.Context) []Installation { return installations } +func ignoresExecutableVersion(agentId string) bool { + for i := range fingerprints { + if fingerprints[i].ID == agentId { + return fingerprints[i].IgnoreExecutableVersion + } + } + return false +} + func windowsHomes(ctx context.Context) []homeDir { seen := map[string]bool{} var homes []homeDir diff --git a/agentinstall/host.go b/agentinstall/host.go new file mode 100644 index 0000000..7a8806c --- /dev/null +++ b/agentinstall/host.go @@ -0,0 +1,116 @@ +// 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 ( + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/apache/casbin-gateway/agent" +) + +// launcherOf is the program of an installation, for the drivers that run the +// agent itself. Empty for one found by its state directory alone, which has no +// program to run, and for a path that has since gone. +func launcherOf(installation agent.Installation) string { + path := installation.Path + if path == "" || installation.InstallMethod == agent.InstallMethodConfig { + return "" + } + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return "" + } + return path +} + +// shellCommand wraps a vendor's install command in the shell that reads it. The +// command is one string from a fingerprint, with the pipe the vendor wrote in +// it, so it needs a shell rather than an argument list. +func shellCommand(script string) (string, []string) { + if runtime.GOOS == "windows" { + program := lookup("powershell") + if program == "" { + return "", nil + } + return program, []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script} + } + + program := lookup("bash") + if program == "" { + program = lookup("sh") + } + if program == "" { + return "", nil + } + return program, []string{"-c", script} +} + +// filesDriver removes what an install left on disk, which is the last thing +// left for one that registered no uninstaller anywhere. It deletes the +// directory the fingerprint declares for this agent, or, where that directory +// is shared with other programs, only the launcher in it. An agent's state +// directory is not touched either way. +func filesDriver(plan Plan, spec target) (Plan, bool) { + launcher := launcherOf(spec.installation) + if launcher == "" { + return plan, false + } + if !ownedByUser(launcher) { + spec.note("this installation is outside the account Gateway runs as, so it can only be removed by the account that owns it") + return plan, false + } + + removed, whole := agent.RemovablePathOf(spec.installation) + if removed == "" { + return plan, false + } + + program, args := removeCommand(removed, whole) + if program == "" { + return plan, false + } + built := fill(plan, ManagerFiles, program, args...) + built.Command = removeDisplay(removed, whole) + if whole { + built.Warning = "this deletes the whole directory the agent was installed into" + } else { + built.Warning = "this deletes the agent's program, which shares its directory with others" + } + return built, true +} + +// ownedByUser keeps a removal inside the account Gateway runs as: a scan sees +// every profile on the host, and deleting out of another one would fail halfway +// through rather than cleanly. +func ownedByUser(path string) bool { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return false + } + return within(home, path) +} + +// within reports whether path is inside root, comparing whole segments so that +// a directory is never confused with one whose name merely starts the same. +func within(root string, path string) bool { + relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return false + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/agentinstall/host_unix.go b/agentinstall/host_unix.go new file mode 100644 index 0000000..4169c86 --- /dev/null +++ b/agentinstall/host_unix.go @@ -0,0 +1,45 @@ +// 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 + +// uninstallerDriver has nothing to read off Windows: a registered uninstaller +// is a Windows idea, and everywhere else an installation is either a package or +// the files it left. +func uninstallerDriver(plan Plan, _ target) (Plan, bool) { return plan, false } + +// appxDriver is the same for Store packages. +func appxDriver(plan Plan, _ target) (Plan, bool) { return plan, false } + +// removeCommand deletes a path, through rm so that what it did is in the job's +// own output rather than only in an error. +func removeCommand(path string, whole bool) (string, []string) { + program := lookup("rm") + if program == "" { + return "", nil + } + if whole { + return program, []string{"-rf", path} + } + return program, []string{"-f", path} +} + +func removeDisplay(path string, whole bool) string { + if whole { + return "rm -rf " + path + } + return "rm -f " + path +} diff --git a/agentinstall/host_windows.go b/agentinstall/host_windows.go new file mode 100644 index 0000000..c5ac655 --- /dev/null +++ b/agentinstall/host_windows.go @@ -0,0 +1,363 @@ +// 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" + "path/filepath" + "strings" + + "golang.org/x/sys/windows/registry" +) + +// uninstallerDriver runs the uninstaller the app registered with Windows. Every +// installer records one, so this covers the apps that arrived as a setup +// program rather than from a package manager. +func uninstallerDriver(plan Plan, spec target) (Plan, bool) { + if spec.action != ActionUninstall || spec.installation.Path == "" { + return plan, false + } + entry, ok := uninstallEntryFor(spec.installation.Path) + if !ok { + return plan, false + } + + program, args := resolveCommandLine(entry.command) + if program == "" { + return plan, false + } + if !filepath.IsAbs(program) { + resolved := lookup(program) + if resolved == "" { + spec.note("this app registered " + program + " as its uninstaller, which is not on Gateway's PATH") + return plan, false + } + program = resolved + } + + // A machine-wide uninstaller has to run as an administrator, which a plain + // start cannot become: Windows refuses it outright. Going through + // Start-Process raises the consent prompt instead, and waits for the answer. + if entry.machineWide { + shell := lookup("powershell") + if shell == "" { + spec.note("removing a machine-wide install needs PowerShell, which is not on Gateway's PATH") + return plan, false + } + elevated := fill(plan, ManagerUninstaller, shell, elevatedArgs(program, args)...) + elevated.Command = entry.command + elevated.Interactive = true + elevated.Warning = "this app was installed for every account, so Windows will ask for administrator approval" + return elevated, true + } + + built := fill(plan, ManagerUninstaller, program, args...) + built.Command = entry.command + // Without a silent switch the uninstaller opens a window of its own, and + // hiding it would leave the job waiting on a dialog nobody can answer. + built.Interactive = !entry.quiet + if built.Interactive { + built.Warning = "this uninstaller has no silent mode, so it opens its own window to finish in" + } + return built, true +} + +// elevatedArgs run one program through PowerShell's own launcher, which is what +// raises the Windows consent prompt. +func elevatedArgs(program string, args []string) []string { + command := "Start-Process -FilePath " + powershellQuote(program) + if len(args) > 0 { + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, powershellQuote(arg)) + } + command += " -ArgumentList " + strings.Join(quoted, ",") + } + command += " -Verb RunAs -Wait" + return []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command} +} + +// appxDriver removes a Store app, which lives under a directory no account may +// write and is only ever removed through the packaging service. +func appxDriver(plan Plan, spec target) (Plan, bool) { + family := spec.packages.MsixFamily + if spec.action != ActionUninstall || family == "" { + return plan, false + } + shell := lookup("powershell") + if shell == "" { + spec.note("removing a Microsoft Store app needs PowerShell, which is not on Gateway's PATH") + return plan, false + } + + command := "Get-AppxPackage -PackageFamilyName " + powershellQuote(family) + " | Remove-AppxPackage" + built := fill(plan, ManagerAppx, shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command) + built.Command = command + return built, true +} + +// removeCommand deletes a path, through the shell so that what it did is in the +// job's own output rather than only in an error. +func removeCommand(path string, whole bool) (string, []string) { + shell := lookup("powershell") + if shell == "" { + return "", nil + } + command := "Remove-Item -LiteralPath " + powershellQuote(path) + " -Force" + if whole { + command += " -Recurse" + } + return shell, []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command} +} + +func removeDisplay(path string, whole bool) string { + command := "Remove-Item " + powershellQuote(path) + " -Force" + if whole { + command += " -Recurse" + } + return command +} + +func powershellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +// uninstallEntry is one program's entry in the list Windows keeps for Add or +// remove programs, reduced to what running its uninstaller needs. +type uninstallEntry struct { + command string + quiet bool + // machineWide marks an entry under the machine's own hive, which only an + // administrator can remove. + machineWide bool +} + +type uninstallHive struct { + key registry.Key + path string + access uint32 + machineWide bool +} + +var uninstallHives = []uninstallHive{ + {registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, registry.WOW64_64KEY, false}, + {registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, registry.WOW64_32KEY, false}, + {registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, registry.WOW64_64KEY, true}, + {registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, registry.WOW64_32KEY, true}, +} + +// uninstallEntryFor finds the entry whose own directory holds this program. An +// entry names where it installed to, so matching on that is what ties a +// discovered launcher to the installer that put it there; the most specific +// directory wins, which keeps one app's entry off another's files. +func uninstallEntryFor(path string) (uninstallEntry, bool) { + var best uninstallEntry + bestDepth := -1 + + for _, hive := range uninstallHives { + root, err := registry.OpenKey(hive.key, hive.path, registry.ENUMERATE_SUB_KEYS|hive.access) + if err != nil { + continue + } + names, err := root.ReadSubKeyNames(-1) + if err != nil { + root.Close() + continue + } + for _, name := range names { + entry, directory, ok := readUninstallEntry(root, name, hive.access, hive.machineWide) + if !ok || !within(directory, path) { + continue + } + if depth := len(splitPath(directory)); depth > bestDepth { + best, bestDepth = entry, depth + } + } + root.Close() + } + return best, bestDepth >= 0 +} + +func readUninstallEntry(root registry.Key, name string, access uint32, machineWide bool) (uninstallEntry, string, bool) { + key, err := registry.OpenKey(root, name, registry.QUERY_VALUE|access) + if err != nil { + return uninstallEntry{}, "", false + } + defer key.Close() + + // A component Windows installed for something else is not an app anyone + // uninstalls on its own. + if system, _, err := key.GetIntegerValue("SystemComponent"); err == nil && system == 1 { + return uninstallEntry{}, "", false + } + + quiet, _, _ := key.GetStringValue("QuietUninstallString") + command, _, _ := key.GetStringValue("UninstallString") + if quiet != "" { + command = quiet + } + if strings.TrimSpace(command) == "" { + return uninstallEntry{}, "", false + } + + location, _, _ := key.GetStringValue("InstallLocation") + icon, _, _ := key.GetStringValue("DisplayIcon") + directory := entryDirectory(location, icon, command) + if directory == "" { + return uninstallEntry{}, "", false + } + return uninstallEntry{command: command, quiet: quiet != "", machineWide: machineWide}, directory, true +} + +// entryDirectory is where an entry says its app lives. Not every installer +// records that, so the icon it registered and the uninstaller it left behind +// stand in for it - both sit in the directory the app was installed into. +// +// A drive root, and the few directories every app is installed under, are +// refused whatever they came from: an entry that resolves to one of those would +// claim every installation on the host as its own. +func entryDirectory(location string, icon string, command string) string { + for _, directory := range []string{ + strings.Trim(strings.TrimSpace(location), `"`), + iconDirectory(icon), + programDirectory(command), + } { + if directory == "" { + continue + } + if cleaned := filepath.Clean(directory); !isSharedRoot(cleaned) { + return cleaned + } + } + return "" +} + +// isSharedRoot reports a directory that holds programs rather than being one: +// a drive root, or one of the roots a scan looks under. +func isSharedRoot(directory string) bool { + if len(splitPath(directory)) < 2 { + return true + } + roots := []string{ + os.Getenv("ProgramFiles"), + os.Getenv("ProgramFiles(x86)"), + os.Getenv("ProgramW6432"), + os.Getenv("LOCALAPPDATA"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs"), + os.Getenv("APPDATA"), + os.Getenv("USERPROFILE"), + os.Getenv("SystemRoot"), + } + for _, root := range roots { + if root != "" && strings.EqualFold(filepath.Clean(root), directory) { + return true + } + } + return false +} + +// iconDirectory reads the icon an entry registered, which is recorded as +// "file,index" and sits in the directory the app was installed into. +func iconDirectory(icon string) string { + path := strings.Trim(strings.TrimSpace(icon), `"`) + if comma := strings.LastIndex(path, ","); comma > 1 { + path = path[:comma] + } + if !filepath.IsAbs(path) { + return "" + } + return filepath.Dir(path) +} + +func programDirectory(command string) string { + program, _ := resolveCommandLine(command) + if !filepath.IsAbs(program) { + return "" + } + return filepath.Dir(program) +} + +func splitPath(path string) []string { + parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) + kept := parts[:0] + for _, part := range parts { + if part != "" { + kept = append(kept, part) + } + } + return kept +} + +// resolveCommandLine reads a recorded command line the way Windows does. An +// installer that recorded its uninstaller unquoted leaves the space in +// "Program Files" ambiguous, and Windows resolves that by trying each prefix +// until one names a file that is there - so this does too, rather than reading +// "C:\Program" as the program and a drive root as its directory. +func resolveCommandLine(line string) (string, []string) { + fields := splitCommandLine(line) + if len(fields) == 0 { + return "", nil + } + if strings.HasPrefix(strings.TrimSpace(line), `"`) { + return fields[0], fields[1:] + } + + for taken := 1; taken <= len(fields); taken++ { + candidate := strings.Join(fields[:taken], " ") + if isProgram(candidate) || isProgram(candidate+".exe") { + return candidate, fields[taken:] + } + } + return fields[0], fields[1:] +} + +func isProgram(path string) bool { + if !filepath.IsAbs(path) { + return false + } + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +// splitCommandLine breaks a command line on the spaces outside its quotes. +func splitCommandLine(line string) []string { + var fields []string + var current strings.Builder + quoted, started := false, false + + flush := func() { + if started { + fields = append(fields, current.String()) + current.Reset() + started = false + } + } + for i := 0; i < len(line); i++ { + switch c := line[i]; { + case c == '"': + quoted = !quoted + started = true + case (c == ' ' || c == '\t') && !quoted: + flush() + default: + current.WriteByte(c) + started = true + } + } + flush() + return fields +} diff --git a/agentinstall/job.go b/agentinstall/job.go index fab3a55..6f84c73 100644 --- a/agentinstall/job.go +++ b/agentinstall/job.go @@ -48,7 +48,10 @@ type Job struct { Command string `json:"command"` // Version is the release the job pinned, empty for the current one. Version string `json:"version,omitempty"` - Running bool `json:"running"` + // Interactive marks a job that put a window on screen and is waiting for + // whoever is at the machine, rather than one working on its own. + Interactive bool `json:"interactive,omitempty"` + 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. @@ -83,14 +86,15 @@ func Start(plan Plan) (Job, error) { 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, - Version: plan.Version, - Running: true, - StartTime: now(), + AgentId: plan.AgentId, + Name: agent.DisplayNameOf(plan.AgentId), + Action: plan.Action, + Manager: plan.Manager, + Command: plan.Command, + Version: plan.Version, + Interactive: plan.Interactive, + Running: true, + StartTime: now(), }} jobs.byAgent[plan.AgentId] = started jobs.Unlock() @@ -140,7 +144,12 @@ func run(started *job, plan Plan) { // 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) + // An uninstaller with no silent switch, and the consent prompt Windows + // raises for a machine-wide change, both need to be on screen: hiding them + // would leave the job waiting on a dialog nobody can answer. + if !plan.Interactive { + hideWindow(cmd) + } err := cmd.Run() if ctx.Err() != nil { diff --git a/agentinstall/plan.go b/agentinstall/plan.go index 77cdcb9..024ae46 100644 --- a/agentinstall/plan.go +++ b/agentinstall/plan.go @@ -12,13 +12,16 @@ // 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 installs, upgrades and removes agent CLIs and apps the +// way this host installed them: with a package manager, with the uninstaller +// the app registered, with the agent's own updater, or with the vendor's own +// install command. Everything it runs is built from the fingerprints compiled +// into Gateway and from what the host itself records, never from a request. package agentinstall import ( "os/exec" + "path/filepath" "runtime" "strings" "sync" @@ -35,16 +38,34 @@ const ( ActionUninstall = "uninstall" ) -// 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. +// The ways Gateway can change an installation. The first three are also install +// methods a scan reports, which is how an action finds the manager that owns a +// tree; the rest are what is left for the installations no package manager put +// there. const ( ManagerNpm = "npm" ManagerWinget = "winget" ManagerHomebrew = "homebrew" + // ManagerMsStore is winget's Microsoft Store source, for an app published + // only there. + ManagerMsStore = "msstore" + // ManagerAppx removes a Store app by its package family. + ManagerAppx = "appx" + // ManagerUninstaller runs the uninstaller the app registered with Windows, + // which is the one its own installer left behind. + ManagerUninstaller = "uninstaller" + // ManagerSelf runs the agent's own updater, for one that ships with it. + ManagerSelf = "self" + // ManagerScript runs the vendor's own install command, which installs and + // upgrades in one. + ManagerScript = "script" + // ManagerFiles deletes what an install left on disk, which is all that is + // left for one that registered nothing anywhere. + ManagerFiles = "files" ) -// Plan is the command that would install or upgrade one agent here, and, when -// there is none, what is missing. +// Plan is the command that would carry out one action here, and, when there is +// none, what is missing. type Plan struct { AgentId string `json:"agentId"` Action string `json:"action"` @@ -56,16 +77,23 @@ type Plan struct { Version string `json:"version,omitempty"` Available bool `json:"available"` Detail string `json:"detail,omitempty"` + // Interactive marks a command that puts a window on screen: an uninstaller + // with no silent switch, or the consent prompt Windows raises for a + // machine-wide change. It runs visibly, since hiding it would leave the job + // waiting on a dialog nobody can answer. + Interactive bool `json:"interactive,omitempty"` + // Warning is what this command does that a package manager would not, for + // the ones worth reading before clicking. + Warning string `json:"warning,omitempty"` // InstallUrl is the vendor's own page, which is all that is left for an - // agent no package manager here can install. + // agent nothing 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. +// InstallPlan picks the way to install an agent this host does not have. func InstallPlan(agentId string) Plan { return InstallVersionPlan(agentId, "") } @@ -74,17 +102,115 @@ func InstallPlan(agentId string) Plan { // a machine that wants the version the rest of its fleet is on. func InstallVersionPlan(agentId string, version string) Plan { if version != "" && !IsValidVersion(version) { - return Plan{AgentId: agentId, Action: ActionInstall, Version: version, - InstallUrl: agent.PackagesOf(agentId).InstallUrl, - Detail: "\"" + version + "\" is not a version number"} + return invalidVersionPlan(agentId, ActionInstall, version) } - return installPlan(agentId, version) + return resolve(agent.Installation{AgentId: agentId}, ActionInstall, version) } -func installPlan(agentId string, version string) Plan { - packages := agent.PackagesOf(agentId) - plan := Plan{AgentId: agentId, Action: ActionInstall, Version: version, InstallUrl: packages.InstallUrl} +// UpgradePlan updates an installation in place, through whatever owns it. +func UpgradePlan(installation agent.Installation) Plan { + return resolve(installation, ActionUpgrade, "") +} + +// VersionPlan moves an installation onto one published release. Going back is +// what it is for: an agent whose new release broke something is pinned to the +// one before it. +func VersionPlan(installation agent.Installation, version string) Plan { + action := ActionUpgrade + if CompareVersions(version, installation.Version) < 0 { + action = ActionDowngrade + } + if !IsValidVersion(version) { + return invalidVersionPlan(installation.AgentId, action, version) + } + return resolve(installation, action, version) +} + +// UninstallPlan removes an installation the way it was installed. Only the +// program goes: an agent's own state directory holds its sign-in and its +// history, and a reinstall finds them where it left them. +func UninstallPlan(installation agent.Installation) Plan { + return resolve(installation, ActionUninstall, "") +} + +func invalidVersionPlan(agentId string, action string, version string) Plan { + return Plan{AgentId: agentId, Action: action, Version: version, + InstallUrl: agent.PackagesOf(agentId).InstallUrl, + Detail: quote(version) + " is not a version number"} +} + +// target is everything a driver needs: the installation to act on, what its +// agent is published as, and what is being asked of it. +type target struct { + installation agent.Installation + packages agent.Packages + action string + version string + // notes are why the drivers tried first could not do it, which is what the + // answer says when none of them can. + notes *[]string +} + +func (t target) method() string { return t.installation.InstallMethod } + +// pinned reports an action that names one release, which only a package manager +// can carry out: a vendor's script and an agent's own updater both install +// whatever they call current. +func (t target) pinned() bool { return t.version != "" } + +func (t target) note(detail string) { + if detail != "" { + *t.notes = append(*t.notes, detail) + } +} + +// driver builds the command for one way of carrying an action out, and reports +// whether that way is open here. +type driver func(Plan, target) (Plan, bool) + +// resolve walks the ways an action can be carried out, in the order that leaves +// the host consistent: whatever owns the installation first, then the vendor's +// own tools, then what is left on disk. +func resolve(installation agent.Installation, action string, version string) Plan { + packages := agent.PackagesOf(installation.AgentId) + plan := Plan{AgentId: installation.AgentId, Action: action, Version: version, + InstallUrl: packages.InstallUrl} + + if action == ActionUninstall && installation.InstallMethod == agent.InstallMethodConfig { + plan.Detail = "Gateway found this agent's configuration directory but not its program, so there is nothing here to remove" + return plan + } + notes := []string{} + spec := target{installation: installation, packages: packages, + action: action, version: version, notes: ¬es} + + for _, build := range driversFor(action) { + if built, ok := build(plan, spec); ok { + return built + } + } + + plan.Detail = unavailableDetail(spec, notes) + return plan +} + +func driversFor(action string) []driver { + switch action { + case ActionInstall: + return []driver{installManagerDriver, scriptDriver, msStoreDriver} + case ActionUninstall: + return []driver{ownManagerDriver, uninstallerDriver, appxDriver, + selfRemoveDriver, filesDriver} + default: + return []driver{ownManagerDriver, selfUpdateDriver, scriptDriver, wingetDriver, msStoreDriver} + } +} + +// installManagerDriver 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 installManagerDriver(plan Plan, spec target) (Plan, bool) { + packages := spec.packages for _, manager := range installOrder() { switch manager { case ManagerNpm: @@ -92,131 +218,172 @@ func installPlan(agentId string, version string) Plan { continue } if program := lookup("npm"); program != "" { - return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@"+requestedRelease(version)) + return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@"+requestedRelease(spec.version)), true } + spec.note("npm publishes this agent but is not on Gateway's PATH") case ManagerHomebrew: // A cask names one version, so a pinned install has to go somewhere // that keeps the older ones. - if packages.HomebrewCask == "" || version != "" { + if packages.HomebrewCask == "" || spec.pinned() { continue } if program := lookup("brew"); program != "" { - return fill(plan, ManagerHomebrew, program, "install", "--cask", packages.HomebrewCask) + return fill(plan, ManagerHomebrew, program, "install", "--cask", packages.HomebrewCask), true } + spec.note("Homebrew publishes this agent but is not on Gateway's PATH") case ManagerWinget: if packages.Winget == "" { continue } if program := lookup("winget"); program != "" { args := []string{"install", "--id", packages.Winget, "--exact"} - if version != "" { - args = append(args, "--version", version) + if spec.pinned() { + args = append(args, "--version", spec.version) } - return fill(plan, ManagerWinget, program, append(args, wingetFlags...)...) + return fill(plan, ManagerWinget, program, append(args, wingetFlags...)...), true } + spec.note("winget publishes this agent but is not on Gateway's PATH") } } - - 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 { - return managerPlan(agentId, installMethod, ActionUpgrade, "") -} - -// VersionPlan moves an installation onto one published release, through the -// manager that owns its tree. Going back is what it is for: an agent whose new -// release broke something is pinned to the one before it. -func VersionPlan(agentId string, installMethod string, version string, current string) Plan { - action := ActionUpgrade - if CompareVersions(version, current) < 0 { - action = ActionDowngrade - } - - plan := managerPlan(agentId, installMethod, action, version) - if !IsValidVersion(version) { - return Plan{AgentId: agentId, Action: action, Version: version, - InstallUrl: plan.InstallUrl, Detail: "\"" + version + "\" is not a version number"} - } - return plan -} - -// UninstallPlan removes an installation with the manager that installed it. -// Only the program goes: an agent's own state directory holds its sign-in and -// its history, and a reinstall finds them where it left them. -func UninstallPlan(agentId string, installMethod string) Plan { - return managerPlan(agentId, installMethod, ActionUninstall, "") + return plan, false } -// managerPlan builds the command for one action against an installation whose -// manager is known. Everything but the version comes from the fingerprint. -func managerPlan(agentId string, installMethod string, action string, version string) Plan { - packages := agent.PackagesOf(agentId) - plan := Plan{AgentId: agentId, Action: action, Version: version, InstallUrl: packages.InstallUrl} +// ownManagerDriver acts through the package manager that owns the tree, when +// one does: another manager would install a second copy beside it. +func ownManagerDriver(plan Plan, spec target) (Plan, bool) { + packages := spec.packages - switch installMethod { + switch spec.method() { case ManagerNpm: if packages.Npm == "" { - break + return plan, false } program := lookup("npm") if program == "" { - plan.Detail = "npm installed this agent but is not on Gateway's PATH" - return plan + spec.note("npm installed this agent but is not on Gateway's PATH") + return plan, false } - if action == ActionUninstall { - return fill(plan, ManagerNpm, program, "uninstall", "-g", packages.Npm) + if spec.action == ActionUninstall { + return fill(plan, ManagerNpm, program, "uninstall", "-g", packages.Npm), true } - return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@"+requestedRelease(version)) + return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@"+requestedRelease(spec.version)), true case ManagerHomebrew: if packages.HomebrewCask == "" { - break + return plan, false } program := lookup("brew") if program == "" { - plan.Detail = "Homebrew installed this agent but is not on Gateway's PATH" - return plan + spec.note("Homebrew installed this agent but is not on Gateway's PATH") + return plan, false } - switch action { - case ActionUninstall: - return fill(plan, ManagerHomebrew, program, "uninstall", "--cask", packages.HomebrewCask) - case ActionUpgrade: - if version == "" { - return fill(plan, ManagerHomebrew, program, "upgrade", "--cask", packages.HomebrewCask) - } + switch { + case spec.action == ActionUninstall: + return fill(plan, ManagerHomebrew, program, "uninstall", "--cask", packages.HomebrewCask), true + case !spec.pinned(): + return fill(plan, ManagerHomebrew, program, "upgrade", "--cask", packages.HomebrewCask), true } // A cask carries one version, the current one, so there is no older // release to ask it for. - plan.Detail = "Homebrew only installs the version its cask names; use the vendor's own downloads for an older one" - return plan + spec.note("Homebrew only installs the version its cask names") + return plan, false case ManagerWinget: - if packages.Winget == "" { - break - } - program := lookup("winget") - if program == "" { - plan.Detail = "winget installed this agent but is not on Gateway's PATH" - return plan - } - exact := []string{"--id", packages.Winget, "--exact"} - switch action { - case ActionUninstall: - return fill(plan, ManagerWinget, program, append(append([]string{"uninstall"}, exact...), wingetUninstallFlags...)...) - case ActionUpgrade: - if version == "" { - return fill(plan, ManagerWinget, program, append(append([]string{"upgrade"}, exact...), wingetFlags...)...) - } - } - // An older version needs the install verb: upgrade refuses to move back. + return wingetDriver(plan, spec) + } + return plan, false +} + +// wingetDriver acts through winget on an installation winget did not make. +// Windows records every installer's own uninstall entry and winget matches its +// packages against them, so it upgrades and removes an app the vendor's own +// installer put there. +func wingetDriver(plan Plan, spec target) (Plan, bool) { + if runtime.GOOS != "windows" || spec.packages.Winget == "" { + return plan, false + } + program := lookup("winget") + if program == "" { + spec.note("winget publishes this agent but is not on Gateway's PATH") + return plan, false + } + + exact := []string{"--id", spec.packages.Winget, "--exact"} + switch { + case spec.action == ActionUninstall: + return fill(plan, ManagerWinget, program, + append(append([]string{"uninstall"}, exact...), wingetUninstallFlags...)...), true + case !spec.pinned(): return fill(plan, ManagerWinget, program, - append(append([]string{"install"}, append(exact, "--version", version)...), wingetFlags...)...) + append(append([]string{"upgrade"}, exact...), wingetFlags...)...), true + } + // An older release needs the install verb: upgrade refuses to move back. + return fill(plan, ManagerWinget, program, + append(append([]string{"install"}, append(exact, "--version", spec.version)...), wingetFlags...)...), true +} + +// msStoreDriver drives an app published only in the Microsoft Store, which +// winget installs and updates from a source of its own. +func msStoreDriver(plan Plan, spec target) (Plan, bool) { + if runtime.GOOS != "windows" || spec.packages.MsStore == "" || spec.pinned() { + return plan, false + } + program := lookup("winget") + if program == "" { + spec.note("this agent comes from the Microsoft Store, and winget is not on Gateway's PATH") + return plan, false } - plan.Detail = "this agent was installed as \"" + installMethod + "\", which Gateway cannot " + action + "; use its own updater" - return plan + verb := "install" + if spec.action != ActionInstall { + verb = "upgrade" + } + args := []string{verb, "--id", spec.packages.MsStore, "--exact", "--source", "msstore"} + return fill(plan, ManagerMsStore, program, append(args, wingetFlags...)...), true +} + +// selfUpdateDriver runs the updater the agent ships with, which is how an +// installation a vendor's script put there moves to a new release. +func selfUpdateDriver(plan Plan, spec target) (Plan, bool) { + if len(spec.packages.UpdateArgs) == 0 || spec.pinned() { + return plan, false + } + program := launcherOf(spec.installation) + if program == "" { + return plan, false + } + return fill(plan, ManagerSelf, program, spec.packages.UpdateArgs...), true +} + +// selfRemoveDriver is the same for an agent that removes itself. +func selfRemoveDriver(plan Plan, spec target) (Plan, bool) { + if len(spec.packages.RemoveArgs) == 0 { + return plan, false + } + program := launcherOf(spec.installation) + if program == "" { + return plan, false + } + return fill(plan, ManagerSelf, program, spec.packages.RemoveArgs...), true +} + +// scriptDriver runs the vendor's own install command, the one their setup page +// tells a person to paste. It installs and upgrades alike, which is what an +// agent published on no package manager here is left with. +func scriptDriver(plan Plan, spec target) (Plan, bool) { + script := spec.packages.Script + if script == "" || spec.pinned() { + return plan, false + } + + shell, args := shellCommand(script) + if shell == "" { + return plan, false + } + built := fill(plan, ManagerScript, shell, args...) + // The wrapper is Gateway's; the command is the vendor's, and that is the + // one worth reading before it runs. + built.Command = script + built.Warning = "this runs the vendor's own install command, which downloads and runs their installer" + return built, true } // requestedRelease is the npm tag or version an install asks for. @@ -279,14 +446,45 @@ 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.Command = strings.TrimSpace(programName(program) + " " + strings.Join(args, " ")) plan.Available = true return plan } -// unavailableDetail says which of the two is missing: the package, or the +// programName is what to call the program in the command a page shows: its own +// name, since the manager that chose it is named beside it anyway. Windows +// resolves npm to npm.cmd and a launcher to launcher.exe, and neither extension +// is part of what anyone would type. +func programName(program string) string { + name := filepath.Base(program) + for _, extension := range []string{".exe", ".cmd", ".bat", ".com", ".ps1"} { + if strings.EqualFold(filepath.Ext(name), extension) { + return name[:len(name)-len(extension)] + } + } + return name +} + +// unavailableDetail says why nothing can be done, preferring what a driver +// found over a guess from the fingerprint. +func unavailableDetail(spec target, notes []string) string { + if len(notes) > 0 { + return strings.Join(notes, "; ") + } + if spec.action == ActionInstall { + return missingInstallDetail(spec.packages) + } + if spec.packages.Desktop { + return "this app was installed as " + quote(spec.method()) + + " and registered nothing Gateway can drive; use its own updater, or Windows Settings to remove it" + } + return "this agent was installed as " + quote(spec.method()) + + ", which registered no updater or uninstaller Gateway can find" +} + +// missingInstallDetail says which of the two is missing: the package, or the // manager that would install it. -func unavailableDetail(packages agent.Packages) string { +func missingInstallDetail(packages agent.Packages) string { var managers []string if packages.Npm != "" { managers = append(managers, "npm") @@ -306,6 +504,10 @@ func unavailableDetail(packages agent.Packages) string { return "install " + strings.Join(managers, " or ") + " first, or install the agent from its vendor's page" } +func quote(value string) string { + return "\"" + value + "\"" +} + // 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 diff --git a/agentinstall/versions.go b/agentinstall/versions.go index 7e9c7ec..aa2b592 100644 --- a/agentinstall/versions.go +++ b/agentinstall/versions.go @@ -238,12 +238,14 @@ func withAgent(catalog Catalog, agentId string, installMethod string) Catalog { } // The template is built here rather than cached: it names the program the // PATH search resolved, which the cache outlives. - plan := managerPlan(agentId, installMethod, ActionDowngrade, versionPlaceholder) + action := ActionDowngrade if installMethod == "" { // Nothing installed, so there is no manager that owns a tree: the one // an install would reach for answers instead. - plan = installPlan(agentId, versionPlaceholder) + action = ActionInstall } + plan := resolve(agent.Installation{AgentId: agentId, InstallMethod: installMethod}, + action, versionPlaceholder) if plan.Available { catalog.CommandTemplate = plan.Command } @@ -255,16 +257,6 @@ func withAgent(catalog Catalog, agentId string, installMethod string) Catalog { // does one whose manager does not publish this agent. func managerPackage(agentId string, installMethod string) (string, string) { packages := agent.PackagesOf(agentId) - if installMethod == "" { - // Not installed: the manager an install would reach for is the one - // whose releases are worth listing. - for _, candidate := range installOrder() { - if manager, packageId := managerPackage(agentId, candidate); manager != "" { - return manager, packageId - } - } - return "", "" - } switch installMethod { case ManagerNpm: @@ -277,6 +269,16 @@ func managerPackage(agentId string, installMethod string) (string, string) { } return withPackage(ManagerWinget, packages.Winget) } + + // Either nothing is installed, or it was installed by something that + // publishes no releases of its own - a vendor's script, a setup program. + // The manager that publishes the agent here still names what the current + // release is, which is what an update check is asking for. + for _, candidate := range installOrder() { + if manager, packageId := managerPackage(agentId, candidate); manager != "" { + return manager, packageId + } + } return "", "" } @@ -288,10 +290,6 @@ func withPackage(manager string, packageId string) (string, string) { } func catalogUnavailableDetail(agentId string, installMethod string) string { - if installMethod != "" && installMethod != ManagerNpm && - installMethod != ManagerHomebrew && installMethod != ManagerWinget { - return "this agent was installed as \"" + installMethod + "\", which publishes no version list Gateway can read" - } if agent.PackagesOf(agentId).Desktop { return "this is a desktop app; its versions come from the vendor's own downloads" } diff --git a/controllers/agent.go b/controllers/agent.go index c912893..c3b8aac 100644 --- a/controllers/agent.go +++ b/controllers/agent.go @@ -94,8 +94,8 @@ func (c *ApiController) GetAgents() { Fallbacks: []string{}, Mode: object.ModeGateway, ProviderConfig: agentprovider.StatusOf(providerTarget(target)), - Upgrade: agentinstall.UpgradePlan(installation.AgentId, installation.InstallMethod), - Uninstall: agentinstall.UninstallPlan(installation.AgentId, installation.InstallMethod), + Upgrade: agentinstall.UpgradePlan(installation), + Uninstall: agentinstall.UninstallPlan(installation), SupportsInstances: agent.SupportsInstances(installation.AgentId), } diff --git a/controllers/agent_install.go b/controllers/agent_install.go index 99911ae..3bfe13e 100644 --- a/controllers/agent_install.go +++ b/controllers/agent_install.go @@ -66,7 +66,7 @@ func (c *ApiController) UpgradeAgent() { return } - job, err := agentinstall.Start(agentinstall.UpgradePlan(installation.AgentId, installation.InstallMethod)) + job, err := agentinstall.Start(agentinstall.UpgradePlan(installation)) if err != nil { c.ResponseError(err.Error()) return @@ -107,8 +107,7 @@ func (c *ApiController) SetAgentVersion() { return } - plan := agentinstall.VersionPlan( - installation.AgentId, installation.InstallMethod, form.Version, installation.Version) + plan := agentinstall.VersionPlan(installation, form.Version) job, err := agentinstall.Start(plan) if err != nil { c.ResponseError(err.Error()) @@ -130,8 +129,7 @@ func (c *ApiController) UninstallAgent() { return } - job, err := agentinstall.Start( - agentinstall.UninstallPlan(installation.AgentId, installation.InstallMethod)) + job, err := agentinstall.Start(agentinstall.UninstallPlan(installation)) if err != nil { c.ResponseError(err.Error()) return diff --git a/web/src/components/AgentInstallJob.tsx b/web/src/components/AgentInstallJob.tsx new file mode 100644 index 0000000..bbe4455 --- /dev/null +++ b/web/src/components/AgentInstallJob.tsx @@ -0,0 +1,256 @@ +// 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 {CheckCircle2, Copy, MonitorCog, ScrollText, XCircle} from "lucide-react"; +import copy from "copy-to-clipboard"; +import i18next from "i18next"; + +import * as Setting from "@/Setting"; +import {Badge} from "@/components/ui/badge"; +import {Button} from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import type {AgentInstallJob} from "@/types"; + +/** What a job is doing, in the words the buttons use for it. */ +export function installActionLabel(action: string, running: boolean) { + switch (action) { + case "uninstall": + return i18next.t(running ? "agent:Uninstalling" : "agent:Uninstall"); + case "downgrade": + return i18next.t(running ? "agent:Switching version" : "agent:Switch version"); + case "upgrade": + return i18next.t(running ? "agent:Upgrading" : "agent:Upgrade"); + default: + return i18next.t(running ? "agent:Installing" : "agent:Install"); + } +} + +/** + * The step a job is on. A package manager prints its progress rather than + * reporting it, so the last line it wrote is the most honest answer there is to + * "what is it doing now". + */ +function currentStep(job: AgentInstallJob) { + const lines = job.output.split("\n").map(line => line.trim()).filter(line => line !== ""); + return lines.length > 0 ? lines[lines.length - 1] : ""; +} + +/** + * How far along a job is, when its own output says. winget prints a percentage + * and npm does not, so this is a real number when there is one and nothing at + * all when there is not - never a guess that fills up on a timer. + */ +function reportedPercent(job: AgentInstallJob) { + const matches = job.output.match(/(\d{1,3})\s?%/g); + if (!matches || matches.length === 0) { + return undefined; + } + const last = Number(matches[matches.length - 1].replace(/[^\d]/g, "")); + return last >= 0 && last <= 100 ? last : undefined; +} + +/** How long the job has been going, or how long it took. */ +function elapsedOf(job: AgentInstallJob, now: number) { + const started = Date.parse(job.startTime); + const ended = job.endTime ? Date.parse(job.endTime) : now; + if (!Number.isFinite(started) || !Number.isFinite(ended)) { + return ""; + } + const seconds = Math.max(0, Math.round((ended - started) / 1000)); + return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + +/** A clock that only runs while something is being timed. */ +function useElapsed(active: boolean) { + const [now, setNow] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!active) { + return; + } + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [active]); + + return now; +} + +/** + * The bar under a running install. A percentage is shown when the manager + * reports one; otherwise the bar sweeps, which says that work is happening + * without claiming to know how much is left. + */ +function InstallBar({percent}: {percent?: number}) { + return ( + <div className="bg-muted relative h-1.5 w-full overflow-hidden rounded-full"> + {percent === undefined ? ( + <span + className="bg-primary absolute inset-y-0 w-2/5 rounded-full" + style={{animation: "install-sweep 1.4s ease-in-out infinite"}} + /> + ) : ( + <span + className="bg-primary absolute inset-y-0 left-0 rounded-full transition-all" + style={{width: `${percent}%`}} + /> + )} + </div> + ); +} + +/** + * What one install, upgrade or removal is doing, in a row: the step it is on, a + * bar while it runs, and the way into the console output it is writing. It + * stays after the job ends, so a failure can be read rather than guessed at. + */ +export function InstallJobProgress({job, className = ""}: {job?: AgentInstallJob; className?: string}) { + const now = useElapsed(job?.running === true); + if (!job) { + return null; + } + + const step = currentStep(job); + const elapsed = elapsedOf(job, now); + + return ( + <div className={`min-w-0 space-y-1.5 ${className}`}> + <div className="flex items-center gap-2 text-xs"> + <InstallJobBadge job={job} /> + <span className="text-muted-foreground tabular-nums">{elapsed}</span> + <InstallLogDialog job={job}> + <Button size="sm" variant="ghost" className="ml-auto h-6 px-2 text-xs"> + <ScrollText className="size-3" /> + {i18next.t("agent:Log")} + </Button> + </InstallLogDialog> + </div> + + {job.running ? <InstallBar percent={reportedPercent(job)} /> : null} + + {job.interactive && job.running ? ( + <p className="text-warning flex items-center gap-1.5 text-xs"> + <MonitorCog className="size-3 shrink-0" /> + {i18next.t("agent:Waiting at the machine")} + </p> + ) : null} + + {step || job.error ? ( + <p className="text-muted-foreground truncate font-mono text-xs" title={job.error || step}> + {job.error || step} + </p> + ) : null} + </div> + ); +} + +/** Where a job stands: running, done, or failed. */ +export function InstallJobBadge({job}: {job: AgentInstallJob}) { + if (job.running) { + return ( + <Badge variant="info"> + <span className="bg-info size-1.5 animate-pulse rounded-full" /> + {installActionLabel(job.action, true)} + </Badge> + ); + } + return ( + <Badge variant={job.ok ? "success" : "destructive"}> + {job.ok ? <CheckCircle2 className="size-3" /> : <XCircle className="size-3" />} + {i18next.t(job.ok ? "agent:Finished" : "agent:Failed")} + </Badge> + ); +} + +/** + * Everything one package manager printed, live while it prints it. An install + * that fails says why on its own last lines, so this is where that goes rather + * than a message that only says it failed. + */ +export function InstallLogDialog({ + job, + children, +}: { + job?: AgentInstallJob; + children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + const bottom = React.useRef<HTMLDivElement>(null); + const output = job?.output ?? ""; + + // A running install keeps writing, and the end is the part worth reading. + React.useEffect(() => { + if (open) { + bottom.current?.scrollIntoView({block: "end"}); + } + }, [open, output]); + + if (!job) { + return null; + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent className="sm:max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + {installActionLabel(job.action, false)} · {job.name} + <InstallJobBadge job={job} /> + </DialogTitle> + <DialogDescription>{i18next.t("agent:Install log hint")}</DialogDescription> + </DialogHeader> + + <code className="bg-muted block rounded p-2 text-xs break-all">{job.command}</code> + + <pre className="bg-muted/60 scrollbar-thin max-h-80 overflow-auto rounded p-3 text-xs whitespace-pre-wrap"> + {output || i18next.t("agent:No output yet")} + <div ref={bottom} /> + </pre> + + {job.error ? <p className="text-destructive text-xs">{job.error}</p> : null} + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + copy(`${job.command}\n\n${output}`); + Setting.showMessage("success", i18next.t("general:Copied to clipboard")); + }} + > + <Copy /> + {i18next.t("general:Copy")} + </Button> + <Button onClick={() => setOpen(false)}>{i18next.t("general:Close")}</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +/** The tail of what a package manager printed, for the places with room for it. */ +export function InstallOutput({job}: {job?: AgentInstallJob}) { + if (!job) { + return null; + } + return <InstallJobProgress job={job} />; +} diff --git a/web/src/components/AgentVersionDialog.tsx b/web/src/components/AgentVersionDialog.tsx index d0079ac..7dd3d0f 100644 --- a/web/src/components/AgentVersionDialog.tsx +++ b/web/src/components/AgentVersionDialog.tsx @@ -17,7 +17,7 @@ import {ArrowUpCircle, History, RefreshCw, Trash2} from "lucide-react"; import i18next from "i18next"; import * as AgentBackend from "@/backend/AgentBackend"; -import {ConfirmDialog} from "@/components/shared/confirm-dialog"; +import {AgentActionButton} from "@/components/ToolUpgradeConfirmDialog"; import {Badge} from "@/components/ui/badge"; import {Button} from "@/components/ui/button"; import { @@ -37,7 +37,7 @@ import { SelectValue, } from "@/components/ui/select"; import {SimpleTooltip} from "@/components/ui/tooltip"; -import type {Agent, AgentUpdate, AgentVersionCatalog} from "@/types"; +import type {Agent, AgentInstallJob, AgentUpdate, AgentVersionCatalog} from "@/types"; /** * The mark on an installation a newer release is waiting for. Nothing is shown @@ -221,48 +221,32 @@ export function AgentVersionDialog({ /** * The guard in front of an uninstall. The program goes; the agent's own state - * directory, with its sign-in and its history, stays where it is, so the - * dialog says so rather than leaving that to be discovered. + * directory, with its sign-in and its history, stays where it is, so the dialog + * says so rather than leaving that to be discovered. */ export function ToolUninstallConfirmDialog({ agent, + job, busy, onConfirm, }: { agent: Agent; + job?: AgentInstallJob; busy: boolean; onConfirm: () => void; }) { - const plan = agent.uninstall; - - const button = ( - <Button size="sm" variant="outline" disabled={!plan?.available} loading={busy}> - <Trash2 /> - {i18next.t("agent:Uninstall")} - </Button> - ); - - if (!plan?.available) { - return ( - <SimpleTooltip title={plan?.detail}> - <span>{button}</span> - </SimpleTooltip> - ); - } - return ( - <ConfirmDialog + <AgentActionButton title={i18next.t("agent:Uninstall {agent}?").replace("{agent}", agent.name)} - description={ - <span className="space-y-2"> - <span className="block">{i18next.t("agent:Uninstall hint")}</span> - <code className="bg-muted block rounded p-2 text-xs break-all">{plan.command}</code> - </span> - } - confirmText={i18next.t("agent:Uninstall")} + label={i18next.t("agent:Uninstall")} + runningLabel={i18next.t("agent:Uninstalling")} + icon={<Trash2 />} + plan={agent.uninstall} + job={job} + busy={busy} + confirmVariant="destructive" + hint={i18next.t("agent:Uninstall hint")} onConfirm={onConfirm} - > - {button} - </ConfirmDialog> + /> ); } diff --git a/web/src/components/ToolInstallRow.tsx b/web/src/components/ToolInstallRow.tsx index f1f2e68..6301db2 100644 --- a/web/src/components/ToolInstallRow.tsx +++ b/web/src/components/ToolInstallRow.tsx @@ -12,13 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Bot, Download, ExternalLink} from "lucide-react"; +import {Bot, ExternalLink} from "lucide-react"; import i18next from "i18next"; import {AgentIcon} from "@/components/AgentIcon"; -import {InstallOutput} from "@/components/ToolUpgradeConfirmDialog"; +import {InstallJobProgress} from "@/components/AgentInstallJob"; +import {AgentInstallButton} 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"; @@ -42,7 +42,6 @@ export function ToolInstallRow({ onInstall: (agentId: string) => void; }) { const plan = entry.install; - const running = job?.running === true; const failed = job !== undefined && !job.running && !job.ok; const fallbackUrl = !plan.available ? entry.installUrl : undefined; @@ -71,30 +70,17 @@ export function ToolInstallRow({ </SimpleTooltip> ) : null} - <SimpleTooltip - title={plan.available ? plan.command : fallbackUrl ? i18next.t("agent:Install page") : plan.detail} - > - <span className="shrink-0"> - <Button - size="sm" - variant={failed ? "outline" : "default"} - disabled={(!plan.available && !fallbackUrl) || running} - loading={busy || running} - onClick={() => { - if (plan.available) { - onInstall(entry.agentId); - } else if (fallbackUrl) { - window.open(fallbackUrl, "_blank", "noreferrer"); - } - }} - > - {fallbackUrl ? <ExternalLink /> : <Download />} - {i18next.t( - running ? "agent:Installing" : failed ? "agent:Retry" : "agent:Install", - )} - </Button> - </span> - </SimpleTooltip> + <span className="shrink-0"> + <AgentInstallButton + name={entry.name} + plan={plan} + installUrl={entry.installUrl} + job={job} + busy={busy} + label={failed ? i18next.t("agent:Retry") : undefined} + onInstall={() => onInstall(entry.agentId)} + /> + </span> </div> <div className="flex flex-wrap items-center gap-2"> @@ -108,7 +94,7 @@ export function ToolInstallRow({ ) : null} </div> - <InstallOutput job={job} /> + <InstallJobProgress job={job} /> </div> ); } diff --git a/web/src/components/ToolUpgradeConfirmDialog.tsx b/web/src/components/ToolUpgradeConfirmDialog.tsx index 36a6b2c..62a07c2 100644 --- a/web/src/components/ToolUpgradeConfirmDialog.tsx +++ b/web/src/components/ToolUpgradeConfirmDialog.tsx @@ -12,56 +12,89 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {ArrowUpCircle} from "lucide-react"; +import * as React from "react"; +import {AlertTriangle, ArrowUpCircle, Download, ExternalLink, MonitorCog} from "lucide-react"; import i18next from "i18next"; +import type {VariantProps} from "class-variance-authority"; import {ConfirmDialog} from "@/components/shared/confirm-dialog"; -import {Button} from "@/components/ui/button"; +import {Button, type buttonVariants} from "@/components/ui/button"; import {SimpleTooltip} from "@/components/ui/tooltip"; -import type {Agent, AgentInstallJob} from "@/types"; +import type {Agent, AgentInstallJob, AgentInstallPlan} 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> - ); -} +export {InstallJobProgress, InstallLogDialog, InstallOutput} from "@/components/AgentInstallJob"; /** - * 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. + * One click that changes what is on disk. Whatever the agent was installed with + * - a package manager, the uninstaller it registered with Windows, its own + * updater, or the vendor's install command - the command is spelled out first + * and confirmed before it runs. + * + * An agent nothing here can drive keeps a click of its own: the vendor's page, + * which is where that install has to be done by hand. A button that only greys + * out tells a person nothing they can act on. */ -export function ToolUpgradeConfirmDialog({ - agent, +export function AgentActionButton({ + title, + label, + runningLabel, + icon, + plan, job, busy, + variant = "outline", + confirmVariant = "default", + hint, + fallbackUrl, + fallbackLabel, onConfirm, }: { - agent: Agent; - /** The running or last upgrade of this agent, when there was one. */ + title: React.ReactNode; + label: string; + runningLabel: string; + icon: React.ReactNode; + plan?: AgentInstallPlan; + /** The running or last job of this agent, when there was one. */ job?: AgentInstallJob; busy: boolean; + variant?: VariantProps<typeof buttonVariants>["variant"]; + confirmVariant?: VariantProps<typeof buttonVariants>["variant"]; + /** What this action does, in a sentence, above the command itself. */ + hint?: React.ReactNode; + /** The vendor's page, for when Gateway has no command for this agent. */ + fallbackUrl?: string; + /** What that page is called, since it is not the action it stands in for. */ + fallbackLabel?: string; onConfirm: () => void; }) { - const plan = agent.upgrade; const running = job?.running === true; + // Gateway has no command for this one, but the vendor's own page still does + // what the button was for, so it goes there rather than greying out. + if (!plan?.available && fallbackUrl) { + return ( + <SimpleTooltip title={plan?.detail}> + <Button + size="sm" + variant={variant} + onClick={() => window.open(fallbackUrl, "_blank", "noreferrer")} + > + <ExternalLink /> + {fallbackLabel ?? i18next.t("agent:Vendor page")} + </Button> + </SimpleTooltip> + ); + } + const button = ( - <Button size="sm" variant="outline" disabled={!plan?.available || running} loading={busy || running}> - <ArrowUpCircle /> - {i18next.t(running ? "agent:Upgrading" : "agent:Upgrade")} + <Button size="sm" variant={variant} disabled={!plan?.available} loading={busy || running}> + {icon} + {running ? runningLabel : label} </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. + // Nothing at all can reach this installation, so the button says why instead + // of opening a dialog with no command in it. if (!plan?.available) { return ( <SimpleTooltip title={plan?.detail}> @@ -72,18 +105,122 @@ export function ToolUpgradeConfirmDialog({ return ( <ConfirmDialog - title={i18next.t("agent:Upgrade {agent}?").replace("{agent}", agent.name)} + title={title} description={ <span className="space-y-2"> - <span className="block">{i18next.t("agent:Upgrade hint")}</span> + {hint ? <span className="block">{hint}</span> : null} <code className="bg-muted block rounded p-2 text-xs break-all">{plan.command}</code> + {plan.warning ? ( + <span className="text-warning flex items-start gap-1.5"> + <AlertTriangle className="mt-0.5 size-3.5 shrink-0" /> + {plan.warning} + </span> + ) : null} + {plan.interactive ? ( + <span className="text-muted-foreground flex items-start gap-1.5"> + <MonitorCog className="mt-0.5 size-3.5 shrink-0" /> + {i18next.t("agent:Interactive hint")} + </span> + ) : null} </span> } - confirmText={i18next.t("agent:Upgrade")} - variant="default" + confirmText={label} + variant={confirmVariant} onConfirm={onConfirm} > {button} </ConfirmDialog> ); } + +/** + * 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; + job?: AgentInstallJob; + busy: boolean; + onConfirm: () => void; +}) { + return ( + <AgentActionButton + title={i18next.t("agent:Upgrade {agent}?").replace("{agent}", agent.name)} + label={i18next.t("agent:Upgrade")} + runningLabel={i18next.t("agent:Upgrading")} + icon={<ArrowUpCircle />} + plan={agent.upgrade} + job={job} + busy={busy} + hint={i18next.t("agent:Upgrade hint")} + fallbackUrl={agent.upgrade?.installUrl} + fallbackLabel={i18next.t("agent:Update manually")} + onConfirm={onConfirm} + /> + ); +} + +/** + * The click that installs an agent this machine does not have. It is one click: + * an install adds a program rather than replacing or removing one, so there is + * nothing to undo and nothing to warn about - unless the only way in is the + * vendor's own script, which is confirmed like any other command that runs + * something downloaded. + */ +export function AgentInstallButton({ + name, + plan, + installUrl, + job, + busy, + label, + onInstall, +}: { + name: string; + plan?: AgentInstallPlan; + /** The vendor's own page, where an agent nothing here installs is got. */ + installUrl?: string; + job?: AgentInstallJob; + busy: boolean; + /** What the button says, for the places that call a second try a retry. */ + label?: string; + onInstall: () => void; +}) { + const running = job?.running === true; + const text = label ?? i18next.t("agent:Install"); + + if (plan?.available && !plan.warning) { + return ( + <SimpleTooltip title={plan.command}> + <span> + <Button size="sm" disabled={running} loading={busy || running} onClick={onInstall}> + <Download /> + {running ? i18next.t("agent:Installing") : text} + </Button> + </span> + </SimpleTooltip> + ); + } + + return ( + <AgentActionButton + title={i18next.t("agent:Install {agent}?").replace("{agent}", name)} + label={text} + runningLabel={i18next.t("agent:Installing")} + icon={<Download />} + plan={plan} + job={job} + busy={busy} + variant="default" + hint={i18next.t("agent:Install hint")} + fallbackUrl={installUrl} + onConfirm={onInstall} + /> + ); +} diff --git a/web/src/index.css b/web/src/index.css index ba66af0..280276e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -505,3 +505,10 @@ html[data-palette="neutral"].dark { 0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; } 40% { transform: scale(1); opacity: 1; } } + +/* The bar under a running install, for the package managers that report no + percentage of their own: it says work is happening, not how much is left. */ +@keyframes install-sweep { + 0% { left: -40%; } + 100% { left: 100%; } +} diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 4087dae..6b55cd4 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -108,6 +108,7 @@ "Event": "Event", "Extra rules": "Extra rules", "Extra rules hint": "One policy line per row, as \"sub, obj, act, eft\". They are checked before everything the switches wrote, so they can carve out what the switches cannot say.", + "Failed": "Failed", "Failed to add the instance": "Failed to add the instance", "Failed to get agent records": "Failed to get agent records", "Failed to get agent sessions": "Failed to get agent sessions", @@ -124,6 +125,7 @@ "Fetch a URL": "Fetch a URL", "Filter": "Filter", "Find files by name": "Find files by name", + "Finished": "Finished", "First activity": "First activity", "From the transcript": "From the transcript", "Gateway": "Gateway", @@ -137,7 +139,10 @@ "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 hint": "Installs this agent on this machine with the command below.", + "Install log hint": "Everything the command printed, live while it runs.", "Install page": "The vendor's own install page", + "Install {agent}?": "Install {agent}?", "Installation": "Installation", "Installed": "Installed", "Installed version": "Installed version", @@ -146,6 +151,7 @@ "Instance removed": "Instance removed", "Instances": "Instances", "Instances hint": "Extra copies of this agent, each with a state directory of its own, so each is signed in to its own account and they run at the same time.", + "Interactive hint": "This opens a window on the machine and waits for an answer there.", "Keep a task list": "Keep a task list", "Kimi Code config hint": "Kimi Code keeps the endpoint in ~/.kimi-code/config.toml: a [providers.casbin-gateway] table with type \"openai\", base_url and api_key, and one [models.*] alias per model, picked by default_model at the root of the file. It reads no credential from the environment, and KIMI_CODE_HOME moves the whole directory.", "Last activity": "Last activity", @@ -154,6 +160,7 @@ "Latest version": "Latest version", "Leave plan mode": "Leave plan mode", "List a directory": "List a directory", + "Log": "Log", "Look at an image": "Look at an image", "MCP servers": "MCP servers", "MCP target": "MCP target", @@ -172,6 +179,7 @@ "No agent sessions yet - patch an agent to start collecting them": "No sessions yet - turn on monitoring for an agent to start collecting them", "No instances yet": "No instances yet - add one to sign in with a second account", "No other provider speaks this API": "No other provider speaks this API", + "No output yet": "No output yet", "No provider yet": "No provider has been added yet.", "No rule yet": "no rule yet", "No supported agents found": "No supported agents found", @@ -266,6 +274,7 @@ "Stopped": "Stopped", "Switch takes effect at once": "Picking another provider above rewrites this file at once, so switching takes one click.", "Switch version": "Switch version", + "Switching version": "Switching version", "Target / Model": "Target / Model", "Terminal": "Terminal", "The internet": "The internet", @@ -282,20 +291,23 @@ "Transcript truncated": "Some of this session was too large to show, so what follows is not all of it", "Turn on monitoring to collect activity": "Turn on monitoring to collect activity", "Uninstall": "Uninstall", - "Uninstall hint": "The package manager removes the program. The agent's own directory, with its sign-in and its history, stays where it is.", + "Uninstall hint": "Removes the program the way it was installed. The agent's own directory, with its sign-in and its history, stays where it is.", "Uninstall {agent}?": "Uninstall {agent}?", + "Uninstalling": "Uninstalling", "Unknown": "Unknown", "Unpatch": "Turn off monitoring", "Unrestricted": "Unrestricted", "Unrestricted hint": "This agent may ask for any model, reach any provider and use every tool.", "Up to date": "Up to date", + "Update manually": "Update manually", "Updates available": "Updates available", "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 hint": "Replaces this installation with the latest version, the way it was installed. Restart the agent afterwards to run the new one.", "Upgrade {agent}?": "Upgrade {agent}?", "Upgrading": "Upgrading", "Usage": "Usage", "User": "User", + "Vendor page": "Vendor page", "Version": "Version", "Version install hint": "Install this agent at any release its package manager still publishes, not only the current one.", "Version page hint": "Which build of each agent is on this machine, and what its package manager publishes", @@ -304,6 +316,7 @@ "Versions of {agent}": "Versions of {agent}", "View Records": "View Records", "View all": "View all", + "Waiting at the machine": "Waiting for an answer at the machine", "Waiting for the sign-in link": "Waiting for the sign-in link", "What it may do": "What it may do", "Write configuration": "Write configuration", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index c4fc079..498fcef 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -108,6 +108,7 @@ "Event": "事件", "Extra rules": "额外规则", "Extra rules hint": "每行一条策略,格式为 \"sub, obj, act, eft\"。它们排在开关生成的规则之前,用来表达开关表达不了的例外。", + "Failed": "失败", "Failed to add the instance": "添加实例失败", "Failed to get agent records": "获取 Agent 记录失败", "Failed to get agent sessions": "获取 Agent 会话失败", @@ -124,6 +125,7 @@ "Fetch a URL": "抓取网址", "Filter": "筛选", "Find files by name": "按文件名查找", + "Finished": "已完成", "First activity": "首次活动", "From the transcript": "来自会话记录", "Gateway": "网关", @@ -137,7 +139,10 @@ "Install": "安装", "Install Method": "安装方式", "Install an AI agent on this machine, then scan again": "在本机安装一个 AI 智能体后,再扫描一次", + "Install hint": "用下面的命令在本机安装该 Agent。", + "Install log hint": "命令输出的全部内容,运行时实时更新。", "Install page": "厂商的安装页面", + "Install {agent}?": "安装 {agent}?", "Installation": "安装信息", "Installed": "已安装", "Installed version": "已安装版本", @@ -146,6 +151,7 @@ "Instance removed": "已移除实例", "Instances": "实例", "Instances hint": "该 Agent 的多个副本,每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。", + "Interactive hint": "这会在本机弹出窗口,需要在本机上确认。", "Keep a task list": "维护任务清单", "Kimi Code config hint": "Kimi Code 的端点写在 ~/.kimi-code/config.toml 里:一个 [providers.casbin-gateway] 表,含 type \"openai\"、base_url 和 api_key,每个模型再写一个 [models.*] 别名,由文件根部的 default_model 选中。它不从环境变量读取任何凭据,KIMI_CODE_HOME 会把整个目录挪到别处。", "Last activity": "最后活动", @@ -154,6 +160,7 @@ "Latest version": "最新版本", "Leave plan mode": "退出计划模式", "List a directory": "列出目录", + "Log": "日志", "Look at an image": "查看图片", "MCP servers": "MCP 服务", "MCP target": "MCP 目标", @@ -172,6 +179,7 @@ "No agent sessions yet - patch an agent to start collecting them": "还没有会话,开启某个智能体的监控后才会开始收集", "No instances yet": "暂无实例 - 添加一个即可用第二个账号登录", "No other provider speaks this API": "没有其他供应商使用相同的 API 协议", + "No output yet": "暂无输出", "No provider yet": "还没有添加任何供应商。", "No rule yet": "还没有任何规则", "No supported agents found": "没有发现本机安装的智能体", @@ -266,6 +274,7 @@ "Stopped": "未运行", "Switch takes effect at once": "在上面换一个供应商会立即改写这个文件,所以切换只需要点一下。", "Switch version": "切换版本", + "Switching version": "切换版本中", "Target / Model": "目标 / 模型", "Terminal": "终端", "The internet": "访问网络", @@ -282,20 +291,23 @@ "Transcript truncated": "这个会话有部分内容太大,没能全部展示出来", "Turn on monitoring to collect activity": "开启监控后才会收集活动", "Uninstall": "卸载", - "Uninstall hint": "包管理器只删除程序本身。Agent 自己的目录(登录状态和历史记录)会原样保留。", + "Uninstall hint": "按当初的安装方式删除程序。Agent 自己的目录(登录状态、历史记录)会原封不动。", "Uninstall {agent}?": "卸载 {agent}?", + "Uninstalling": "卸载中", "Unknown": "未知", "Unpatch": "关闭监控", "Unrestricted": "不受限制", "Unrestricted hint": "该智能体可以使用任意模型、任意供应商和全部工具。", "Up to date": "已是最新", + "Update manually": "手动更新", "Updates available": "可更新", "Upgrade": "升级", - "Upgrade hint": "通过当初安装它的包管理器,将该安装替换为最新版本。升级后需重启代理才能用上新版本。", + "Upgrade hint": "按当初的安装方式将本安装替换为最新版本。完成后重启 Agent 才会运行新版本。", "Upgrade {agent}?": "升级 {agent}?", "Upgrading": "升级中", "Usage": "用量", "User": "用户", + "Vendor page": "官方安装页", "Version": "版本", "Version install hint": "可以安装该 Agent 的任意一个仍在发布的版本,不限于当前版本。", "Version page hint": "本机每个 Agent 装的是哪个版本,包管理器又发布到了哪个版本", @@ -304,6 +316,7 @@ "Versions of {agent}": "{agent} 的版本", "View Records": "查看活动", "View all": "查看全部", + "Waiting at the machine": "等待在本机上确认", "Waiting for the sign-in link": "正在等待登录链接", "What it may do": "它可以做什么", "Write configuration": "写入配置", diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx index bdb4415..92b7fc7 100644 --- a/web/src/pages/AgentDetailPage.tsx +++ b/web/src/pages/AgentDetailPage.tsx @@ -529,6 +529,7 @@ export default function AgentDetailPage({account}: {account: Account}) { /> <ToolUninstallConfirmDialog agent={agent} + job={installer.jobs[agent.agentId]} busy={installer.busyId === agent.agentId} onConfirm={() => installer.uninstall(agent)} /> diff --git a/web/src/pages/AgentVersionsPage.tsx b/web/src/pages/AgentVersionsPage.tsx index a8f6532..d43f442 100644 --- a/web/src/pages/AgentVersionsPage.tsx +++ b/web/src/pages/AgentVersionsPage.tsx @@ -13,7 +13,7 @@ // limitations under the License. import {Link} from "react-router-dom"; -import {Bot, Download, ExternalLink, RefreshCw} from "lucide-react"; +import {Bot, RefreshCw} from "lucide-react"; import i18next from "i18next"; import * as Setting from "@/Setting"; @@ -23,7 +23,8 @@ import { AgentVersionDialog, ToolUninstallConfirmDialog, } from "@/components/AgentVersionDialog"; -import {InstallOutput, ToolUpgradeConfirmDialog} from "@/components/ToolUpgradeConfirmDialog"; +import {InstallJobProgress} from "@/components/AgentInstallJob"; +import {AgentInstallButton, ToolUpgradeConfirmDialog} from "@/components/ToolUpgradeConfirmDialog"; import {DataTable, type Column} from "@/components/shared/data-table"; import {UnauthorizedResult} from "@/components/shared/misc"; import {PageContainer, PageHeader} from "@/components/shared/page-header"; @@ -159,41 +160,57 @@ export default function AgentVersionsPage({account}: {account: Account}) { { title: i18next.t("general:Action"), key: "action", - render: (_value, row) => ( - <div className="flex flex-wrap items-center gap-2"> - {row.installed ? ( - <> - <ToolUpgradeConfirmDialog - agent={row.installed} - job={installer.jobs[row.agentId]} - busy={installer.busyId === row.agentId} - onConfirm={() => installer.upgrade(row.installed as Agent)} - /> - <ToolUninstallConfirmDialog - agent={row.installed} + render: (_value, row) => { + const job = installer.jobs[row.agentId]; + // While one runs, the row shows what it is doing instead of the + // buttons that would start a second. + if (job?.running) { + return <InstallJobProgress job={job} className="w-56" />; + } + return ( + <div className="flex items-center gap-2 whitespace-nowrap"> + {row.installed ? ( + <> + <ToolUpgradeConfirmDialog + agent={row.installed} + job={job} + busy={installer.busyId === row.agentId} + onConfirm={() => installer.upgrade(row.installed as Agent)} + /> + <ToolUninstallConfirmDialog + agent={row.installed} + job={job} + busy={installer.busyId === row.agentId} + onConfirm={() => installer.uninstall(row.installed as Agent)} + /> + </> + ) : ( + <AgentInstallButton + name={row.name} + plan={row.missing?.install} + installUrl={row.missing?.installUrl} + job={job} busy={installer.busyId === row.agentId} - onConfirm={() => installer.uninstall(row.installed as Agent)} + onInstall={() => installer.install(row.agentId)} /> - </> - ) : ( - <InstallButton row={row} installer={installer} /> - )} - <AgentVersionDialog - agentId={row.agentId} - name={row.name} - installMethod={row.installed?.installMethod ?? ""} - installedVersion={row.installed?.version ?? ""} - update={row.update} - busy={installer.busyId === row.agentId} - fallbackDetail={row.installed?.upgrade?.detail ?? row.missing?.install.detail} - onSelect={version => - row.installed - ? installer.setVersion(row.installed, version) - : installer.install(row.agentId, version) - } - /> - </div> - ), + )} + <AgentVersionDialog + agentId={row.agentId} + name={row.name} + installMethod={row.installed?.installMethod ?? ""} + installedVersion={row.installed?.version ?? ""} + update={row.update} + busy={installer.busyId === row.agentId} + fallbackDetail={row.installed?.upgrade?.detail ?? row.missing?.install.detail} + onSelect={version => + row.installed + ? installer.setVersion(row.installed, version) + : installer.install(row.agentId, version) + } + /> + </div> + ); + }, }, ]; @@ -245,7 +262,7 @@ export default function AgentVersionsPage({account}: {account: Account}) { } expandable={{ rowExpandable: row => installer.jobs[row.agentId] !== undefined, - expandedRowRender: row => <InstallOutput job={installer.jobs[row.agentId]} />, + expandedRowRender: row => <InstallJobProgress job={installer.jobs[row.agentId]} />, }} /> </PageContainer> @@ -291,39 +308,3 @@ function StatusBadge({row}: {row: VersionRow}) { } return <Badge variant="success">{i18next.t("agent:Up to date")}</Badge>; } - -/** The install of an agent this machine does not have, or its vendor's page. */ -function InstallButton({ - row, - installer, -}: { - row: VersionRow; - installer: ReturnType<typeof useAgentInstall>; -}) { - const plan = row.missing?.install; - const job = installer.jobs[row.agentId]; - const running = job?.running === true; - const fallbackUrl = plan?.available ? undefined : row.missing?.installUrl; - - return ( - <SimpleTooltip title={plan?.available ? plan.command : fallbackUrl ? i18next.t("agent:Install page") : plan?.detail}> - <span> - <Button - size="sm" - disabled={(!plan?.available && !fallbackUrl) || running} - loading={installer.busyId === row.agentId || running} - onClick={() => { - if (plan?.available) { - installer.install(row.agentId); - } else if (fallbackUrl) { - window.open(fallbackUrl, "_blank", "noreferrer"); - } - }} - > - {fallbackUrl ? <ExternalLink /> : <Download />} - {i18next.t(running ? "agent:Installing" : "agent:Install")} - </Button> - </span> - </SimpleTooltip> - ); -} diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx index 7bd24e5..eb20c51 100644 --- a/web/src/pages/AgentsPage.tsx +++ b/web/src/pages/AgentsPage.tsx @@ -223,6 +223,7 @@ export default function AgentsPage({account}: {account: Account}) { /> <ToolUninstallConfirmDialog agent={record} + job={installer.jobs[record.agentId]} busy={installer.busyId === record.agentId} onConfirm={() => installer.uninstall(record)} /> diff --git a/web/src/types.ts b/web/src/types.ts index 0154ce9..74919a3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -278,7 +278,9 @@ export interface AgentInstallPlan { agentId: string; /** "install", "upgrade", "downgrade" or "uninstall". */ action: string; - /** The package manager that would do it: npm, winget or homebrew. */ + /** What would do it: a package manager (npm, winget, homebrew, msstore), the + * app's registered uninstaller, a Store package, the agent's own updater, + * the vendor's install command, or the files on disk. */ manager?: string; command?: string; /** The release a pinned install asks for, absent for the current one. */ @@ -286,6 +288,11 @@ export interface AgentInstallPlan { available: boolean; /** Why there is no command, when there is none. */ detail?: string; + /** The command opens a window of its own and waits for whoever is at the + * machine: an uninstaller with no silent mode, or a consent prompt. */ + interactive?: boolean; + /** What this command does that a package manager would not. */ + warning?: string; installUrl?: string; } @@ -331,6 +338,9 @@ export interface AgentInstallJob { ok: boolean; /** The release a pinned install asked for, absent for the current one. */ version?: string; + /** The job put a window on screen and is waiting for whoever is at the + * machine, rather than working on its own. */ + interactive?: boolean; /** The tail of what the package manager printed, on both streams. */ output: string; error?: string; --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
