Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0fe2df6
feat(schema): define plugin contracts
lucas77778 Jul 22, 2026
9257af7
feat(engine): serve plugin catalog
lucas77778 Jul 22, 2026
66124be
feat(config): persist plugin settings
lucas77778 Jul 22, 2026
e226c95
feat(engine): resolve plugin servers
lucas77778 Jul 22, 2026
80e217a
feat(client): manage plugin settings
lucas77778 Jul 22, 2026
64df55c
feat(ui): present plugin settings
lucas77778 Jul 22, 2026
36da7b2
feat(workbench): manage plugin connections
lucas77778 Jul 22, 2026
7b57c75
feat(settings): expose plugin tools
lucas77778 Jul 22, 2026
2833562
fix(settings): polish plugin connection state
lucas77778 Jul 22, 2026
f8ed2fd
Revert "fix(settings): polish plugin connection state"
lucas77778 Jul 22, 2026
f78a433
Revert "feat(settings): expose plugin tools"
lucas77778 Jul 22, 2026
b715db1
Revert "feat(workbench): manage plugin connections"
lucas77778 Jul 22, 2026
19509fc
Revert "feat(ui): present plugin settings"
lucas77778 Jul 22, 2026
8d0697a
feat(schema,engine,client): bind plugin MCP servers to connectors per…
lucas77778 Jul 22, 2026
a774ce7
merge: master into chenyu/code-387, wire protocol 46
lucas77778 Jul 23, 2026
763ce7e
feat(engine,schema): share the MCP capability matrix and replay plugi…
lucas77778 Jul 23, 2026
eccab4f
feat(client): fold plugin warnings into the conversation view model
lucas77778 Jul 23, 2026
7e7493e
feat(workbench): derive plugin unit and service view models
lucas77778 Jul 23, 2026
ead0b02
feat(settings): present MCP plugin cards with enablement and status
lucas77778 Jul 23, 2026
27f1218
feat(agent-adapter): inject StartOptions.mcpServers into agent sessio…
lucas77778 Jul 23, 2026
248d184
feat(plugins): import custom user-defined MCP servers (CODE-407)
lucas77778 Jul 23, 2026
77aa145
fix(plugins): enforce custom-server name uniqueness beyond the write …
lucas77778 Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion apps/daemon/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { PluginConfig } from '@linkcode/schema';
import { noop } from 'foxts/noop';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
Expand All @@ -9,6 +10,7 @@ import {
hqCredentialsPath,
loadConfig,
runtimeFilePath,
savePlugins,
} from '../config';
import { logger } from '../logger';
import { telemetryConfigCachePath } from '../paths';
Expand Down Expand Up @@ -39,6 +41,12 @@ function writeAccountsConfig(accounts: unknown): void {
writeFileSync(join(dir, 'config.json'), JSON.stringify({ accounts }));
}

function writeRawConfig(config: unknown): void {
const dir = join(process.env.HOME ?? '', '.linkcode');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'config.json'), JSON.stringify(config));
}

const validAccount = {
id: 'acc_1',
label: 'Personal key',
Expand Down Expand Up @@ -169,3 +177,74 @@ describe('loadConfig accounts', () => {
expect(errorSpy).not.toHaveBeenCalled();
});
});

