Skip to content

Commit a495a0d

Browse files
committed
fix(updatecheck): require configured repo for deb/rpm commands; surface promoted go-install version (Spec 079 US2)
Cross-model review round 4 fixes: - deb/rpm detection additionally requires the MCPProxy repository config (/etc/apt/sources.list.d/mcpproxy.list resp. /etc/yum.repos.d/mcpproxy.repo, as written by the documented setup). A standalone .deb/.rpm downloaded from a GitHub release is dpkg/rpm- owned but has no apt/dnf upgrade candidate, so the channel commands would be no-ops; those installs now degrade to unknown and get the release-page guidance (FR-009). - /api/v1/info top-level version now prefers the update checker's current version: identical for packaged builds, but for go-install builds the checker promotes the build-info module version while the ldflags default would render 'development' on status/Web UI next to a real go-install update command.
1 parent a34ddbe commit a495a0d

5 files changed

Lines changed: 100 additions & 32 deletions

File tree

docs/features/version-updates.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,19 @@ in decreasing confidence order:
180180
Homebrew prefix (`/opt/homebrew/`, a `Cellar/` path, or
181181
`/home/linuxbrew/.linuxbrew`).
182182
2. **Docker**: `/.dockerenv` exists.
183-
3. **deb / rpm**: on Linux, the executable is exactly `/usr/bin/mcpproxy`
184-
**and** the owning package manager confirms it owns the file
183+
3. **deb / rpm**: on Linux, the executable is exactly `/usr/bin/mcpproxy`,
184+
the owning package manager confirms it owns the file
185185
(`/var/lib/dpkg/info/mcpproxy.list` for deb; for rpm, an rpm database
186186
exists **and** a one-shot `rpm -qf /usr/bin/mcpproxy` query names the
187-
`mcpproxy` package). Both signals are required — a binary merely copied to
188-
`/usr/bin` (e.g. an AUR or manual install), even on an RPM-based distro,
189-
stays `unknown`.
187+
`mcpproxy` package), **and** the MCPProxy repository is configured
188+
(`/etc/apt/sources.list.d/mcpproxy.list` resp.
189+
`/etc/yum.repos.d/mcpproxy.repo`, as written by the documented setup).
190+
The repo signal matters because the apt/dnf commands only work against
191+
`apt.mcpproxy.app` / `rpm.mcpproxy.app`: a standalone `.deb`/`.rpm`
192+
downloaded from a GitHub release is dpkg/rpm-owned but has no upgrade
193+
candidate, so it stays `unknown` and gets release-page guidance. A binary
194+
merely copied to `/usr/bin` (e.g. an AUR or manual install) also stays
195+
`unknown`.
190196
4. **DMG**: on macOS, the executable runs from an `.app/Contents/MacOS` or
191197
`.app/Contents/Resources/bin` bundle path, or is the tray-staged core at
192198
`~/Library/Application Support/mcpproxy/bin/mcpproxy` (the process that

internal/httpapi/contracts_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,12 @@ func TestInfoEndpointIncludesUpdateInfo(t *testing.T) {
695695
// The two additive Spec 079 US2 fields.
696696
assert.Equal(t, "homebrew", updateInfo["install_channel"], "update info should carry the detected install channel")
697697
assert.Equal(t, "brew upgrade mcpproxy", updateInfo["update_command"], "update info should carry the channel update command when an update is available")
698+
699+
// The top-level version must match the checker's current version: they
700+
// are identical for packaged builds, and for go-install builds the
701+
// checker promotes the build-info module version while the ldflags
702+
// default would read "development" (Spec 079 US2).
703+
assert.Equal(t, "v1.0.0", data["version"], "top-level version should prefer the checker's current version")
698704
}
699705

700706
// MockControllerWithUpdateInfo extends MockServerController with update info

internal/httpapi/server.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,23 @@ func (s *Server) handleGetInfo(w http.ResponseWriter, r *http.Request) {
10551055
// Get version from build info or environment
10561056
version := GetBuildVersion()
10571057

1058+
// Update information - refresh if requested
1059+
refresh := r.URL.Query().Get("refresh") == "true"
1060+
var versionInfo *updatecheck.VersionInfo
1061+
if refresh {
1062+
versionInfo = s.controller.RefreshVersionInfo()
1063+
} else {
1064+
versionInfo = s.controller.GetVersionInfo()
1065+
}
1066+
if versionInfo != nil && versionInfo.CurrentVersion != "" {
1067+
// The checker's current version is the ldflags build version for
1068+
// every packaged build, but for go-install builds it is promoted to
1069+
// the module version recorded in build info (Spec 079 US2) — the
1070+
// ldflags default would render "development" on the status/Web UI
1071+
// surfaces next to a real go-install update command.
1072+
version = versionInfo.CurrentVersion
1073+
}
1074+
10581075
response := map[string]interface{}{
10591076
"version": version,
10601077
"web_ui_url": webUIURL,
@@ -1064,15 +1081,6 @@ func (s *Server) handleGetInfo(w http.ResponseWriter, r *http.Request) {
10641081
"socket": getSocketPath(), // Returns socket path if enabled, empty otherwise
10651082
},
10661083
}
1067-
1068-
// Add update information - refresh if requested
1069-
refresh := r.URL.Query().Get("refresh") == "true"
1070-
var versionInfo *updatecheck.VersionInfo
1071-
if refresh {
1072-
versionInfo = s.controller.RefreshVersionInfo()
1073-
} else {
1074-
versionInfo = s.controller.GetVersionInfo()
1075-
}
10761084
if versionInfo != nil {
10771085
response["update"] = versionInfo.ToAPIResponse()
10781086
}

internal/updatecheck/channel.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,20 +152,30 @@ func (d *channelDetector) detect() string {
152152
return ChannelDocker
153153
}
154154

155-
// (3) deb/rpm: require BOTH the package-manager-owned install path AND
156-
// package-specific ownership evidence. dpkg keeps a per-package file list
157-
// (mcpproxy.list); rpm has no per-package file to stat, so the database
158-
// presence (host is RPM-based) is confirmed with an `rpm -qf` ownership
159-
// query — the DB alone would misclassify a manual tarball copy to
160-
// /usr/bin/mcpproxy on any RPM distro. AUR/manual installs have neither
161-
// and must fall to unknown; both matching is ambiguous and also falls to
162-
// unknown. This branch is terminal for the /usr/bin/mcpproxy path: never
163-
// guess (FR-009).
155+
// (3) deb/rpm: require the package-manager-owned install path AND
156+
// package-specific ownership evidence AND the MCPProxy repository being
157+
// configured. dpkg keeps a per-package file list (mcpproxy.list); rpm has
158+
// no per-package file to stat, so the database presence (host is
159+
// RPM-based) is confirmed with an `rpm -qf` ownership query — the DB
160+
// alone would misclassify a manual tarball copy to /usr/bin/mcpproxy on
161+
// any RPM distro. The repo-config file (the documented setup writes
162+
// /etc/apt/sources.list.d/mcpproxy.list resp. /etc/yum.repos.d/
163+
// mcpproxy.repo) is required because the apt/dnf update commands only
164+
// work against apt.mcpproxy.app / rpm.mcpproxy.app: a standalone .deb/
165+
// .rpm downloaded from a GitHub release is dpkg/rpm-owned but has no
166+
// upgrade candidate, so it degrades to unknown and gets the release-page
167+
// guidance instead of a no-op command. AUR/manual installs have neither
168+
// signal and must fall to unknown; both package managers matching is
169+
// ambiguous and also falls to unknown. This branch is terminal for the
170+
// /usr/bin/mcpproxy path: never guess (FR-009).
164171
if d.goos == "linux" && path == "/usr/bin/mcpproxy" {
165-
hasDeb := d.statFile("/var/lib/dpkg/info/mcpproxy.list") == nil
172+
hasDeb := d.statFile("/var/lib/dpkg/info/mcpproxy.list") == nil &&
173+
d.statFile("/etc/apt/sources.list.d/mcpproxy.list") == nil
166174
rpmDBPresent := d.statFile("/var/lib/rpm/rpmdb.sqlite") == nil ||
167175
d.statFile("/var/lib/rpm/Packages") == nil
168-
hasRPM := rpmDBPresent && d.rpmOwnsBinary()
176+
hasRPM := rpmDBPresent &&
177+
d.statFile("/etc/yum.repos.d/mcpproxy.repo") == nil &&
178+
d.rpmOwnsBinary()
169179
switch {
170180
case hasDeb && !hasRPM:
171181
return ChannelDeb

internal/updatecheck/channel_test.go

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,13 @@ func TestDetectChannel_Docker(t *testing.T) {
9696

9797
func TestDetectChannel_DebRequiresBothSignals(t *testing.T) {
9898
dpkgList := "/var/lib/dpkg/info/mcpproxy.list"
99+
aptRepo := "/etc/apt/sources.list.d/mcpproxy.list"
99100

100-
t.Run("path + dpkg entry -> deb", func(t *testing.T) {
101+
t.Run("path + dpkg entry + apt repo -> deb", func(t *testing.T) {
101102
d := testDetector()
102103
d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil }
103104
d.statFile = func(p string) error {
104-
if p == dpkgList {
105+
if p == dpkgList || p == aptRepo {
105106
return nil
106107
}
107108
return os.ErrNotExist
@@ -111,6 +112,23 @@ func TestDetectChannel_DebRequiresBothSignals(t *testing.T) {
111112
}
112113
})
113114

115+
t.Run("standalone GitHub .deb without the apt repo -> unknown", func(t *testing.T) {
116+
// dpkg owns the binary, but without apt.mcpproxy.app configured the
117+
// apt upgrade command would be a no-op — degrade to unknown so the
118+
// release-page guidance is shown instead (FR-009).
119+
d := testDetector()
120+
d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil }
121+
d.statFile = func(p string) error {
122+
if p == dpkgList {
123+
return nil
124+
}
125+
return os.ErrNotExist
126+
}
127+
if got := d.detect(); got != ChannelUnknown {
128+
t.Errorf("detect() = %q, want %q", got, ChannelUnknown)
129+
}
130+
})
131+
114132
t.Run("dpkg entry without the path -> not deb", func(t *testing.T) {
115133
d := testDetector()
116134
d.execPath = func() (string, error) { return "/usr/local/bin/mcpproxy", nil }
@@ -135,11 +153,13 @@ func TestDetectChannel_DebRequiresBothSignals(t *testing.T) {
135153
}
136154

137155
func TestDetectChannel_RPMRequiresBothSignals(t *testing.T) {
138-
t.Run("path + rpmdb.sqlite + rpm ownership -> rpm", func(t *testing.T) {
156+
yumRepo := "/etc/yum.repos.d/mcpproxy.repo"
157+
158+
t.Run("path + rpmdb.sqlite + yum repo + rpm ownership -> rpm", func(t *testing.T) {
139159
d := testDetector()
140160
d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil }
141161
d.statFile = func(p string) error {
142-
if p == "/var/lib/rpm/rpmdb.sqlite" {
162+
if p == "/var/lib/rpm/rpmdb.sqlite" || p == yumRepo {
143163
return nil
144164
}
145165
return os.ErrNotExist
@@ -150,11 +170,11 @@ func TestDetectChannel_RPMRequiresBothSignals(t *testing.T) {
150170
}
151171
})
152172

153-
t.Run("path + legacy Packages db + rpm ownership -> rpm", func(t *testing.T) {
173+
t.Run("path + legacy Packages db + yum repo + rpm ownership -> rpm", func(t *testing.T) {
154174
d := testDetector()
155175
d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil }
156176
d.statFile = func(p string) error {
157-
if p == "/var/lib/rpm/Packages" {
177+
if p == "/var/lib/rpm/Packages" || p == yumRepo {
158178
return nil
159179
}
160180
return os.ErrNotExist
@@ -172,7 +192,7 @@ func TestDetectChannel_RPMRequiresBothSignals(t *testing.T) {
172192
d := testDetector()
173193
d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil }
174194
d.statFile = func(p string) error {
175-
if p == "/var/lib/rpm/rpmdb.sqlite" {
195+
if p == "/var/lib/rpm/rpmdb.sqlite" || p == yumRepo {
176196
return nil
177197
}
178198
return os.ErrNotExist
@@ -183,6 +203,24 @@ func TestDetectChannel_RPMRequiresBothSignals(t *testing.T) {
183203
}
184204
})
185205

206+
t.Run("standalone GitHub .rpm without the yum repo -> unknown", func(t *testing.T) {
207+
// rpm owns the binary, but without rpm.mcpproxy.app configured the
208+
// dnf upgrade command has no candidate — degrade to unknown so the
209+
// release-page guidance is shown instead (FR-009).
210+
d := testDetector()
211+
d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil }
212+
d.statFile = func(p string) error {
213+
if p == "/var/lib/rpm/rpmdb.sqlite" {
214+
return nil
215+
}
216+
return os.ErrNotExist
217+
}
218+
d.rpmOwnsBinary = func() bool { return true }
219+
if got := d.detect(); got != ChannelUnknown {
220+
t.Errorf("detect() = %q, want %q", got, ChannelUnknown)
221+
}
222+
})
223+
186224
t.Run("both dpkg and rpm evidence is ambiguous -> unknown", func(t *testing.T) {
187225
d := testDetector()
188226
d.execPath = func() (string, error) { return "/usr/bin/mcpproxy", nil }

0 commit comments

Comments
 (0)