diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts index 2bbeb0f3..42a58059 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts @@ -21,6 +21,14 @@ export interface AgentStatus { "config": string; "version": string | null; "lockedVersion": string | null; + + /** + * LatestVersion is the newest version published to the package registry, or + * nil when it is not knowable -- offline, rate limited, or not an npm + * package. It drives the update dot only, so nil means "say nothing" rather + * than "up to date". + */ + "latestVersion": string | null; "canInstall": boolean; "provider": string | null; "profileId": string | null; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts index f95a0635..5fbd52e8 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts @@ -12,6 +12,16 @@ export interface CatalogItem { "platforms": string[] | null; "platformNote": string; "rank": number; + + /** + * PackageManager and PackageName describe how this Agent is installed, so the + * UI can decide whether to offer an update without keeping its own list of + * npm Agents. That list existed and had already drifted: OpenClaw shipped as + * an npm Agent and was missing from it, so it silently lost its update + * button. Empty means the Agent has no package contract. + */ + "packageManager"?: string; + "packageName"?: string; } export interface Group { diff --git a/frontend/src/components/AgentManageRow.test.tsx b/frontend/src/components/AgentManageRow.test.tsx index 50df8d9a..08bb29d5 100644 --- a/frontend/src/components/AgentManageRow.test.tsx +++ b/frontend/src/components/AgentManageRow.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { OneAgentApiError } from "../backend/errors"; import type { AgentCatalogItem, AgentStatus } from "../types/api"; -import { AgentManageRow, compareVersions, isBehind, targetSummary } from "./AgentManageRow"; +import { AgentManageRow, compareVersions, isBehind, targetSummary, updateOffer } from "./AgentManageRow"; const launchAgent = vi.fn(); @@ -33,6 +33,11 @@ const catalogAgent: AgentCatalogItem = { platforms: ["macos", "linux", "windows"], platformNote: "", rank: 1, + // The package contract comes from the catalog, which reads agents.lock.json. + // It used to be a literal in the component, which is how OpenClaw ended up + // without an update button after shipping as an npm Agent. + packageManager: "npm", + packageName: "@openai/codex", }; function agentStatus(over: Partial = {}): AgentStatus { @@ -43,6 +48,7 @@ function agentStatus(over: Partial = {}): AgentStatus { config: "/home/u/.codex/config.toml", version: "0.145.0", lockedVersion: "0.145.0", + latestVersion: null, canInstall: true, provider: "ppio", profileId: "team", @@ -54,12 +60,12 @@ function agentStatus(over: Partial = {}): AgentStatus { }; } -function renderRow(over: Partial = {}, profileName = "团队 PPIO") { +function renderRow(over: Partial = {}, profileName = "团队 PPIO", catalogOver: Partial = {}) { render( { expect(summary.text).toBe("未配置"); }); }); + +describe("updateOffer", () => { + const npm = catalogAgent; + + it("marks an Agent behind the registry", () => { + const offer = updateOffer(npm, agentStatus({ version: "0.145.0", latestVersion: "0.150.0" })); + expect(offer).toEqual({ npm: true, behind: "0.150.0" }); + }); + + it("says nothing when the installed version is current or newer", () => { + // A newer local version is normal -- the user upgraded the Agent themselves + // -- and flagging it would invite a downgrade. + for (const [version, latest] of [["1.2.3", "1.2.3"], ["2.0.0", "1.9.9"]]) { + expect(updateOffer(npm, agentStatus({ version, latestVersion: latest })).behind).toBe(""); + } + }); + + it("says nothing when the registry could not be reached", () => { + // Offline or rate limited. The dot is an affordance, so an unknown answer + // leaves the row exactly as it was rather than claiming either state. + expect(updateOffer(npm, agentStatus({ version: "1.0.0", latestVersion: null })).behind).toBe(""); + }); + + it("offers no update for an Agent that is not installed", () => { + // `npm update -g` on a package that was never installed exits 0 and does + // nothing, so the button would report success while the Agent stayed missing. + const offer = updateOffer(npm, agentStatus({ installed: false, version: null, latestVersion: "9.9.9" })); + expect(offer.npm).toBe(false); + }); + + it("offers no update for Agents npm does not manage", () => { + // Aider comes from PyPI through uv; a guide-only entry has no package at all. + expect(updateOffer({ ...npm, packageManager: "uv", packageName: "aider-chat" }, agentStatus()).npm).toBe(false); + expect(updateOffer({ ...npm, packageManager: undefined, packageName: undefined }, agentStatus()).npm).toBe(false); + expect(updateOffer(undefined, agentStatus()).npm).toBe(false); + }); +}); + +describe("the update affordance in the row", () => { + it("puts a dot on the update button when a newer version exists", async () => { + renderRow({ version: "0.145.0", latestVersion: "0.150.0" }); + const button = screen.getByRole("button", { name: /更新/ }); + expect(button.querySelector(".agent-update-dot")).toBeTruthy(); + // The version reaches assistive technology through the title, so the dot + // itself is decoration and must not be announced. + expect(button.getAttribute("title")).toContain("0.150.0"); + }); + + it("leaves the button bare when the Agent is current", () => { + renderRow({ version: "0.145.0", latestVersion: "0.145.0" }); + const button = screen.getByRole("button", { name: /更新/ }); + expect(button.querySelector(".agent-update-dot")).toBeNull(); + }); + + it("hides the update button entirely when the Agent is not installed", () => { + renderRow({ installed: false, version: null, latestVersion: "9.9.9" }); + expect(screen.queryByRole("button", { name: /更新/ })).toBeNull(); + }); + + it("reads the npm package name from the catalog, not a list of its own", async () => { + // OpenClaw shipped as an npm Agent and was missing from that list, so it + // lost both its update button and its package name here. + renderRow({}, "团队 PPIO", { packageName: "openclaw" }); + await userEvent.click(screen.getByText("详情")); + expect(screen.getByText("openclaw")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/AgentManageRow.tsx b/frontend/src/components/AgentManageRow.tsx index 3dea517f..095a5e91 100644 --- a/frontend/src/components/AgentManageRow.tsx +++ b/frontend/src/components/AgentManageRow.tsx @@ -9,13 +9,28 @@ import type { AgentCatalogItem, AgentStatus, ProfileSummary, StatusResponse } fr import { AgentIcon, agentTagline } from "./icons/agents"; type Providers = StatusResponse["providers"]; -const npmAgents = new Set(["codex", "claude-code", "opencode", "kilo-cli"]); -const npmPackages: Record = { - codex: "@openai/codex", - "claude-code": "@anthropic-ai/claude-code", - opencode: "opencode-ai", - "kilo-cli": "@kilocode/cli", -}; + +/** + * Whether an update can be offered, and the newer version if the registry named + * one. + * + * The npm-managed set used to be a literal in this file, alongside a second + * literal mapping each Agent to its package name. Both had already drifted: + * OpenClaw shipped as an npm Agent and was in neither, so it silently lost its + * update button and its package name in the details. The catalog carries both + * facts now, from the same agents.lock.json the installer reads. + */ +export function updateOffer(agent: AgentCatalogItem | undefined, status: AgentStatus): { npm: boolean; behind: string } { + // Not installed means there is nothing to update. `npm update -g` on a package + // that was never installed exits 0 and does nothing, so offering the button + // would report success while leaving the Agent missing. + const npm = agent?.packageManager === "npm" && status.installed; + if (!npm || !status.version || !status.latestVersion) return { npm: Boolean(npm), behind: "" }; + // Only an older local version is an update. A newer one is normal -- the user + // upgraded the Agent themselves -- and flagging it would invite a downgrade. + const behind = compareVersions(status.version, status.latestVersion) < 0 ? status.latestVersion : ""; + return { npm: true, behind }; +} /** -1, 0 or 1 comparing dotted numeric versions; non-numeric parts sort last. */ export function compareVersions(left: string, right: string): number { @@ -135,6 +150,7 @@ export function AgentManageRow({ // installed is true only when the Agent's command resolved on the managed // PATH, so it is already the precise "there is something to launch" signal. const canLaunch = status.installed; + const offer = updateOffer(catalog, status); const launch = async () => { setLaunching(true); @@ -211,10 +227,19 @@ export function AgentManageRow({ {/* Always in the row, not only when the Agent cannot launch. Configuring an installed Agent was previously reachable only by opening
, which made the common case the hidden one. */} - {npmAgents.has(agentId) ? ( - ) : null} {t("备注")}{target.note} ) : null} - {npmPackages[agentId] ? ( -
npm{npmPackages[agentId]}
+ {catalog?.packageManager === "npm" && catalog.packageName ? ( +
npm{catalog.packageName}
) : null}
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index e150aa1b..dfc37840 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -215,6 +215,7 @@ const english = { "更新完成": "Update complete", "更新 {name}": "Update {name}", "执行 npm update": "Run npm update", + "有新版本 {version},点击更新": "Version {version} is available; click to update", "无法更新 Agent": "Could not update the agent", "运行时": "Runtimes", "缺少 {count} 个运行时,安装后即可自动安装对应 Agent。": "{count} runtime(s) missing. Install them to enable automatic agent installation.", diff --git a/frontend/src/pages/EnvironmentOverviewPage.test.tsx b/frontend/src/pages/EnvironmentOverviewPage.test.tsx index be45a811..11ddba65 100644 --- a/frontend/src/pages/EnvironmentOverviewPage.test.tsx +++ b/frontend/src/pages/EnvironmentOverviewPage.test.tsx @@ -40,6 +40,7 @@ function status(): StatusResponse { baseUrl: installed ? "https://api.ppio.com/openai" : null, updatedAt: null, detected: null, + latestVersion: null, }); return { apiVersion: 1, diff --git a/frontend/src/pages/ProfilesPage.test.tsx b/frontend/src/pages/ProfilesPage.test.tsx index da691f76..c367ec05 100644 --- a/frontend/src/pages/ProfilesPage.test.tsx +++ b/frontend/src/pages/ProfilesPage.test.tsx @@ -29,6 +29,7 @@ function statusWith(profiles: ProfileSummary[]): StatusResponse { config: "/c", version: "1.0.0", lockedVersion: "1.0.0", + latestVersion: null, canInstall: true, provider: "ppio", profileId: "team-ppio", diff --git a/frontend/src/pages/ProvidersPage.test.tsx b/frontend/src/pages/ProvidersPage.test.tsx index 204d4f34..1bb17b04 100644 --- a/frontend/src/pages/ProvidersPage.test.tsx +++ b/frontend/src/pages/ProvidersPage.test.tsx @@ -28,6 +28,7 @@ function statusWith(agents: Record): StatusResponse { config: "/c", version: "1.0.0", lockedVersion: "1.0.0", + latestVersion: null, canInstall: true, provider, profileId: null, @@ -45,6 +46,7 @@ function statusWith(agents: Record): StatusResponse { configMode: "auto" as const, guideOnly: false, lockedVersion: "1.0.0", + latestVersion: null, protocol: "openai" as const, platforms: ["macos" as const], platformNote: "", diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 879f5b57..82301759 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1230,6 +1230,31 @@ filter: brightness(0.94); } +/* Anchor for the update dot. overflow stays visible because the dot sits + outside the border box, so it cannot reflow the label beside it. */ +.agent-update-button { + position: relative; + overflow: visible; +} + +/* A newer version is published. Deliberately wordless: the button's title + carries the version for anyone who wants it, and five rows each spelling out + "update available" is noise. The ring matches the surface behind the button so + the dot reads as a badge rather than a smudge over the border it overlaps. + The ring is --surface-subtle because that is the row's own background, and it + tracks the theme with it. */ +.agent-update-dot { + position: absolute; + top: -3px; + right: -3px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--blue); + box-shadow: 0 0 0 2px var(--surface-subtle); + pointer-events: none; +} + .agent-manage-details { grid-area: details; align-self: end; diff --git a/internal/app/latest.go b/internal/app/latest.go new file mode 100644 index 00000000..34b2498a --- /dev/null +++ b/internal/app/latest.go @@ -0,0 +1,137 @@ +package app + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + "github.com/MaimoryLab/OneAgent/internal/install" +) + +// latestVersionConcurrency bounds how many registry lookups run at once. The +// npm registry rate-limits by client, and a status poll asking for every Agent +// at the same instant is exactly the shape that trips it. +const latestVersionConcurrency = 3 + +// latestVersionTTL is how long registry answers stay usable. Agent releases are +// a daily event at most, while the status call runs on every poll and on every +// refresh the user triggers -- without a cache that is a request per Agent per +// poll, which is both wasteful and the fastest way to get rate-limited. +const latestVersionTTL = 30 * time.Minute + +// latestVersionCache holds one batch of answers and when they were taken. +type latestVersionCache struct { + mu sync.Mutex + taken time.Time + answers map[string]*string +} + +// read returns the cached answers while they are fresh. The second result +// distinguishes a fresh empty batch -- every lookup failed, or nothing is +// installed -- from having nothing cached at all, so a failing registry is not +// retried on every poll. +func (c *latestVersionCache) read() (map[string]*string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if c.taken.IsZero() || time.Since(c.taken) > latestVersionTTL { + return nil, false + } + return c.answers, true +} + +func (c *latestVersionCache) write(answers map[string]*string) { + c.mu.Lock() + defer c.mu.Unlock() + c.taken = time.Now() + c.answers = answers +} + +// latestAgentVersions asks the registry for each installed npm Agent's newest +// published version. Only installed Agents are queried: the answer exists to +// mark an update as available, and there is nothing to update on a machine that +// does not have the Agent. +// +// Every failure is silent by design. This decorates the UI, so an offline +// machine, a rate-limited registry, or a captive portal should leave the row +// looking exactly as it did rather than turning a status poll into an error. +// The whole batch is bounded by one timeout so a hanging registry cannot hold +// the status call open. +func (u *UseCases) latestAgentVersions(ctx context.Context, manifest catalog.Manifest, lookup func(string) (string, bool)) map[string]*string { + if u == nil { + return nil + } + if cached, fresh := u.latestVersions.read(); fresh { + return cached + } + // httpDoer is only set when a caller injected one, which in production is + // nobody: it exists so tests can answer without a network. Falling back to a + // bounded client here matches downloadArtifact, and without it this would be + // dead code outside the test suite. + client := u.httpDoer + if client == nil { + client = &http.Client{Timeout: install.LatestVersionTimeout} + } + type query struct { + id string + agent catalog.Agent + } + queries := make([]query, 0, len(manifest.Agents)) + for id, agent := range manifest.Agents { + if agent.Package == nil || agent.Package.Manager != "npm" || agent.Command == "" { + continue + } + if _, installed := lookup(agent.Command); !installed { + continue + } + queries = append(queries, query{id: id, agent: agent}) + } + if len(queries) == 0 { + // Nothing installed is a real answer, and caching it keeps a first run from + // re-deciding this on every poll. + u.latestVersions.write(nil) + return nil + } + registry, err := install.ResolveRegistry(u.packageRegistry(ctx, "")) + if err != nil { + return nil + } + // One deadline for the batch, not per lookup: the caller is a status poll, + // and its total cost is what the user feels. + lookupCtx, cancel := context.WithTimeout(ctx, install.LatestVersionTimeout) + defer cancel() + + var mu sync.Mutex + found := make(map[string]*string, len(queries)) + var group sync.WaitGroup + tokens := make(chan struct{}, latestVersionConcurrency) + for _, item := range queries { + group.Add(1) + go func(id string, agent catalog.Agent) { + defer group.Done() + tokens <- struct{}{} + defer func() { <-tokens }() + version := install.LatestVersion(lookupCtx, client, agent, registry) + if version == "" { + return + } + mu.Lock() + found[id] = &version + mu.Unlock() + }(item.id, item.agent) + } + group.Wait() + // A cancelled status poll answers nothing and must not be cached as "the + // registry said nothing", or one cancelled refresh would suppress the dot for + // the whole TTL. + if lookupCtx.Err() != nil && len(found) == 0 { + return nil + } + if len(found) == 0 { + u.latestVersions.write(nil) + return nil + } + u.latestVersions.write(found) + return found +} diff --git a/internal/app/latest_test.go b/internal/app/latest_test.go new file mode 100644 index 00000000..4194a129 --- /dev/null +++ b/internal/app/latest_test.go @@ -0,0 +1,176 @@ +package app + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/MaimoryLab/OneAgent/internal/platform" +) + +// registryDoer answers any package with one version and records what was asked. +type registryDoer struct { + mu sync.Mutex + asked []string + calls atomic.Int32 + inFlight atomic.Int32 + peak atomic.Int32 + version string + status int +} + +func (d *registryDoer) Do(request *http.Request) (*http.Response, error) { + d.calls.Add(1) + if current := d.inFlight.Add(1); current > d.peak.Load() { + d.peak.Store(current) + } + defer d.inFlight.Add(-1) + d.mu.Lock() + d.asked = append(d.asked, request.URL.Path) + d.mu.Unlock() + status := d.status + if status == 0 { + status = http.StatusOK + } + version := d.version + if version == "" { + version = "9.9.9" + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(`{"dist-tags":{"latest":"` + version + `"}}`)), + }, nil +} + +func (d *registryDoer) paths() []string { + d.mu.Lock() + defer d.mu.Unlock() + return append([]string(nil), d.asked...) +} + +// statusWithRegistry builds a use case whose codex and npm commands resolve, so +// exactly one npm Agent counts as installed. +func statusWithRegistry(t *testing.T, doer *registryDoer, installed ...string) *UseCases { + t.Helper() + present := map[string]bool{"npm": true} + for _, command := range installed { + present[command] = true + } + core := NewUseCases(StatusOptions{ + Home: t.TempDir(), + Platform: platform.For("linux", "amd64"), + Lookup: func(command string) (string, bool) { + if present[command] { + return "/fake/" + command, true + } + return "", false + }, + }) + core.SetRuntimeDownloader(doer) + return core +} + +func TestStatusReportsTheRegistryVersionForInstalledNPMAgents(t *testing.T) { + doer := ®istryDoer{version: "1.4.0"} + status, err := statusWithRegistry(t, doer, "codex").GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + latest := status.Agents["codex"].LatestVersion + if latest == nil || *latest != "1.4.0" { + t.Fatalf("codex latestVersion = %v", latest) + } + // Only what is installed is asked about: there is nothing to update on a + // machine that does not have the Agent, and asking anyway would spend a + // request per catalog entry on every poll. + if paths := doer.paths(); len(paths) != 1 || !strings.Contains(paths[0], "@openai/codex") { + t.Fatalf("registry was asked for %v, want only @openai/codex", paths) + } + if opencode := status.Agents["opencode"].LatestVersion; opencode != nil { + t.Fatalf("uninstalled Agent carried a latestVersion: %v", opencode) + } +} + +func TestStatusLeavesLatestVersionUnsetWhenTheRegistryCannotAnswer(t *testing.T) { + // Offline, rate limited, or behind a captive portal: the dot is a decoration, + // so none of these may turn a status poll into an error or a false "current". + doer := ®istryDoer{status: http.StatusTooManyRequests} + status, err := statusWithRegistry(t, doer, "codex").GetStatus(context.Background()) + if err != nil { + t.Fatalf("a failing registry broke the status call: %v", err) + } + if latest := status.Agents["codex"].LatestVersion; latest != nil { + t.Fatalf("latestVersion = %v, want nil", latest) + } + if !status.Agents["codex"].Installed { + t.Fatal("the failing lookup also lost the installed state") + } +} + +func TestStatusDoesNotAskThePyPIAgentTheNPMQuestion(t *testing.T) { + // Aider is installed with uv from PyPI, whose metadata shape is different. + doer := ®istryDoer{} + status, err := statusWithRegistry(t, doer, "aider", "uv").GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if latest := status.Agents["aider"].LatestVersion; latest != nil { + t.Fatalf("aider borrowed an npm answer: %v", latest) + } + if doer.calls.Load() != 0 { + t.Fatalf("registry was called %d times for a uv Agent", doer.calls.Load()) + } +} + +func TestStatusAsksTheRegistryOnceAcrossPolls(t *testing.T) { + doer := ®istryDoer{version: "2.0.0"} + core := statusWithRegistry(t, doer, "codex") + for range 4 { + if _, err := core.GetStatus(context.Background()); err != nil { + t.Fatal(err) + } + } + // The UI polls status; without a cache that is one request per Agent per + // poll, which is both wasteful and the fastest way to get rate-limited. + if got := doer.calls.Load(); got != 1 { + t.Fatalf("registry was asked %d times across four polls, want 1", got) + } +} + +func TestStatusBoundsHowManyRegistryRequestsRunAtOnce(t *testing.T) { + doer := ®istryDoer{} + // Every npm Agent installed at once is the worst case for a rate limiter. + core := statusWithRegistry(t, doer, "codex", "claude", "opencode", "kilo", "openclaw") + if _, err := core.GetStatus(context.Background()); err != nil { + t.Fatal(err) + } + if peak := doer.peak.Load(); peak > latestVersionConcurrency { + t.Fatalf("%d requests were in flight at once, limit is %d", peak, latestVersionConcurrency) + } +} + +func TestNothingInstalledAsksTheRegistryNothing(t *testing.T) { + // A first run has no Agents, so there is no version to compare and no reason + // to spend a request. The answer is cached so this is decided once. + doer := ®istryDoer{} + core := NewUseCases(StatusOptions{ + Home: t.TempDir(), + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + core.SetRuntimeDownloader(doer) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if latest := status.Agents["codex"].LatestVersion; latest != nil { + t.Fatalf("latestVersion = %v for an Agent that is not installed", latest) + } + if got := doer.calls.Load(); got != 0 { + t.Fatalf("registry was asked %d times with nothing installed", got) + } +} diff --git a/internal/app/status.go b/internal/app/status.go index 69456942..10f92a7d 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -64,6 +64,9 @@ type UseCases struct { regionMu sync.Mutex regionKnown bool regionIsChinese bool + // Registry answers for the update dot, cached so a status poll does not spend + // a request per Agent every time it runs. + latestVersions latestVersionCache } // SetRuntimeDownloader overrides the HTTP client used for internal downloads. @@ -200,12 +203,17 @@ type Capabilities struct { } type AgentStatus struct { - Installed bool `json:"installed"` - Configured bool `json:"configured"` - GuideOnly bool `json:"guideOnly"` - Config string `json:"config"` - Version *string `json:"version"` - LockedVersion *string `json:"lockedVersion"` + Installed bool `json:"installed"` + Configured bool `json:"configured"` + GuideOnly bool `json:"guideOnly"` + Config string `json:"config"` + Version *string `json:"version"` + LockedVersion *string `json:"lockedVersion"` + // LatestVersion is the newest version published to the package registry, or + // nil when it is not knowable -- offline, rate limited, or not an npm + // package. It drives the update dot only, so nil means "say nothing" rather + // than "up to date". + LatestVersion *string `json:"latestVersion"` CanInstall bool `json:"canInstall"` Provider *string `json:"provider"` ProfileID *string `json:"profileId"` @@ -269,6 +277,7 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { agentLookup := runtimeLookup.lookup statuses := make(map[string]AgentStatus, len(manifest.Agents)) bindings := u.profiles.ListAgentBindings() + latestVersions := u.latestAgentVersions(ctx, manifest, agentLookup) for _, id := range catalog.AgentIDs(manifest) { agent := manifest.Agents[id] configPath := configPath(options.Home, options.Platform.OS, agent) @@ -322,6 +331,7 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { Config: configPath, Version: installedVersion, LockedVersion: nil, + LatestVersion: latestVersions[id], CanInstall: canInstall, Provider: boundProvider, ProfileID: boundProfileID, diff --git a/internal/app/testdata/status-empty-linux-arm64.json b/internal/app/testdata/status-empty-linux-arm64.json index af0a2d06..b5b44242 100644 --- a/internal/app/testdata/status-empty-linux-arm64.json +++ b/internal/app/testdata/status-empty-linux-arm64.json @@ -39,6 +39,7 @@ "config": "${HOME}/.oneagent/aider.env", "version": null, "lockedVersion": null, + "latestVersion": null, "canInstall": false, "provider": null, "profileId": null, @@ -54,6 +55,7 @@ "config": "${HOME}/.claude/settings.json", "version": null, "lockedVersion": null, + "latestVersion": null, "canInstall": false, "provider": null, "profileId": null, @@ -69,6 +71,7 @@ "config": "${HOME}/.codex/config.toml", "version": null, "lockedVersion": null, + "latestVersion": null, "canInstall": false, "provider": null, "profileId": null, @@ -84,6 +87,7 @@ "config": "${HOME}/.config/kilo/kilo.jsonc", "version": null, "lockedVersion": null, + "latestVersion": null, "canInstall": false, "provider": null, "profileId": null, @@ -99,6 +103,7 @@ "config": "${HOME}/.openclaw/openclaw.json", "version": null, "lockedVersion": null, + "latestVersion": null, "canInstall": false, "provider": null, "profileId": null, @@ -114,6 +119,7 @@ "config": "${HOME}/.config/opencode/opencode.json", "version": null, "lockedVersion": null, + "latestVersion": null, "canInstall": false, "provider": null, "profileId": null, @@ -138,7 +144,9 @@ "windows" ], "platformNote": "", - "rank": 1 + "rank": 1, + "packageManager": "npm", + "packageName": "@openai/codex" }, { "id": "claude-code", @@ -154,7 +162,9 @@ "windows" ], "platformNote": "", - "rank": 2 + "rank": 2, + "packageManager": "npm", + "packageName": "@anthropic-ai/claude-code" }, { "id": "opencode", @@ -170,7 +180,9 @@ "windows" ], "platformNote": "", - "rank": 3 + "rank": 3, + "packageManager": "npm", + "packageName": "opencode-ai" }, { "id": "kilo-cli", @@ -186,7 +198,9 @@ "windows" ], "platformNote": "", - "rank": 4 + "rank": 4, + "packageManager": "npm", + "packageName": "@kilocode/cli" }, { "id": "aider", @@ -202,7 +216,9 @@ "windows" ], "platformNote": "", - "rank": 5 + "rank": 5, + "packageManager": "uv", + "packageName": "aider-chat" }, { "id": "openclaw", @@ -218,7 +234,9 @@ "windows" ], "platformNote": "", - "rank": 6 + "rank": 6, + "packageManager": "npm", + "packageName": "openclaw" } ], "groups": [ diff --git a/internal/app/update.go b/internal/app/update.go index 1db65606..8d56adeb 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -38,6 +38,16 @@ func (u *UseCases) UpdateAgent(ctx context.Context, agentID string) (AgentUpdate if !present || npm == "" { return AgentUpdateResult{}, oneerrors.New(oneerrors.PrerequisiteMissing, "npm is required to update "+agent.Name) } + // `npm update -g` on a package that was never installed exits 0 and does + // nothing, so without this the task centre reports "update complete" for an + // Agent that is still missing -- worse than an error, because it looks like it + // worked. The UI hides the button in this case; this covers the CLI and a + // restored task card, which do not go through it. + if agent.Command != "" { + if executable, installed := runtime.Runner.LookPath(agent.Command); !installed || executable == "" { + return AgentUpdateResult{}, oneerrors.New(oneerrors.PrerequisiteMissing, agent.Name+" is not installed yet; install it before updating") + } + } args := []string{npm, "update", "-g", agent.Package.Name} if _, err := runtime.Runner.Run(ctx, args, runtime.Env, 180*time.Second); err != nil { return AgentUpdateResult{}, oneerrors.New(oneerrors.InternalError, "Unable to update "+agent.Name, oneerrors.WithStatus(500), oneerrors.WithRetryable(true), oneerrors.WithCause(err)) diff --git a/internal/catalog/public.go b/internal/catalog/public.go index 4a356f73..a73cdc47 100644 --- a/internal/catalog/public.go +++ b/internal/catalog/public.go @@ -105,17 +105,24 @@ func PublicCatalog(manifest Manifest, platformID string) []CatalogItem { if platformID == "windows" { platformNote = agent.WindowsNote } + packageManager, packageName := "", "" + if agent.Package != nil { + packageManager = agent.Package.Manager + packageName = agent.Package.Name + } items = append(items, CatalogItem{ - ID: id, - Name: agent.Name, - Group: agent.Group, - ConfigMode: agent.ConfigMode, - GuideOnly: agent.ConfigMode == "guide", - LockedVersion: nil, - Protocol: protocol, - Platforms: append([]string(nil), agent.Platforms...), - PlatformNote: platformNote, - Rank: agent.Rank, + ID: id, + Name: agent.Name, + Group: agent.Group, + ConfigMode: agent.ConfigMode, + GuideOnly: agent.ConfigMode == "guide", + LockedVersion: nil, + Protocol: protocol, + Platforms: append([]string(nil), agent.Platforms...), + PlatformNote: platformNote, + Rank: agent.Rank, + PackageManager: packageManager, + PackageName: packageName, }) } sort.Slice(items, func(i, j int) bool { diff --git a/internal/catalog/types.go b/internal/catalog/types.go index 18fa611d..2c0a5f34 100644 --- a/internal/catalog/types.go +++ b/internal/catalog/types.go @@ -54,6 +54,13 @@ type CatalogItem struct { Platforms []string `json:"platforms"` PlatformNote string `json:"platformNote"` Rank int `json:"rank"` + // PackageManager and PackageName describe how this Agent is installed, so the + // UI can decide whether to offer an update without keeping its own list of + // npm Agents. That list existed and had already drifted: OpenClaw shipped as + // an npm Agent and was missing from it, so it silently lost its update + // button. Empty means the Agent has no package contract. + PackageManager string `json:"packageManager,omitempty"` + PackageName string `json:"packageName,omitempty"` } type Group struct { diff --git a/internal/install/latest.go b/internal/install/latest.go new file mode 100644 index 00000000..6bee1ea8 --- /dev/null +++ b/internal/install/latest.go @@ -0,0 +1,153 @@ +package install + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" +) + +// LatestVersionTimeout bounds one registry lookup. Short on purpose: this +// answers a decoration in the UI, so a slow registry must not hold up the +// status call that carries it. +const LatestVersionTimeout = 6 * time.Second + +// LatestVersion reports the newest published version of an Agent's package, or +// "" when the answer is not knowable. An empty string is not an error the user +// needs to see: the update dot is an affordance, and a registry that is +// unreachable, rate-limiting, or behind a captive portal should leave the UI +// exactly as it was rather than surfacing a failure for something nobody asked +// for. +// +// Only npm packages are supported. Aider is installed with uv from PyPI, whose +// metadata shape is different, and the desktop Agents are not packages at all. +func LatestVersion(ctx context.Context, client Doer, agent catalog.Agent, registry string) string { + if client == nil || agent.Package == nil || agent.Package.Manager != "npm" { + return "" + } + endpoint, err := packageEndpoint(registry, agent.Package.Name) + if err != nil { + return "" + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "" + } + // The abbreviated document is a fraction of the full packument -- the full + // one carries every version's metadata, which for a package like + // @anthropic-ai/claude-code is megabytes to answer a single string. + request.Header.Set("Accept", "application/vnd.npm.install-v1+json") + response, err := client.Do(request) + if err != nil { + return "" + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "" + } + latest := strings.TrimSpace(decodeLatestTag(response.Body)) + // Validate before returning: this value reaches the UI and is compared + // against the installed version, and a registry is not something to trust + // with arbitrary strings. ValidateVersion accepts "" as "unspecified", which + // is meaningful for an install request but not for an answer, so the empty + // case is rejected here rather than left to it. + if latest == "" || ValidateVersion(latest) != nil { + return "" + } + return latest +} + +// decodeLatestTag streams the document and stops at dist-tags.latest, returning +// "" if it is not there. +// +// Decoding the whole object into a struct is the obvious approach and does not +// work: even the abbreviated document is 13 MB for opencode-ai, because it +// carries an entry per published version. Reading all of it to answer one string +// meant either a size cap that truncated the JSON -- failing with unexpected EOF +// while the field sat 24 bytes in -- or no cap at all, letting a registry decide +// how much memory this spends. Streaming needs neither: dist-tags appears near +// the front, and the rest is never read. +func decodeLatestTag(body io.Reader) string { + // The cap still bounds a registry that sends an endless stream with no + // dist-tags in it; it is generous because it now only has to cover the + // distance to that key, not the whole document. + decoder := json.NewDecoder(io.LimitReader(body, 8<<20)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return "" + } + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return "" + } + name, _ := key.(string) + if name != "dist-tags" { + // Skip the value without materialising it: json.RawMessage would hold the + // entire versions map, which is the size problem all over again. + if err := skipValue(decoder); err != nil { + return "" + } + continue + } + var tags map[string]string + if err := decoder.Decode(&tags); err != nil { + return "" + } + return tags["latest"] + } + return "" +} + +// skipValue consumes the next value, descending through objects and arrays. +func skipValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + if _, isDelimiter := token.(json.Delim); !isDelimiter { + // A scalar is one token and is now consumed. + return nil + } + depth := 1 + for depth > 0 { + token, err := decoder.Token() + if err != nil { + return err + } + switch token { + case json.Delim('{'), json.Delim('['): + depth++ + case json.Delim('}'), json.Delim(']'): + depth-- + } + } + return nil +} + +// packageEndpoint joins a package name onto a registry root. Scoped names keep +// their slash unescaped, which is what registries serve, so the path is built +// rather than delegated to url.JoinPath. +func packageEndpoint(registry, name string) (string, error) { + root := strings.TrimSpace(registry) + if root == "" { + root = officialRegistry() + } + parsed, err := url.Parse(root) + if err != nil || parsed.Scheme != "https" { + // Plain HTTP would expose which Agents a user has installed to anyone on + // the path, and a mirror that only speaks HTTP is not worth that. + return "", fmt.Errorf("registry must be an https URL: %q", registry) + } + packageName := strings.TrimSpace(name) + if packageName == "" || strings.Contains(packageName, "..") { + return "", fmt.Errorf("invalid package name: %q", name) + } + return strings.TrimSuffix(parsed.String(), "/") + "/" + packageName, nil +} diff --git a/internal/install/latest_test.go b/internal/install/latest_test.go new file mode 100644 index 00000000..47e4c9dd --- /dev/null +++ b/internal/install/latest_test.go @@ -0,0 +1,175 @@ +package install + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/MaimoryLab/OneAgent/internal/catalog" +) + +type latestDoer func(*http.Request) (*http.Response, error) + +func (fn latestDoer) Do(request *http.Request) (*http.Response, error) { return fn(request) } + +func latestResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body))} +} + +func npmAgent(name string) catalog.Agent { + return catalog.Agent{Name: "Test", Package: &catalog.Package{Manager: "npm", Name: name}} +} + +func TestLatestVersionReadsDistTagAndAsksForTheAbbreviatedDocument(t *testing.T) { + var seen *http.Request + client := latestDoer(func(request *http.Request) (*http.Response, error) { + seen = request + return latestResponse(http.StatusOK, `{"dist-tags":{"latest":"1.2.3","next":"2.0.0-beta.1"}}`), nil + }) + if got := LatestVersion(context.Background(), client, npmAgent("@openai/codex"), "https://registry.npmjs.org/"); got != "1.2.3" { + t.Fatalf("LatestVersion() = %q, want 1.2.3", got) + } + // The full packument carries every version's metadata, which is megabytes for + // the larger Agents; the abbreviated document answers the same question. + if got := seen.Header.Get("Accept"); got != "application/vnd.npm.install-v1+json" { + t.Fatalf("Accept = %q", got) + } + // A scoped name keeps its slash unescaped, which is what registries serve. + if got := seen.URL.String(); got != "https://registry.npmjs.org/@openai/codex" { + t.Fatalf("URL = %q", got) + } +} + +func TestLatestVersionIsSilentOnEveryFailureTheUserDidNotAskAbout(t *testing.T) { + transport := errors.New("no route to host") + for name, client := range map[string]Doer{ + "transport error": latestDoer(func(*http.Request) (*http.Response, error) { return nil, transport }), + "rate limited": latestDoer(func(*http.Request) (*http.Response, error) { + return latestResponse(http.StatusTooManyRequests, ""), nil + }), + "not found": latestResponder(http.StatusNotFound, `{"error":"Not found"}`), + "captive portal": latestResponder(http.StatusOK, "sign in to continue"), + "no dist-tags": latestResponder(http.StatusOK, `{"name":"x"}`), + "empty latest": latestResponder(http.StatusOK, `{"dist-tags":{"latest":""}}`), + "range not exact": latestResponder(http.StatusOK, `{"dist-tags":{"latest":"^1.2.3"}}`), + "shell injection": latestResponder(http.StatusOK, `{"dist-tags":{"latest":"1.2.3; rm -rf /"}}`), + "wrong type": latestResponder(http.StatusOK, `{"dist-tags":{"latest":123}}`), + "nil client": nil, + } { + if got := LatestVersion(context.Background(), client, npmAgent("pkg"), ""); got != "" { + t.Errorf("%s: LatestVersion() = %q, want \"\"", name, got) + } + } +} + +func TestLatestVersionSkipsWhatIsNotAnNPMPackage(t *testing.T) { + client := latestResponder(http.StatusOK, `{"dist-tags":{"latest":"9.9.9"}}`) + // Aider comes from PyPI through uv, whose metadata shape is different, and a + // guide-only entry has no package at all. Neither may borrow the npm answer. + for name, agent := range map[string]catalog.Agent{ + "uv managed": {Name: "Aider", Package: &catalog.Package{Manager: "uv", Name: "aider-chat"}}, + "no package": {Name: "Cursor"}, + "no manager": {Name: "Odd", Package: &catalog.Package{Name: "x"}}, + } { + if got := LatestVersion(context.Background(), client, agent, ""); got != "" { + t.Errorf("%s: LatestVersion() = %q, want \"\"", name, got) + } + } +} + +func TestLatestVersionRefusesToLeakInstalledAgentsOverPlainHTTP(t *testing.T) { + called := false + client := latestDoer(func(*http.Request) (*http.Response, error) { + called = true + return latestResponse(http.StatusOK, `{"dist-tags":{"latest":"1.0.0"}}`), nil + }) + // Which Agents a user has installed is not something to put on the wire in + // the clear for the sake of a decoration. + for _, registry := range []string{"http://registry.example.com/", "ftp://registry.example.com/", "://broken"} { + if got := LatestVersion(context.Background(), client, npmAgent("pkg"), registry); got != "" { + t.Errorf("registry %q returned %q", registry, got) + } + } + if called { + t.Fatal("a non-https registry still reached the network") + } +} + +func TestLatestVersionDefaultsToTheOfficialRegistryAndRejectsTraversal(t *testing.T) { + var seen string + client := latestDoer(func(request *http.Request) (*http.Response, error) { + seen = request.URL.String() + return latestResponse(http.StatusOK, `{"dist-tags":{"latest":"1.0.0"}}`), nil + }) + if got := LatestVersion(context.Background(), client, npmAgent("pkg"), ""); got != "1.0.0" { + t.Fatalf("LatestVersion() with empty registry = %q", got) + } + if !strings.HasPrefix(seen, officialRegistry()) { + t.Fatalf("empty registry did not fall back to the official one: %q", seen) + } + seen = "" + if got := LatestVersion(context.Background(), client, npmAgent("../../etc/passwd"), ""); got != "" || seen != "" { + t.Fatalf("traversal in a package name reached %q and returned %q", seen, got) + } +} + +func TestLatestVersionReadsADocumentTooLargeToHoldInMemory(t *testing.T) { + // The real reason this streams. opencode-ai's abbreviated document is 13 MB + // because it carries an entry per published version, and dist-tags sits in + // the first 24 bytes. Decoding the whole object into a struct forced a choice + // between a size cap that truncated the JSON -- unexpected EOF with the answer + // already read -- and no cap, letting a registry decide the memory bill. + var body strings.Builder + body.WriteString(`{"dist-tags":{"latest":"1.18.14"},"versions":{`) + for index := range 60000 { + if index > 0 { + body.WriteString(",") + } + fmt.Fprintf(&body, `"0.0.%d":{"name":"opencode-ai","dist":{"tarball":"https://registry.example/%d.tgz"}}`, index, index) + } + body.WriteString("}}") + if body.Len() < 4<<20 { + t.Fatalf("fixture is only %d bytes; it must exceed a naive read cap to be the case in question", body.Len()) + } + client := latestResponder(http.StatusOK, body.String()) + if got := LatestVersion(context.Background(), client, npmAgent("opencode-ai"), ""); got != "1.18.14" { + t.Fatalf("LatestVersion() = %q, want 1.18.14", got) + } +} + +func TestLatestVersionFindsTheTagAfterKeysItDoesNotCareAbout(t *testing.T) { + // dist-tags is near the front in practice but nothing guarantees ordering, so + // preceding values of every shape have to be skipped rather than parsed. + body := `{"_id":"pkg","nested":{"a":{"b":[1,2,{"c":null}]}},"list":[[],{},"x"],"n":12.5,"t":true,` + + `"dist-tags":{"beta":"2.0.0-rc.1","latest":"3.1.4"}}` + if got := LatestVersion(context.Background(), latestResponder(http.StatusOK, body), npmAgent("pkg"), ""); got != "3.1.4" { + t.Fatalf("LatestVersion() = %q, want 3.1.4", got) + } +} + +func TestLatestVersionStopsReadingAnEndlessBody(t *testing.T) { + // A registry that never stops sending must not be able to exhaust memory. + client := latestDoer(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(endlessReader{})}, nil + }) + if got := LatestVersion(context.Background(), client, npmAgent("pkg"), ""); got != "" { + t.Fatalf("LatestVersion() = %q, want \"\"", got) + } +} + +type endlessReader struct{} + +func (endlessReader) Read(buffer []byte) (int, error) { + for index := range buffer { + buffer[index] = ' ' + } + return len(buffer), nil +} + +func latestResponder(status int, body string) Doer { + return latestDoer(func(*http.Request) (*http.Response, error) { return latestResponse(status, body), nil }) +} diff --git a/third_party/manifest.json b/third_party/manifest.json index c539abd6..64768962 100644 --- a/third_party/manifest.json +++ b/third_party/manifest.json @@ -133,6 +133,7 @@ "license_files": [ "licenses/go/golang.org_x_mod@v0.35.0/LICENSE" ], + "modification": "", "name": "golang.org/x/mod", "platforms": [ "macos-arm64",