describe('loadConfig plugins', () => {
const connector = {
id: 'github-personal',
label: 'Personal GitHub',
service: 'github',
credential: { type: 'auth-token', secret: 'github-secret' },
} as const;

it('keeps valid entries and drops malformed entries independently', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(noop);
writeRawConfig({
plugins: {
units: [
{ unitId: 'github-read', enabled: true },
{ unitId: 'unknown-unit', enabled: true },
],
serviceBindings: {
github: { type: 'local', connectorId: connector.id },
jira: { type: 'local', connectorId: 'stale' },
},
connectors: [connector, { id: 'bad', service: 'github' }],
customServers: [
{ id: 'cs1', enabled: true, server: { type: 'stdio', name: 'local-fs', command: 'fs' } },
{ id: 'bad', enabled: true, server: { type: 'stdio', name: 'x' } },
],
},
});

expect(loadConfig().plugins).toEqual({
units: [{ unitId: 'github-read', enabled: true }],
serviceBindings: { github: { type: 'local', connectorId: connector.id } },
connectors: [connector],
customServers: [
{ id: 'cs1', enabled: true, server: { type: 'stdio', name: 'local-fs', command: 'fs' } },
],
});
expect(warnSpy).toHaveBeenCalledTimes(3);
});

it('round-trips credentials at mode 0600 without overwriting providers or accounts', () => {
writeRawConfig({ providers: { codex: { enabled: true } }, accounts: [validAccount] });
const plugins: PluginConfig = {
units: [],
serviceBindings: { github: { type: 'local', connectorId: connector.id } },
connectors: [connector],
customServers: [],
};

savePlugins(plugins);

expect(loadConfig()).toMatchObject({
providers: { codex: { enabled: true } },
accounts: [validAccount],
plugins,
});
expect(statSync(join(process.env.HOME ?? '', '.linkcode', 'config.json')).mode & 0o777).toBe(
0o600,
);
});

it('defaults an older config without a plugins section to empty state', () => {
writeRawConfig({ providers: {} });
expect(loadConfig().plugins).toEqual({
units: [],
serviceBindings: {},
connectors: [],
customServers: [],
});
});
});
4 changes: 4 additions & 0 deletions apps/daemon/src/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ describe('daemon logger', () => {
nested: { apiKey: 'nested-secret', token: 'nested-token' },
providers: { codex: { apiKey: 'provider-secret' } },
accounts: [{ credential: { key: 'account-secret' } }],
plugins: {
connectors: [{ credential: { type: 'auth-token', secret: 'plugin-secret' } }],
},
sessionId: 'session-1',
},
'Session started',
Expand All @@ -48,6 +51,7 @@ describe('daemon logger', () => {
nested: { apiKey: '[Redacted]', token: '[Redacted]' },
providers: { codex: { apiKey: '[Redacted]' } },
accounts: [{ credential: '[Redacted]' }],
plugins: { connectors: [{ credential: '[Redacted]' }] },
sessionId: 'session-1',
});
});
Expand Down
77 changes: 74 additions & 3 deletions apps/daemon/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { daemonRuntimeFilePath } from '@linkcode/common/node';
import type { Accounts, ProvidersConfig } from '@linkcode/schema';
import type { Accounts, PluginConfig, ProvidersConfig } from '@linkcode/schema';
import {
AccountSchema,
AgentKindSchema,
CustomMcpServerSchema,
DAEMON_DEFAULT_PORT,
McpPluginServiceSchema,
PluginConnectorSchema,
PluginServiceBindingSchema,
PluginUnitStateSchema,
ProviderConfigSchema,
} from '@linkcode/schema';
import { WORKSPACES_DIRNAME } from '@linkcode/schema/product';
Expand All @@ -29,6 +34,8 @@ export interface DaemonConfig {
providers?: ProvidersConfig;
/** Global account pool (data plane); undefined when nothing is configured. */
accounts?: Accounts;
/** Global plugin enablement and daemon-local connector credentials. */
plugins?: PluginConfig;
}

const DEFAULT_PORT = DAEMON_DEFAULT_PORT;
Expand All @@ -40,6 +47,7 @@ interface ConfigFile {
listeners?: unknown;
providers?: unknown;
accounts?: unknown;
plugins?: unknown;
}

function configPath(): string {
Expand Down Expand Up @@ -101,9 +109,66 @@ export function loadConfig(): DaemonConfig {
),
providers: parseProviders(file.providers),
accounts: parseAccounts(file.accounts),
plugins: parsePlugins(file.plugins),
};
}

