Skip to content

Commit cc7ca6c

Browse files
authored
feat(plugins): plugins, MCP, and skills management on the provider-plugin model (#344)
Builds the client-facing management surface for provider plugins, user-owned custom MCP servers, and skills on top of the provider-plugin model that landed with CODE-23/403/432 (which was backend-only: no wire contract, no UI, `managementCapabilities` hardcoded all-false). Supersedes the closed #261 / CODE-382~387 batch — that design invented a parallel "capability unit + connector" concept in the same paths. Design doc: [Plugins / MCP / Skills Management](https://linear.app/arcbox/document/plugins-mcp-skills-management-design-bdf5c992be29). ## What ships **Wire (protocol 67; minimum compatible 67)** - `plugin.list.get`/`result` carrying `Plugin[]`, standalone skills, and `providerStatus[]` — so the UI can tell "this agent has no plugins" from "its CLI failed". - `plugin.set-enabled`/`plugin.updated` and `skill.set-enabled`/`skill.updated`, both replying with the re-read entity so clients patch one cache entry instead of re-running discovery. - `config.get`/`config.set` gain custom MCP servers: masked reads (`envKeys`/`headerKeys`, never values) and per-key patch ops. - `session.started` gains optional `mcpWarnings`. **Provider management, honestly capability-gated** - Claude plugin enable/disable via `claude plugin enable|disable -s <scope>`; Codex enable/disable via `config/value/write` on `plugins."<id>".enabled`. - Codex plugin install/uninstall via app-server `plugin/install` and `plugin/uninstall`; Claude installation remains unimplemented by this adapter. - Per-skill toggles: Claude writes `skillOverrides` in settings.json (what the TUI `/skills` dialog writes; read-modify-write preserving every other key and any finer `name-only`/`user-invocable-only` tier the user set), Codex calls `skills/config/write`. - Both providers **blind-write** — a nonexistent plugin/skill still reports success — so every write is followed by a re-read, and that readback is the only success check. Covered by tests. **Custom (BYO) MCP servers** - Persisted in `~/.linkcode/config.json` (0600, per-entry tolerant parse), injected into MCP-capable sessions' `StartOptions.mcpServers` alongside the existing simulator endpoint, with `agent-unsupported`/`name-conflict` warnings instead of silent drops. - Writes are patch ops with per-key secret semantics (blank = keep, typed = replace, removed = delete). A whole-array replace would clobber real secrets with mask placeholders, so it is structurally impossible here. `buildCustomMcpPatch` is the highest-value unit test in the batch. **Settings page** — one "Plugins & Skills" category on desktop and webview, four tabs: **Plugins** (installed only, provider-grouped, capability-gated switches), **Market** (uninstalled marketplace listings), **MCP** (custom servers + read-only plugin-provided ones), **Skills** (plugin + standalone, per-skill switches). Manual refresh only — discovery is a real CLI shell-out. ## Verification - `pnpm check:ci` + `pnpm test` green on the current head (2341 tests). - Dev-mock Playwright pass, 17/17: card rendering, toggle round-trip, secret never reaching the DOM, blank-save preserving stored secrets, add/edit/remove, empty states. - Live against a real daemon before the latest rebase (then wire 64): 469 real plugins + 70 skills discovered; `claude plugin disable/enable` round-trips on the real CLI; a real skill toggle writes `skillOverrides` and restoring it removes the key; `~/.claude/settings.json` diffed byte-identical to a pre-test backup afterwards. Two real bugs were found by that live pass and are fixed here rather than left for later: - **Discovery was broken for both providers on any current install** (CODE-505). claude's `available[].source` became a union (bare path string in 52 of 275 entries, object with its own `source` discriminator in the rest) and its `version` is `null` for 262 of 275; codex omits `version` entirely while the schema required the key present. One bad entry failed the whole array, so the page was empty everywhere. Regression fixtures now cover every observed shape. - Two secret-row inputs inside one base-ui `Field` both rendered `name="secrets.0.value"` (Field owns a single control), silently dropping the key field from the form. ## Deliberately out of scope Managed/HQ connectors and OAuth (CODE-94/96/340 untouched); plugin update and Claude install/uninstall (Codex install/uninstall ships here); Claude's `name-only`/`user-invocable-only` skill tiers have no wire representation yet (preserved, not coarsened); retrofitting the same masking onto `accounts`, which still round-trips plaintext. The Market list renders a bounded 60 entries per provider with a visible "showing 60 of 273 — narrow it down with search" note rather than truncating silently; virtualizing it is a follow-up if browsing the full catalog matters. Closes CODE-487, CODE-488, CODE-490, CODE-491, CODE-492, CODE-493, CODE-494, CODE-495, CODE-496, CODE-497, CODE-502, CODE-503, CODE-504, CODE-505.
2 parents 1b39d95 + c666f48 commit cc7ca6c

80 files changed

Lines changed: 7477 additions & 223 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/daemon/AGENTS.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,25 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr
2525
redirects config/db/runtime (this is what isolates an E2E daemon).
2626
- **`config.json`** (optional, `0600`): provider and account updates persist together through one
2727
fsynced same-directory temporary file, atomic rename, and POSIX parent-directory fsync, preserving
28-
other fields; malformed or unreadable input fails closed. `loadConfig` validates entries
29-
**field-by-field** — one bad entry is dropped and logged, never blanks the rest. It holds **no
30-
secrets** since CODE-371 —
31-
`providers[kind].apiKey` and each account's credential secret live in `secrets.json` below, and
28+
other fields; custom MCP structure uses the same durable replacement while generation-linked vault
29+
refs keep its cross-file update crash-consistent. Malformed or unreadable input fails closed.
30+
`loadConfig` validates entries **field-by-field** — one bad entry is dropped and logged, never
31+
blanks the rest. It holds **no secrets** since CODE-371 — `providers[kind].apiKey`, each account's
32+
credential secret, and custom MCP env/header values live in `secrets.json` below, and
3233
`withAccountSecret` merges them back *before* zod validation, so a secret that is gone fails
3334
`AccountSchema` and drops through that same per-entry path.
34-
- **`secrets.json`** (`0600`) — every long-lived credential, keyed `namespace:key` where the key is
35-
the id the owning record already carries: `cloud:session`, `provider:<kind>`, `account:<id>`,
36-
`device:software-key`. Custody is a 32-byte AES-256-GCM master key in the OS keyring
35+
- **`secrets.json`** (`0600`) — every long-lived credential, keyed `namespace:key`: `cloud:session`,
36+
`provider:<kind>`, `account:<id>`, custom MCP server/field tuples, `device:software-key`. Custody
37+
is a 32-byte AES-256-GCM master key in the OS keyring
3738
(`@napi-rs/keyring`, service = `keyringServiceName(channel, profile)` so a development daemon cannot
3839
read the release one's), and the file is ciphertext.
3940
- **`vault.namespace(name)` is the only way in** — there is no whole-store handle. That is what
40-
makes `SecretStore.replaceAll` safe to hand out: a `save*` replaces its own namespace in one
41-
write, so pruning a deleted account is implicit and cannot reach a neighbour's secrets. Adding a
42-
subsystem is one entry in the `SecretNamespace` union plus its own key names; the vault stays
41+
makes `SecretStore.replaceAll` safe to hand out: a normal `save*` replaces its own namespace in
42+
one write, so pruning a deleted account is implicit and cannot reach a neighbour's secrets.
43+
Custom MCP is the cross-file exception: refs include a config generation; save writes old+new
44+
refs, atomically points `config.json` at the new generation, then prunes the old refs. Either side
45+
of a process crash therefore names a complete snapshot. Adding a subsystem is one entry in the
46+
`SecretNamespace` union plus its own key names; custom MCP values use the `custom-mcp` namespace. The vault stays
4347
ignorant of what any of them mean.
4448
- **The vault is constructed once, in `main()`, and passed down.** Every consumer takes a
4549
`SecretVault` parameter and opens its own namespace — nothing reaches `secretVault()` by import.

‎apps/daemon/src/__tests__/config.test.ts‎

Lines changed: 263 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
1+
import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
4-
import type { Account } from '@linkcode/schema';
4+
import type { Account, Accounts, CustomMcpServer } from '@linkcode/schema';
55
import { DAEMON_DEFAULT_PORT, DAEMON_PORT_HUNT_SPAN, daemonBasePort } from '@linkcode/schema';
66
import { noop } from 'foxts/noop';
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -12,10 +12,12 @@ import {
1212
databasePath,
1313
loadConfig,
1414
runtimeFilePath,
15+
saveCustomMcpServers,
1516
saveProviderConfiguration,
1617
} from '../config';
1718
import { logger } from '../logger';
1819
import { daemonChannel, telemetryConfigCachePath } from '../paths';
20+
import { createProviderConfigStore } from '../provider-store';
1921
import type { InMemoryVault } from './fixtures/in-memory-vault';
2022
import { createInMemoryVault } from './fixtures/in-memory-vault';
2123

@@ -62,7 +64,7 @@ const validAccount: Account = {
6264
label: 'Personal key',
6365
credential: { type: 'api-key', key: 'sk-test' },
6466
createdAt: 0,
65-
};
67+
} satisfies Accounts[number];
6668

6769
describe('loadConfig providers', () => {
6870
it('keeps valid provider entries and drops an invalid one, logging the error', () => {
@@ -267,6 +269,190 @@ describe('loadConfig accounts', () => {
267269
});
268270
});
269271

272+
describe('loadConfig custom MCP servers', () => {
273+
const validServer = {
274+
id: 'custom-1',
275+
enabled: true,
276+
server: {
277+
type: 'stdio',
278+
name: 'github',
279+
command: 'gh-mcp',
280+
env: { GITHUB_TOKEN: 'secret' },
281+
},
282+
createdAt: 1,
283+
} as const satisfies CustomMcpServer;
284+
285+
function writeCustomMcpConfig(customMcpServers: unknown): void {
286+
const dir = join(process.env.HOME ?? '', '.linkcode');
287+
mkdirSync(dir, { recursive: true });
288+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers }));
289+
}
290+
291+
it('keeps valid servers and drops an invalid one without blanking the rest', () => {
292+
const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop);
293+
writeCustomMcpConfig([validServer, { id: 'broken', server: { type: 'stdio' } }]);
294+
295+
const config = loadConfig(vault);
296+
297+
expect(config.customMcpServers).toEqual([validServer]);
298+
expect(errorSpy).toHaveBeenCalled();
299+
});
300+
301+
it('round-trips through saveCustomMcpServers preserving other fields at mode 0600', () => {
302+
writeCustomMcpConfig([]);
303+
const path = join(process.env.HOME ?? '', '.linkcode', 'config.json');
304+
writeFileSync(path, JSON.stringify({ providers: {}, customMcpServers: [] }));
305+
306+
saveCustomMcpServers(vault, [validServer], []);
307+
308+
const written: unknown = JSON.parse(readFileSync(path, 'utf8'));
309+
expect(written).toEqual({
310+
providers: {},
311+
customMcpServers: {
312+
v: 1,
313+
generation: 1,
314+
servers: [
315+
{
316+
...validServer,
317+
server: { ...validServer.server, env: { GITHUB_TOKEN: null } },
318+
},
319+
],
320+
},
321+
});
322+
expect(vault.refs.get('custom-mcp:[1,"custom-1","env","GITHUB_TOKEN"]')).toBe('secret');
323+
expect(statSync(path).mode & 0o777).toBe(0o600);
324+
expect(loadConfig(vault).customMcpServers).toEqual([validServer]);
325+
});
326+
327+
it('keeps secrets distinct when server ids and keys contain delimiters', () => {
328+
const servers: CustomMcpServer[] = [
329+
{
330+
...validServer,
331+
id: 'a',
332+
server: { ...validServer.server, env: { 'b:env:c': 'first' } },
333+
},
334+
{
335+
...validServer,
336+
id: 'a:env:b',
337+
server: { ...validServer.server, name: 'second', env: { c: 'second' } },
338+
},
339+
];
340+
341+
saveCustomMcpServers(vault, servers, []);
342+
343+
expect(loadConfig(vault).customMcpServers).toEqual(servers);
344+
});
345+
346+
it('loads a complete snapshot on either side of the config commit point', () => {
347+
const nextServer: CustomMcpServer = {
348+
...validServer,
349+
server: { ...validServer.server, env: { NEXT_TOKEN: 'next-secret' } },
350+
};
351+
saveCustomMcpServers(vault, [validServer], []);
352+
const previousConfig = readConfigFile();
353+
const previousRefs = new Map(vault.refs);
354+
saveCustomMcpServers(vault, [nextServer], [validServer]);
355+
const nextConfig = readConfigFile();
356+
const nextRefs = new Map(vault.refs);
357+
const union = new Map([...previousRefs, ...nextRefs]);
358+
const path = join(process.env.HOME ?? '', '.linkcode', 'config.json');
359+
360+
writeFileSync(path, JSON.stringify(previousConfig));
361+
vault.refs.clear();
362+
for (const [key, secret] of union) vault.refs.set(key, secret);
363+
expect(loadConfig(vault).customMcpServers).toEqual([validServer]);
364+
365+
writeFileSync(path, JSON.stringify(nextConfig));
366+
vault.refs.clear();
367+
for (const [key, secret] of union) vault.refs.set(key, secret);
368+
expect(loadConfig(vault).customMcpServers).toEqual([nextServer]);
369+
});
370+
371+
it('replaces orphaned entries when reusing an interrupted generation', () => {
372+
saveCustomMcpServers(vault, [validServer], []);
373+
vault.refs.set('custom-mcp:[2,"custom-1","env","STALE_TOKEN"]', 'stale');
374+
const nextServer: CustomMcpServer = {
375+
...validServer,
376+
server: { ...validServer.server, env: { GITHUB_TOKEN: 'next' } },
377+
};
378+
379+
saveCustomMcpServers(vault, [nextServer], [validServer]);
380+
381+
expect([...vault.refs]).toEqual([['custom-mcp:[2,"custom-1","env","GITHUB_TOKEN"]', 'next']]);
382+
expect(loadConfig(vault).customMcpServers).toEqual([nextServer]);
383+
});
384+
385+
it('keeps a committed snapshot usable when stale-secret pruning fails', () => {
386+
saveCustomMcpServers(vault, [validServer], []);
387+
const nextServer: CustomMcpServer = {
388+
...validServer,
389+
server: { ...validServer.server, env: { GITHUB_TOKEN: 'next' } },
390+
};
391+
const baseVault = vault;
392+
let replacements = 0;
393+
const pruneFailure = new Error('prune failed');
394+
const flakyVault: InMemoryVault = {
395+
...baseVault,
396+
namespace(name) {
397+
const store = baseVault.namespace(name);
398+
if (name !== 'custom-mcp') return store;
399+
return {
400+
...store,
401+
replaceAll(entries) {
402+
replacements += 1;
403+
if (replacements === 2) throw pruneFailure;
404+
store.replaceAll(entries);
405+
},
406+
};
407+
},
408+
};
409+
const warning = vi.spyOn(logger, 'warn').mockImplementation(noop);
410+
411+
expect(() => saveCustomMcpServers(flakyVault, [nextServer], [validServer])).not.toThrow();
412+
413+
expect(loadConfig(flakyVault).customMcpServers).toEqual([nextServer]);
414+
expect(warning).toHaveBeenCalledWith(
415+
{ err: pruneFailure, operation: 'config.save-custom-mcp' },
416+
'Custom MCP state committed but stale secret cleanup failed',
417+
);
418+
});
419+
420+
it('rejects a malformed versioned snapshot instead of guessing its generation', () => {
421+
const warning = vi.spyOn(logger, 'warn').mockImplementation(noop);
422+
writeCustomMcpConfig({ v: 1, generation: '1', servers: [validServer] });
423+
424+
expect(loadConfig(vault).customMcpServers).toEqual([]);
425+
expect(warning).toHaveBeenCalled();
426+
});
427+
});
428+
429+
describe('createProviderConfigStore', () => {
430+
it('does not publish provider, account, or custom MCP state when persistence fails', () => {
431+
const oldProviders = { codex: { enabled: true } } as const;
432+
const oldAccounts: Accounts = [validAccount];
433+
const oldCustomMcpServers: CustomMcpServer[] = [];
434+
const store = createProviderConfigStore(vault, oldProviders, oldAccounts, oldCustomMcpServers);
435+
writeFileSync(join(process.env.HOME ?? '', '.linkcode'), 'not a directory');
436+
437+
expect(() => store.update({ providers: { 'claude-code': { enabled: true } } })).toThrow();
438+
expect(() => store.update({ accounts: [] })).toThrow();
439+
expect(() =>
440+
store.setCustomMcpServers([
441+
{
442+
id: 'custom-1',
443+
enabled: true,
444+
server: { type: 'stdio', name: 'test', command: 'test' },
445+
createdAt: 1,
446+
},
447+
]),
448+
).toThrow();
449+
expect(store.get()).toBe(oldProviders);
450+
expect(store.getAccounts()).toBe(oldAccounts);
451+
expect(store.getCustomMcpServers()).toBe(oldCustomMcpServers);
452+
expect([...vault.refs.keys()].some((ref) => ref.startsWith('custom-mcp:'))).toBe(false);
453+
});
454+
});
455+
270456
// CODE-371: config.json used to hold provider api keys and account credentials in the clear. The
271457
// vault owns them now, and an upgrade has to move them without the user re-entering anything.
272458
describe('credential storage', () => {
@@ -295,6 +481,80 @@ describe('credential storage', () => {
295481
expect(raw).not.toContain('sk-test');
296482
});
297483

484+
it('lazily moves inline custom MCP values while preserving their keys', () => {
485+
vi.spyOn(logger, 'warn').mockImplementation(noop);
486+
const server: CustomMcpServer = {
487+
id: 'custom-http',
488+
enabled: true,
489+
server: {
490+
type: 'http',
491+
name: 'search',
492+
url: 'https://mcp.example',
493+
headers: { Authorization: 'Bearer legacy' },
494+
},
495+
createdAt: 1,
496+
};
497+
const dir = join(process.env.HOME ?? '', '.linkcode');
498+
mkdirSync(dir, { recursive: true });
499+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers: [server] }));
500+
501+
expect(loadConfig(vault).customMcpServers).toEqual([server]);
502+
expect(vault.refs.get('custom-mcp:[1,"custom-http","headers","Authorization"]')).toBe(
503+
'Bearer legacy',
504+
);
505+
expect(readConfigFile().customMcpServers).toEqual({
506+
v: 1,
507+
generation: 1,
508+
servers: [{ ...server, server: { ...server.server, headers: { Authorization: null } } }],
509+
});
510+
expect(loadConfig(vault).customMcpServers).toEqual([server]);
511+
});
512+
513+
it('migrates a legacy mix of inline and placeholder secrets as one snapshot', () => {
514+
vi.spyOn(logger, 'warn').mockImplementation(noop);
515+
const inline: CustomMcpServer = {
516+
id: 'inline',
517+
enabled: true,
518+
server: {
519+
type: 'stdio',
520+
name: 'inline',
521+
command: 'inline',
522+
env: { INLINE_TOKEN: 'inline-secret' },
523+
},
524+
createdAt: 1,
525+
};
526+
const referenced: CustomMcpServer = {
527+
id: 'referenced',
528+
enabled: true,
529+
server: {
530+
type: 'stdio',
531+
name: 'referenced',
532+
command: 'referenced',
533+
env: { STORED_TOKEN: 'stored-secret' },
534+
},
535+
createdAt: 2,
536+
};
537+
const dir = join(process.env.HOME ?? '', '.linkcode');
538+
mkdirSync(dir, { recursive: true });
539+
writeFileSync(
540+
join(dir, 'config.json'),
541+
JSON.stringify({
542+
customMcpServers: [
543+
inline,
544+
{
545+
...referenced,
546+
server: { ...referenced.server, env: { STORED_TOKEN: null } },
547+
},
548+
],
549+
}),
550+
);
551+
vault.refs.set('custom-mcp:["referenced","env","STORED_TOKEN"]', 'stored-secret');
552+
553+
expect(loadConfig(vault).customMcpServers).toEqual([inline, referenced]);
554+
expect(readConfigFile().customMcpServers).toMatchObject({ v: 1, generation: 1 });
555+
expect(loadConfig(vault).customMcpServers).toEqual([inline, referenced]);
556+
});
557+
298558
it('round-trips an account through the vault without ever writing the secret', () => {
299559
saveProviderConfiguration(vault, {}, [validAccount]);
300560

0 commit comments

Comments
 (0)