[BUG] Plugin marketplace auto-update is silently disabled entirely on non-native (Homebrew/package-manager) installs, regardless of `autoUpdate: true`

Status Open
Reported on v2.1.197
Maintainer reply None cached
Activity 3 comments · opened Aug 12, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

Plugin marketplace auto-update (the background "Plugin autoupdate" pass that refreshes marketplaces with autoUpdate: true) is gated behind the same kill switch as the binary's own self-updater, with no independent toggle:

function YNe(){return she()&&!ct(process.env.FORCE_AUTOUPDATE_PLUGINS)}
...
function G6l(){return(async()=>{
  if(YNe()){C("Plugin autoupdate: skipped (auto-updater disabled)");return}
  ...
  let n=await r4m();          // set of marketplaces with autoUpdate:true
  if(n.size===0)return;
  ...
})()}

she() reflects whether Claude Code's native binary self-update is disabled — which it is, by design, on any package-manager-managed install (Homebrew, apt, etc.), since the package manager owns the upgrade path instead. That's the correct behavior for the binary updater. But because plugin-marketplace autoupdate reuses the exact same flag, it means:

Any Homebrew/apt/non-native install of Claude Code never auto-refreshes plugin marketplaces, period — no matter how correctly autoUpdate: true is set, whether via local settings.json, extraKnownMarketplaces, or org-wide server-managed settings (Admin Settings > Claude Code > Managed settings in the claude.ai console). The only log line produced is a single "Plugin autoupdate: skipped (auto-updater disabled)", and there is no user-facing indication anywhere (docs, /status, claude plugin marketplace list) that this coupling exists.

We only found this by decompiling the installed binary after ~12 days of a marketplace staying stale across multiple releases despite a correctly-configured, server-delivered autoUpdate: true (confirmed present in ~/.claude/remote-settings.json since before the staleness window began).

Why this is easy to hit and hard to diagnose

  • Nothing in the server-managed settings docs or settings reference mentions that plugin-marketplace autoupdate depends on the native self-updater being enabled.
  • claude doctor and /status give no indication that plugin autoupdate is being skipped for this reason.
  • The only escape hatch is an undocumented environment variable, FORCE_AUTOUPDATE_PLUGINS, discovered only via the binary strings — it isn't listed in the environment variables reference (this appears to be the same gap flagged in the now-closed docs issue #17627, "FORCE_AUTOUPDATE_PLUGINS missing from Environment Variables reference").
  • Any org relying on Homebrew-distributed Claude Code (common for teams that manage their dev tooling via brew) will have this silently affect 100% of their fleet, with server-managed autoUpdate: true settings having zero effect.

What Should Happen?

One or more of:

  • Decouple plugin-marketplace autoupdate eligibility from the binary self-updater flag entirely — a disabled binary self-updater has no logical bearing on whether a plugin marketplace should refresh.
  • At minimum, log a distinguishable reason (e.g. "Plugin autoupdate: skipped (native self-updater disabled; set FORCE_AUTOUPDATE_PLUGINS=1 to override)") instead of the current generic message, and document the FORCE_AUTOUPDATE_PLUGINS override in the environment variables reference.
  • Surface this state in /status or claude plugin marketplace list --json so it's diagnosable without decompiling the binary.

