yetone / yetone/alma-plugins

Uninstalling a provider plugin leaves an orphan `plugin-provider-models` key that no API can remove (causes ghost models on reinstall)

Open
#31 0 comments 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.