Uninstalling a provider plugin leaves an orphan `plugin-provider-models` key that no API can remove (causes ghost models on reinstall)
- Dominant language
- TypeScript
- Stars
- 53
- Forks
- 17
- Avg merge
- 2h 5m
- Merged PRs (30d)
- 1
Description
## Summary
Uninstalling a provider plugin leaves its `plugin-provider-models:` entry behind in
`app_settings`. The key is never cleaned up, and once it exists it **cannot be removed through the
public API** — `PUT /api/settings` silently re-adds it, because the handler strips every
`plugin-provider-models:` key from the request body and merges the stored ones back.
Two separate defects, but they compound:
1. **Orphan key on uninstall** — `uninstallPlugin()` deactivates the plugin and deletes its
directory, but never removes the model list it wrote into settings.
2. **Key is un-removable via API** — `updateSettings()` does
`for (const k of Object.keys(body)) k.startsWith("plugin-provider-models:") && delete body[k]`,
then merges `{...stored, ...saved}` back in. So the key always wins over the client, and the PUT
still returns `200`.
The user-visible consequence is **ghost models**: reinstall a plugin at a newer version whose model
catalog changed, and Alma still lists the *old* model IDs, because the stale settings entry takes
precedence over what the plugin now reports.
## Steps to reproduce
Verified on **Alma 0.4.38 (macOS)**. Minimal plugin used — no OAuth, no network, just `providers.register`:
```json
{
"id": "repro-provider-test",
"name": "Repro Provider Test",
"version": "1.0.0",
"description": "Minimal provider plugin",
"author": { "name": "Repro" },
"main": "main.js",
"engines": { "alma": "^0.1.0" },
"type": "provider",
"permissions": ["providers:manage"],
"activationEvents": ["onStartup"],
"contributes": {
"providers": [{ "id": "repro", "name": "Repro", "authType": "api-key" }]
}
}
```
```ts
export async function activate(context: any) {
const { providers } = context;
const providerDisposable = providers.register({
id: "repro",
name: "Repro",
authType: "apiKey",
sdkType: "openai-compatible",
baseURL: "https://example.invalid/v1",
apiKey: "repro-key",
async initialize() {},
async isAuthenticated() { return true; },
async authenticate() { return { success: true }; },
async fetchModels() {
return [
{ id: "repro-model-1", name: "Repro Model 1" },
{ id: "repro-model-2", name: "Repro Model 2" },
];
},
getModels() {
return [
{ id: "repro-model-1", name: "Repro Model 1" },
{ id: "repro-model-2", name: "Repro Model 2" },
];
},
getSDKConfig() {
return { apiKey: "repro-key", baseURL: "https://example.invalid/v1" };
},
});
return { dispose: () => providerDisposable.dispose() };
}
```
```bash
# 1. install + refresh, then enable the plugin's models
curl -s -X POST http://localhost:23001/api/plugins/refresh
curl -s -X PUT "http://localhost:23001/api/providers/plugin:repro-provider-test:repro/models" \
-H "Content-Type: application/json" \
-d '{"models":[{"id":"repro-model-1","name":"Repro Model 1"},{"id":"repro-model-2","name":"Repro Model 2"}]}'
# 2. the model list has been persisted
curl -s http://localhost:23001/api/settings | jq '."plugin-provider-models:plugin:repro-provider-test:repro"'
# => ["repro-model-1", "repro-model-2"]
# 3. uninstall the plugin
curl -s -X DELETE http://localhost:23001/api/plugins/repro-provider-test
# => 204
# 4. plugin is gone, provider is gone, directory is gone — but the settings key remains
curl -s http://localhost:23001/api/plugins | jq '[.plugins[].id]' | grep repro # => nothing
curl -s http://localhost:23001/api/providers | jq '[.providers[].id]' | grep repro # => nothing
curl -s http://localhost:23001/api/settings | jq '."plugin-provider-models:plugin:repro-provider-test:repro"'
# => ["repro-model-1", "repro-model-2"] <-- ORPHAN
```
## Defect 2: the orphan key cannot be deleted
```python
import json, urllib.request
API = "http://localhost:23001/api/settings"
KEY = "plugin-provider-models:plugin:repro-provider-test:repro"
d = json.load(urllib.request.urlopen(API))
d.pop(KEY) # key is NOT in the body we send
req = urllib.request.Request(API, data=json.dumps(d).encode(),
method="PUT", headers={"Content-Type": "application/json"})
print(urllib.request.urlopen(req).status) # => 200 (reports success)
print(json.load(urllib.request.urlopen(API)).get(KEY))
# => ['repro-model-1', 'repro-model-2'] <-- silently restored
```
Same with the CLI — it prints a success tick and changes nothing. (Here against a stock plugin,
`cursor-auth`, to show it isn't specific to my repro plugin):
```console
$ alma config get "plugin-provider-models:plugin:cursor-auth:cursor"
[
"cursor-grok-4.6",
"composer-2.5"
]
$ alma config set "plugin-provider-models:plugin:cursor-auth:cursor" "[]"
✅ plugin-provider-models:plugin:cursor-auth:cursor = []
$ alma config get "plugin-provider-models:plugin:cursor-auth:cursor"
[
"cursor-grok-4.6",
"composer-2.5"
]
```
The `✅` is a lie — the value is unchanged everywhere (API, persisted blob, and the provider's
actual enabled-model list all still report the original two models).
## Defect 3 (impact): ghost models survive a reinstall
Reinstall the same plugin as **v2**, whose `fetchModels()` returns a completely different catalog:
```ts
async fetchModels() {
return [{ id: "repro-model-3", name: "Repro Model 3" }];
}
```
Alma reports the *v1* catalog, taken from the stale settings entry:
```console
$ curl -s http://localhost:23001/api/providers | jq '.[] | select(.id|test("repro")) | .models'
["repro-model-1", "repro-model-2"] # v2 never returns these
```
So the leftover key outranks the plugin's own declaration. A user who reinstalls after a model
rename/removal sees model IDs that no longer exist and can select them, which then fails at
request time.
## Root cause
`updateSettings()` (asar `out/main/index.js`):
```js
const l = {};
for (const e of Object.keys(c)) e.startsWith("plugin-provider-models:") && (l[e] = c[e]);
for (const e of Object.keys(n)) e.startsWith("plugin-provider-models:") && delete n[e];
const u = deepMerge({ ...c, ...l }, n);
```
Stored `plugin-provider-models:*` entries are captured into `l`, stripped from the incoming body
`n`, then merged back — so a client can never modify or remove one. Meanwhile
`uninstallPlugin()` only does:
```js
await this.deactivatePlugin(e);
if ("global" === t.source) fs.rmSync(t.sourcePath, { recursive: true, force: true });
await this.deletePluginState?.(e);
```
— nothing touches the settings entry that `enableModels`/`PUT /providers/:id/models` wrote.
## Suggested fix
On uninstall, drop the key for every provider the plugin contributed. There is already a key
helper, so it's a small change in `uninstallPlugin()`:
```js
// for each provider id the plugin contributed
for (const provider of t.manifest?.contributes?.providers ?? []) {
const key = this.getPluginProviderModelsKey(`plugin:${pluginId}:${provider.id}`);
// ...remove `key` from the persisted settings blob, then broadcast settings_updated
}
```
(The exact provider-id shape is `plugin::`, per the keys Alma
itself writes.)
Two smaller things worth fixing while in there:
- The `plugin-provider-models:` guard should not silently swallow a client's intent. Either reject
the request with a 4xx, or — better — stop strip-and-merging and let clients remove a stale entry.
- A migration/cleanup sweep on startup that removes `plugin-provider-models:*` keys whose plugin is
no longer installed would mop up existing users' orphan keys (mine had two:
`plugin:antigravity-auth:antigravity` from a plugin that isn't installed at all, plus the Devin one
I was chasing).
## Environment
- Alma 0.4.38, macOS (arm64)
- Also reproduced against stock plugins: `cursor-auth` (`plugin-provider-models:plugin:cursor-auth:cursor`)
shows the same API-side immutability
## Workaround
Edit the settings blob directly, since no API path can:
```bash
sqlite3 ~/Library/Application\ Support/alma/chat_threads.db \
"SELECT settings_data FROM app_settings WHERE id='default';"
# remove the stale `plugin-provider-models:*` keys, write back
```
The API reflects the change immediately afterwards.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in out/main/index.js by tracing uninstallPlugin(), updateSettings(), and getPluginProviderModelsKey(). Compare the provider keys written by enableModels with the plugin manifest entries, then reproduce the uninstall and PUT /api/settings cases. Done means uninstall removes contributed model keys and the settings API no longer silently restores stale entries.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- sqlite, typescript
- Domain
- api, backend, database
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100