Steps to Reproduce

  1. Install Claude Code via Homebrew (brew install --cask claude-code or equivalent), not the native installer.
  2. Register a git-sourced marketplace with autoUpdate: true (via local settings.json's extraKnownMarketplaces, or via org server-managed settings).
  3. Install a plugin from it.
  4. Push a new commit to the marketplace remote bumping a plugin's version.
  5. Fully quit and relaunch Claude Code repeatedly over several days. Do not run /plugin marketplace update manually.
  6. Observe: the marketplace clone never advances, installed_plugins.json stays on the old version, and no log output (even at --debug) indicates why — only the generic "skipped (auto-updater disabled)" line, which doesn't name plugin autoupdate as the affected subsystem in a way that's discoverable without already knowing to look for it.
  7. Confirm the mechanism: export FORCE_AUTOUPDATE_PLUGINS=1, relaunch — the marketplace now refreshes.

Environment

  • Claude Code: 2.1.197
  • Install method: Homebrew Cask (/opt/homebrew/Caskroom/claude-code/2.1.197/claude)
  • Platform: macOS (Darwin 25.5.0)
  • Marketplace source: github, autoUpdate: true delivered via org server-managed settings (~/.claude/remote-settings.json)

Related Issues

  • #60772 (closed, stale) — same symptom (autoUpdate: true not pulling the clone across relaunches), but the reports there didn't identify this root cause.
  • #84401 (open) — general "plugin autoupdate is silent" observability gaps; complementary but distinct (that reporter is on a native install, so the eligibility gap they hit is different from the one here).
  • #17627 (closed, docs) — flagged FORCE_AUTOUPDATE_PLUGINS as missing from the env var reference; still true today, and now more clearly load-bearing than that issue's framing suggested.

View original on GitHub ↗

3 Comments

justinkillen · 2 months ago

Update: this affects more than Homebrew — including the Claude Desktop app's own bundled CLI

Traced the actual gate more precisely across two different binaries/versions:

  • Homebrew Cask install, 2.1.197, at /opt/homebrew/Caskroom/claude-code/2.1.197/claude
  • The Claude Desktop app's own bundled/version-managed copy, 2.1.227, at ~/Library/Application Support/Claude/claude-code/2.1.227/claude.app/Contents/MacOS/claude, launched directly by Electron (launchd → Claude.app → disclaimer helper → claude, no shell in the chain at all)

Both contain byte-identical gating logic (only identifier names differ across the two builds):

// 2.1.197 (Homebrew)
function she(){ return kEe()!==null }
function YNe(){ return she()&&!ct(process.env.FORCE_AUTOUPDATE_PLUGINS) }

// 2.1.227 (Claude Desktop bundled copy)
function zxe(){ return N5e()!==null }
function E8e(){ return zxe()&&!re.FORCE_AUTOUPDATE_PLUGINS }

// The disabled-reason resolver (kEe / N5e), same condition both builds:
if (t.autoUpdates===false && (t.installMethod!=="native" || t.autoUpdatesProtectedForNative!==true))
  return {type:"config"}

So the actual condition is installMethod !== "native" (plus the autoUpdates config flag), not "Homebrew" specifically. "native" appears to mean strictly the official standalone curl/pkg installer flow. Anything else that produces a running claude binary — a package manager, or an app that bundles and version-manages its own copy — fails that check and gets treated as self-update-disabled, which was the correct call for the binary updater in both cases (Homebrew shouldn't race Claude Code's self-updater; neither should the Desktop app, which manages its own bundled copy's version directory itself). The bug is that plugin-marketplace autoupdate is silently killed by the exact same flag, with no distinction between the two concerns.

Practical implication: this isn't a Homebrew-only edge case. The Claude Desktop app's own embedded CLI runtime — the default experience, not a power-user choice — appears to hit the identical gate for the identical reason. An org relying on Admin Settings > Claude Code > Managed settings (server-managed extraKnownMarketplaces.<name>.autoUpdate: true) to keep an internal marketplace current gets silently no-op'd regardless of whether their users are on Homebrew, npm-global, or just using the Desktop app as shipped.

Caveat: I've confirmed the code path is identical in both binaries, but haven't fully traced exactly how installMethod resolves for the Desktop-bundled copy specifically — I couldn't find it persisted anywhere in ~/.claude.json or elsewhere on disk, so it looks like it's computed live per-process rather than cached, but I don't have the exact detection logic pinned down for that binary. Filing this as an addendum rather than editing the original report since the repro/environment there (Homebrew) is still accurate as written — just narrower in scope than the actual bug.

BrandonLWhite · 1 month ago

Addendum: on the Desktop/remote path the trigger is the env branch, not the config branch — which means native installs are affected too

Hit this independently on Linux and can answer the open caveat from the comment above:

I've confirmed the code path is identical in both binaries, but haven't fully traced exactly how installMethod resolves for the Desktop-bundled copy specifically — I couldn't find it persisted anywhere in ~/.claude.json or elsewhere on disk, so it looks like it's computed live per-process rather than cached, but I don't have the exact detection logic pinned down for that binary.

It isn't installMethod at all. On the Desktop/remote runtime the gate fires one line earlier, on DISABLE_AUTOUPDATER, which the runtime injects into the CLI process itself. The config branch is provably exempt on my machine.

Environment

  • Claude Code 2.1.237, running as ~/.claude/remote/ccd-cli/2.1.237 (the desktop/remote-control runtime, launched with --output-format stream-json)
  • A separate native terminal install also present: ~/.local/bin/claude → ~/.local/share/claude/versions/2.1.212
  • Linux 6.18.33.2 (WSL2)
  • Org-internal marketplace, GitHub source, autoUpdate: true in extraKnownMarketplaces (user settings.json) and mirrored into known_marketplaces.json

The config branch is exempt here

From ~/.claude.json:

installMethod                  = "native"
autoUpdates                    = false
autoUpdatesProtectedForNative  = true

Against the resolver you quoted:

if (t.autoUpdates===false && (t.installMethod!=="native" || t.autoUpdatesProtectedForNative!==true))
  return {type:"config"}

autoUpdates is false, but installMethod === "native" and autoUpdatesProtectedForNative === true, so the parenthesized condition is false and this branch returns nothing. This is the exemption that's supposed to keep native installs auto-updating, and it works as intended.

The env branch is what actually fires

Same gate in 2.1.237 (identifiers differ again, logic identical):

function h7e(){                                  // getAutoUpdaterDisabledReason
  if(q.DISABLE_UPDATES)     return {type:"env",envVar:"DISABLE_UPDATES"};
  if(q.DISABLE_AUTOUPDATER) return {type:"env",envVar:"DISABLE_AUTOUPDATER"};   // <-- fires
  let e=ECr(); if(e) return {type:"env",envVar:e};
  let t=ar();
  if(t.autoUpdates===!1 && (t.installMethod!=="native" || t.autoUpdatesProtectedForNative!==!0))
    return {type:"config"};                      // <-- never reached
  return null
}
function GDe(){ return h7e()!==null }
function m7e(){ return GDe() && !q.FORCE_AUTOUPDATE_PLUGINS }

// startBackgroundHousekeeping -> sng():
if(m7e()){ w("Plugin autoupdate: skipped (auto-updater disabled)"), o(); return }

DISABLE_AUTOUPDATER=1 is set in the CLI process environment, and it comes from the runtime, not from the user's shell:

$ for pid in $(pgrep -f claude); do ... done
pid=994579  <no DISABLE_AUTOUPDATER>  cmd=~/.claude/remote/srv/<sha>/server
pid=1003630 DISABLE_AUTOUPDATER=1     cmd=~/.claude/remote/ccd-cli/2.1.237 --output-format stream-json …
pid=1004621 DISABLE_AUTOUPDATER=1     cmd=~/.claude/remote/ccd-cli/2.1.237 --output-format stream-json …
…

It is not in .bashrc, .bash_profile, .profile, .zshrc, .zprofile, /etc/environment, /etc/profile, /etc/profile.d/*, settings.json, settings.local.json, or ~/.claude.json — I grepped all of them. The srv/.../server parent doesn't carry it; the ccd-cli invocation does. So the runtime adds it per-launch.

Setting it is defensible for the binary updater, for exactly the reason you gave — the desktop runtime version-manages its own copy under ~/.claude/remote/ccd-cli/ and shouldn't race a self-updater. It's the coupling that's the bug, same as your report.

Why this widens the scope

Your framing implies the affected population is "anything but the official curl/pkg installer." That undersells it. Because the env check precedes the config check, autoUpdatesProtectedForNative is bypassed entirely — a plain native install gets plugin autoupdate silently killed the moment it's driven through the desktop app or a remote session. The exemption Anthropic added to protect native installs cannot help, because the branch that would grant it is never evaluated.

Observed impact

Nothing had auto-updated in over a month, across four plugins on a marketplace with autoUpdate: true:

| plugin | installed | HEAD | behind |
|---|---|---|---|
| A | f45e7d3db60a (2026-07-15) | fa07506994c7 (2026-08-20) | 11 commits |
| B | 05f4b34b28db (2026-05-20) | 24b966d51169 (2026-08-18) | 3 commits |
| C | 36e938cb5da8 (2026-06-26) | 5b7be98c8007 (2026-08-06) | 1 commit |
| D | 1043c6c39483 | 1043c6c39483 | 0 (no upstream commits — masked the problem) |

Worth flagging for anyone else diagnosing this: installed_plugins.json is actively misleading here. lastUpdated advanced on three of these entries while version stayed pinned and 11 commits stale, so it is not an indicator that an update happened. (I did not identify what writes it in that state — the sweep certainly wasn't running.) Separately, gitCommitSha lags one version behind version after an update. Compare the version field against the remote, not either of those.

Workaround, verified

FORCE_AUTOUPDATE_PLUGINS works from settings.json at userSettings scope, which makes it org-distributable — worth documenting alongside the #17627 gap:

{ "env": { "FORCE_AUTOUPDATE_PLUGINS": "1" } }

Confirmed rather than assumed:

  • A SessionStart hook probing the CLI's own process.env in a sandboxed CLAUDE_CONFIG_DIR printed FORCE=[1] DISABLE=[1] with the block present, FORCE=[] DISABLE=[1] without it — so filterSettingsEnv doesn't drop the key, and it lands well before the sweep (which runs from a REPL useEffect, long after settings env is applied).
  • FORCE_AUTOUPDATE_PLUGINS is declared qe.bool() → $n(), which accepts ["1","true","yes","on"], so the string "1" parses true.
  • q is a lazy proxy over process.env (per-key getters), so the value is re-read at sweep time — no startup ordering race even in principle.

After adding it, the sweep ran unattended and pulled all four plugins to HEAD, correctly leaving a deliberately SHA-pinned marketplace entry alone.

Two smaller things in the same pass

Both make this harder to confirm once the gate is open, so they're worth fixing alongside:

  1. The pre-flight sleep is await Mr(Math.floor(Math.random()*XtA), void 0, {unref:!0}) with XtA=600000. Because the timer is unref'd, any session that ends before the random draw elapses no-ops silently. The 0–10 min window is noted in #84401's repro; the unref consequence isn't.
  2. The sweep is only reachable from the interactive REPL — io.useEffect(()=>{if(xo===1)h1c(Q.host,J)},…) → startBackgroundHousekeeping → sng(). claude -p never sweeps, at any duration. Non-obvious for anyone driving Claude Code headlessly or in CI and expecting plugins to stay current.

Ask

Same as the original: decouple plugin-marketplace autoupdate from the binary self-updater flag. If the coupling has to stay, please at least have the log line name which branch fired and which knob overrides it — "skipped (auto-updater disabled)" is identical for DISABLE_UPDATES, DISABLE_AUTOUPDATER, and the config branch, which is precisely why the mechanism above took a decompile to pin down twice, by two different people.

matevzbizjakadacta · 1 month ago

Confirming this is still live on 2.1.266, and adding the exact condition — the gate is not the install method, which may be why it reproduces for some package-manager users and not others.

The gate on 2.1.266

The plugin auto-update pass begins with:

if (F4()) { log("Plugin autoupdate: skipped (auto-updater disabled)"); …; return }
// F4() = FZ() !== null && !FORCE_AUTOUPDATE_PLUGINS

FZ() is the shared "auto-updater disabled reason" that claude doctor also reports, and it resolves in this order:

| condition | result |
| --- | --- |
| env DISABLE_UPDATES | disabled |
| env DISABLE_AUTOUPDATER | disabled |
| env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC | disabled |
| autoUpdates === false in ~/.claude.json, unless installMethod === "native" && autoUpdatesProtectedForNative === true | disabled |
| otherwise | enabled |

So the install method only supplies an escape hatch, not the trigger. The native installer writes both keys for exactly that purpose:

{ installMethod: "native", autoUpdates: false, autoUpdatesProtectedForNative: true }
// log: 'Native installer: Set installMethod to "native" and disabled legacy auto-updater for protection'

A non-native install has no equivalent, so there autoUpdates: false is terminal — the coupling you describe. Note also that this gate runs before any per-marketplace eligibility check, which is why local settings.json, remote-settings.json and managed settings all look equally ignored.

I could not inspect 2.1.197, so none of this contradicts your repro — it is just the current condition. The logic is unchanged in 2.1.259, 2.1.265 and 2.1.266 (only minified identifiers differ).

How a non-native install ends up with autoUpdates: false

  • Legacy config migration. When installMethod is absent, the config migration derives it and autoUpdates from the old autoUpdaterStatus: "disabled" → { installMethod: "unknown", autoUpdates: false }. That state has no native protection and never expires, so anyone who turned the updater off long ago is permanently excluded from plugin auto-update — including after later moving to Homebrew.
  • Env var. Disable auto-updates recommends DISABLE_AUTOUPDATER: "1" in settings.json and does not mention that it also stops marketplace and plugin auto-update. The same applies to CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, which people set for traffic or telemetry reasons rather than to freeze plugins.

Two things worth adding to the report

1. The UI hides the switch. The same F4() guard gates the marketplace Enable auto-update / Disable auto-update entries in /plugin → Marketplaces, so an affected user sees no toggle and no explanation. The /config panel does explain an env-var reason ("Unset X to re-enable auto-updates"); the plugin view says nothing.

2. Explicit updates are not gated, which is a workaround for anyone who would rather not rely on an env var. Verified on 2.1.266:

$ DISABLE_AUTOUPDATER=1 claude plugin marketplace update <name>
✔ Successfully updated marketplace: <name>

$ DISABLE_AUTOUPDATER=1 claude plugin update <plugin>@<name> --scope project
✔ <plugin> is already at the latest version

Self-check for anyone landing here

claude doctor prints the same FZ() result:

Auto-updates: enabled       → the plugin pass runs
Auto-updates: disabled (…)  → the plugin pass is skipped too

Suggestion

Decouple the two, or at least stop reading "the binary cannot or should not self-update" as "do not refresh marketplaces". A package-manager install is expected not to self-update, yet its marketplaces are ordinary git checkouts that Claude Code owns and can refresh safely. Failing that, showing the reason in /plugin → Marketplaces instead of hiding the toggle would turn silent multi-day staleness into something a user can diagnose.