anthropics / anthropics/claude-code
[BUG] Desktop-provisioned plugins load as `@inline` but `plugin disable` targets `@<marketplace>` — disable is unreachable, CLI falsely reports "already disabled"
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
## Summary
A plugin provisioned by the desktop app is loaded into **every** Claude Code
session as `@inline`, but `claude plugin disable` and `enabledPlugins`
resolve the same plugin as `@`. The two identities never
match, so the plugin can never be disabled from the CLI — while the CLI
confidently reports it as **already disabled**.
Net effect: an MCP server the user has never invoked (`usageCount: 0` across 97
startups) is spawned once per session and stays resident for the lifetime of the
desktop app, with no supported way to turn it off.
This is the same manifest-load-vs-enable-check seam reported in #58806 (closed
COMPLETED 2026-05-17). That issue is locked, so per the lock bot's instruction I
am filing a new one. The entry point differs: #58806 was a marketplace plugin
explicitly set to `false`; here the plugin is injected by the desktop app under a
different namespace, so the enable check is not merely bypassed — it is
unaddressable.
## Environment
- macOS (Darwin 27.0.0), Apple Silicon
- Claude Desktop 2.110.1
- Claude Code 2.1.271
- Plugin: `browser-use`, marketplace `knowledge-work-plugins`,
`installedBy: "user"`, `installationPreference: "available"`
## Evidence
**1. The CLI does not manage the plugin, yet claims it is disabled**
`claude plugin list` shows 22 plugins; `browser-use` is not among them.
```
$ claude plugin disable browser-use --json
{"outcome":"failed","message":"Plugin \"browser-use\" not found in any editable
settings scope. Use plugin@marketplace format.","failureCode":"ambiguous_marketplace"}
$ claude plugin disable browser-use@knowledge-work-plugins --json
{"outcome":"failed","message":"Plugin \"browser-use@knowledge-work-plugins\" is
already disabled","failureCode":"already_in_goal_state","alreadyInGoalState":true}
```
Meanwhile `~/.claude.json` records the loaded plugin under a different identity:
```json
"pluginUsage": {
"browser-use@inline": { "usageCount": 0, "lastUsedNumStartups": 97 }
}
```
`@inline` vs `@knowledge-work-plugins` — the disable command targets a namespace
the loader never consults.
**2. Explicit `false` does not prevent the spawn**
Wrote to `~/.claude/settings.json` at `21:56:53`:
```json
"enabledPlugins": { "browser-use@knowledge-work-plugins": false }
```
A session process started at `21:56:58` (5s later) still spawned the server:
```
PID 45705 claude ... --setting-sources=user,project,local (session, started 21:56:58)
└─ PID 45749 uv tool uvx --python 3.12 browser-use@latest --cli-mcp (21:56:58)
└─ PID 45866 python .../bin/browser-use --cli-mcp (21:57:04)
```
**3. The app does not pass plugin state to the CLI**
The `--settings` payload on the session's command line contains only
`ultracode` and `deniedMcpServers` — no plugin keys. The string `browser-use`
appears 0 times in the 3378-byte command line. The CLI resolves the plugin
itself, from the app-provisioned RPM plugin directory:
```json
// /local-agent-mode-sessions/<...>/rpm//.mcp.json
{ "browser-use": { "command": "uvx",
"args": ["--python","3.12","browser-use@latest","--cli-mcp"] } }
```
Note `manifest.json` in that directory is rewritten by the app after launch
(observed mtime 20h after app start), so editing it is not a durable workaround.
**4. Controlled comparison — the `@inline` key is the one that works**
Same machine, same running desktop app, three sessions differing only in which
`enabledPlugins` key was present when they started:
| session started | keys set in `enabledPlugins` | MCP server spawned? |
|---|---|---|
| 21:56:21 | (none) | yes |
| 21:56:58 | `browser-use@knowledge-work-plugins: false` | **yes** |
| 22:00:15 | + `browser-use@inline: false` | **no** |
So the plugin *is* gated by `enabledPlugins` — but only under the `@inline`
identity. `claude plugin disable` never writes that key, and offers no way to
address it: passing the bare name is rejected as `ambiguous_marketplace`, and
passing `@knowledge-work-plugins` is accepted but targets a namespace the loader
does not consult, returning `already_in_goal_state`.
## Workaround (undocumented)
```json
// ~/.claude/settings.json
"enabledPlugins": { "browser-use@inline": false }
```
Verified effective on the next session start. Users cannot discover this: the
`@inline` identity appears only in `~/.claude.json`'s `pluginUsage` map, and no
CLI command writes or lists it.
## Suggested fix
1. Have `claude plugin disable ` resolve the identity the loader actually
uses, or disambiguate across *all* namespaces including `@inline`, rather
than only marketplace ones.
2. Do not return `already_in_goal_state` for a plugin that is currently loaded —
that answer is false whenever the two namespaces disagree.
3. Surface app-provisioned plugins in `claude plugin list`; today they are
invisible to it while being active in every session.
## Reproduction
1. Have a desktop-app-provisioned plugin that ships an `.mcp.json` stdio server.
2. `claude plugin disable @` → reports *already disabled*.
3. Set `enabledPlugins["@"] = false` in `~/.claude/settings.json`.
4. Open a new session.
5. **Expected:** no MCP server spawns.
**Actual:** the server spawns; `ps` shows a `uv`/python child per session.
## Impact
- Per session: one `uv` wrapper + one Python interpreter, ~145 MB RSS combined,
for a server with `usageCount: 0`.
- Because session processes are retained for the app's lifetime (see #76268,
#88524), this multiplies: on this machine, 10 sessions opened over ~20h held
11 such trees, ~1.6 GB RSS, at ~0% CPU.
- No *discoverable* off switch. A workaround exists (below) but is undocumented
and unreachable through any CLI command. The CLI's answer ("already disabled")
is actively misleading — a user following the documented path is told the
problem is solved while nothing changes.
## Notes
- The stdio-MCP spawn-to-discover-tools requirement makes *some* eager start
unavoidable; the defect is the unreachable disable, not the spawn itself.
- Tool *schemas* for this server were already deferred (ToolSearch-gated) in the
session, so deferral exists at the schema layer but not at the process layer.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by tracing plugin identity handling for `claude plugin disable`, `enabledPlugins`, and `claude plugin list`, comparing the `@inline` entry in `~/.claude.json` with the marketplace key in `~/.claude/settings.json`. Review the app-provisioned RPM plugin directory's `manifest.json` and `.mcp.json`; done means the CLI can address the loaded plugin and disabling it prevents the MCP server from spawning in a new session.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100