function parsePlugins(raw: unknown): PluginConfig {
const empty: PluginConfig = { units: [], serviceBindings: {}, connectors: [], customServers: [] };
if (raw === undefined) return empty;
if (!isRecord(raw)) {
logger.warn({ operation: 'config.load' }, 'Invalid plugins config: expected an object');
return empty;
}
return {
units: parsePluginEntries(raw.units, PluginUnitStateSchema, 'unit'),
serviceBindings: parseServiceBindings(raw.serviceBindings),
connectors: parsePluginEntries(raw.connectors, PluginConnectorSchema, 'connector'),
customServers: parsePluginEntries(raw.customServers, CustomMcpServerSchema, 'custom server'),
};
}

/** Parse binding by binding: an invalid entry is dropped and logged, never blanking the map. */
function parseServiceBindings(raw: unknown): PluginConfig['serviceBindings'] {
if (raw === undefined) return {};
if (!isRecord(raw)) {
logger.warn(
{ operation: 'config.load' },
'Invalid plugin service bindings config: expected an object',
);
return {};
}
const bindings: PluginConfig['serviceBindings'] = {};
for (const service of McpPluginServiceSchema.options) {
if (!(service in raw)) continue;
const parsed = PluginServiceBindingSchema.safeParse(raw[service]);
if (parsed.success) bindings[service] = parsed.data;
else logger.warn({ operation: 'config.load' }, 'Dropping invalid plugin service binding');
}
return bindings;
}

function parsePluginEntries<T>(
raw: unknown,
schema: { safeParse(value: unknown): { success: true; data: T } | { success: false } },
entryType: string,
): T[] {
if (raw === undefined) return [];
if (!Array.isArray(raw)) {
logger.warn(
{ operation: 'config.load' },
`Invalid plugin ${entryType}s config: expected an array`,
);
return [];
}
return raw.flatMap((value) => {
const parsed = schema.safeParse(value);
if (parsed.success) return [parsed.data];
logger.warn({ operation: 'config.load' }, `Dropping invalid plugin ${entryType} config`);
return [];
});
}

/**
* Parse element by element: an invalid account is dropped and logged, never blanking the pool —
* `saveAccounts` would persist that loss on the next write. Mirrors {@link parseProviders}.
Expand Down Expand Up @@ -166,8 +231,13 @@ export function saveAccounts(accounts: Accounts): void {
writeConfigField('accounts', accounts);
}

/** Persist plugin state and local credentials, preserving other config fields; `0600`. */
export function savePlugins(plugins: PluginConfig): void {
writeConfigField('plugins', plugins);
}

/** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */
function writeConfigField(key: 'providers' | 'accounts', value: unknown): void {
function writeConfigField(key: 'providers' | 'accounts' | 'plugins', value: unknown): void {
const path = configPath();
let file: Record<string, unknown> = {};
try {
Expand All @@ -179,6 +249,7 @@ function writeConfigField(key: 'providers' | 'accounts', value: unknown): void {
file[key] = value;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
chmodSync(path, 0o600);
}

function createDefaultSocketIoListener(file: ConfigFile): DaemonListenerConfig {
Expand Down
6 changes: 5 additions & 1 deletion apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,11 @@ async function main(): Promise<void> {
const EngineSubsystemLive = Layer.unwrap(
Effect.gen(function* () {
const { config, hub, previewRoutes } = yield* Shared;
const store = createProviderConfigStore(config.providers ?? {}, config.accounts ?? []);
const store = createProviderConfigStore(
config.providers ?? {},
config.accounts ?? [],
config.plugins ?? { units: [], serviceBindings: {}, connectors: [], customServers: [] },
);
// Managed assets (CODE-111): GC superseded versions before anything can spawn, then feed the
// store into spawn resolution — managed wins over detected as soon as an install lands.
const assets = new AssetManager();
Expand Down
11 changes: 9 additions & 2 deletions apps/daemon/src/provider-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ProviderConfigStore } from '@linkcode/engine';
import type { Accounts, ProvidersConfig } from '@linkcode/schema';
import { saveAccounts, saveProviders } from './config';
import type { Accounts, PluginConfig, ProvidersConfig } from '@linkcode/schema';
import { saveAccounts, savePlugins, saveProviders } from './config';

/**
* Daemon-backed data-plane config store: in-memory providers + account pool seeded at boot, each
Expand All @@ -10,9 +10,11 @@ import { saveAccounts, saveProviders } from './config';
export function createProviderConfigStore(
initialProviders: ProvidersConfig,
initialAccounts: Accounts,
initialPlugins: PluginConfig,
): ProviderConfigStore {
let providers = initialProviders;
let accounts = initialAccounts;
let plugins = initialPlugins;
return {
get: () => providers,
set(next) {
Expand All @@ -24,5 +26,10 @@ export function createProviderConfigStore(
accounts = next;
saveAccounts(next);
},
getPlugins: () => plugins,
setPlugins(next) {
savePlugins(next);
plugins = next;
},
};
}
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/src/settings/plugins-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PluginsSettingsPanel } from '@linkcode/workbench';

/** Daemon-backed MCP tool settings, shared with the browser client. */
export function PluginsTab(): React.ReactNode {
return <PluginsSettingsPanel />;
}
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/settings/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
HistoryIcon,
InfoIcon,
KeyRoundIcon,
PlugIcon,
SendIcon,
SettingsIcon,
SunMoonIcon,
Expand All @@ -41,6 +42,7 @@ import { GeneralTab } from './general-tab';
import { HistoryImportTab } from './history-import-tab';
import { ImChannelTab } from './im-channel-tab';
import { NotificationsTab } from './notifications-tab';
import { PluginsTab } from './plugins-tab';
import { ProvidersTab } from './providers-tab';
import type { SettingsCategory } from './store';
import { useDesktopSettingsStore } from './store';
Expand Down Expand Up @@ -147,6 +149,14 @@ export function SettingsView(): React.ReactNode {
active: category === 'providers',
onClick: () => setCategory('providers'),
},
{
key: 'plugins',
icon: <PlugIcon className="size-4" />,
label: t('tabs.plugins'),
keywords: searchKeywords.plugins,
active: category === 'plugins',
onClick: () => setCategory('plugins'),
},
{
key: 'imChannel',
icon: <SendIcon className="size-4" />,
Expand Down Expand Up @@ -301,6 +311,8 @@ function renderSettingsPanel(
return <ProvidersTab />;
case 'agents':
return <AgentsTab />;
case 'plugins':
return <PluginsTab />;
case 'imChannel':
return <ImChannelTab />;
case 'history-import':
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/settings/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type SettingsCategory =
| 'about'
| 'providers'
| 'agents'
| 'plugins'
| 'imChannel'
| 'history-import';

Expand Down
2 changes: 2 additions & 0 deletions apps/webview/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DeveloperSettings } from '@webview/routes/settings/developer';
import { GeneralSettings } from '@webview/routes/settings/general';
import { MessagingSettings } from '@webview/routes/settings/messaging';
import { NotificationsSettings } from '@webview/routes/settings/notifications';
import { PluginsSettings } from '@webview/routes/settings/plugins';
import { ProvidersSettings } from '@webview/routes/settings/providers';
import { SettingsLayout } from '@webview/routes/settings/settings-layout';
import { TerminalSettings } from '@webview/routes/settings/terminal';
Expand All @@ -32,6 +33,7 @@ export function createWebviewRouter(
{ path: 'developer', element: <DeveloperSettings /> },
{ path: 'notifications', element: <NotificationsSettings /> },
{ path: 'providers', element: <ProvidersSettings /> },
{ path: 'plugins', element: <PluginsSettings /> },
{ path: 'agents', element: <AgentsSettings /> },
{ path: 'messaging', element: <MessagingSettings /> },
],
Expand Down
9 changes: 9 additions & 0 deletions apps/webview/src/routes/settings/plugins.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PluginsSettingsPanel } from '@linkcode/workbench';
import { usePageTitle } from '@webview/hooks/use-page-title';
import { useTranslations } from 'use-intl';

export function PluginsSettings(): React.ReactNode {
const tTabs = useTranslations('settings.tabs');
usePageTitle(tTabs('plugins'));
return <PluginsSettingsPanel />;
}
Loading