From af3bad0e0e10782b2c665a3c5931581f7ab66c65 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Thu, 6 Aug 2026 14:06:21 +0800 Subject: [PATCH 1/2] feat: mark an Agent whose registry has a newer version, and read npm facts from the catalog Two things, because the second is what made the first possible to write correctly. ## The dot There was no way to know an Agent was out of date. lockedVersion, which versionNote compares against, is hardcoded to nil in status.go -- agents.lock.json deliberately records no versions -- so isBehind never returned true and the "behind" branch of versionNote was unreachable. Knowing required asking the registry, which nothing did. install.LatestVersion asks, and says nothing when it cannot. Every failure is silent on purpose: this decorates a button, so an offline machine, a rate-limited registry, or a captive portal leaves the row exactly as it was rather than turning a status poll into an error or claiming "up to date" it cannot support. nil means "say nothing", not "current". It streams rather than decoding into a struct, and that is not premature. The abbreviated document for opencode-ai is 13 MB because it carries an entry per published version, while dist-tags sits in the first 24 bytes. Reading all of it forced a choice between a size cap that truncated the JSON -- unexpected EOF with the answer long since read -- and no cap, letting a registry decide how much memory this spends. Streaming to the key needs neither. Answers are cached for 30 minutes and at most three requests run at once. The UI polls status; without the cache that is a request per Agent per poll, which is both wasteful and the fastest way to be rate-limited. Only installed npm Agents are asked about: Aider comes from PyPI through uv, and there is nothing to update on a machine that does not have the Agent. ## Where npm facts come from (#61) The set of npm-managed Agents was a literal in AgentManageRow, beside a second literal mapping each to its package name. Both had already drifted: OpenClaw shipped as an npm Agent in #53 and was in neither, so it silently lost its update button and its package name in the details -- with no test or build to catch it, because nothing tied the two lists to agents.lock.json. The catalog carries packageManager and packageName now, from the same manifest the installer reads. The update button also ignored whether the Agent was installed, though the launch button in the same component did not. `npm update -g` on a package that was never installed exits 0 and does nothing, so the task centre reported "update complete" for an Agent that was still missing -- worse than an error, because it looks like it worked. The button is now hidden, and UpdateAgent refuses, since the CLI and a restored task card do not go through the UI. Verified against the real registry: codex 0.146.0 -> 0.146.1 and openclaw 2026.6.5 -> 2026.7.1-2 show a dot, claude-code, kilo-cli and opencode are current and show none, and aider is never asked. --- .../OneAgent/internal/app/models.ts | 8 + .../OneAgent/internal/catalog/models.ts | 10 + .../src/components/AgentManageRow.test.tsx | 79 +++++++- frontend/src/components/AgentManageRow.tsx | 47 +++-- frontend/src/i18n.tsx | 1 + .../pages/EnvironmentOverviewPage.test.tsx | 1 + frontend/src/pages/ProfilesPage.test.tsx | 1 + frontend/src/pages/ProvidersPage.test.tsx | 2 + frontend/src/styles/app.css | 25 +++ internal/app/latest.go | 137 ++++++++++++++ internal/app/latest_test.go | 176 ++++++++++++++++++ internal/app/status.go | 22 ++- .../testdata/status-empty-linux-arm64.json | 30 ++- internal/app/update.go | 10 + internal/catalog/public.go | 27 ++- internal/catalog/types.go | 7 + internal/install/latest.go | 153 +++++++++++++++ internal/install/latest_test.go | 175 +++++++++++++++++ 18 files changed, 875 insertions(+), 36 deletions(-) create mode 100644 internal/app/latest.go create mode 100644 internal/app/latest_test.go create mode 100644 internal/install/latest.go create mode 100644 internal/install/latest_test.go 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 }) +} From 4f1473cc573fa36ae9c0e7fe7846ae5d6da03a59 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Thu, 6 Aug 2026 14:13:37 +0800 Subject: [PATCH 2/2] fix: regenerate the third-party manifest so the compliance gate passes golang.org/x/mod arrived with the OTA updater in #62 and its manifest entry was written by hand, without the `modification` key every other dependency carries. The generator emits it unconditionally -- an empty value is how the bundle states "redistributed unmodified" -- so the tracked file and a fresh generation differed by that one line and the Release compliance job failed. This is main's failure, not this branch's: `--check` fails the same way on a clean checkout of main at bd143bb, and it has been red since #62 merged. The fix is running the generator, which is what the job's own error message asks for. --- third_party/manifest.json | 1 + 1 file changed, 1 insertion(+) 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",