diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index a02c685e7..0ea9f3802 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -23,21 +23,23 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr - **Paths are owned by `src/config.ts`** (`configPath` / `databasePath` / `runtimeFilePath`) — never scatter `homedir()` joins elsewhere. `os.homedir()` is read at call time, so a fake `$HOME` fully redirects config/db/runtime (this is what isolates an E2E daemon). -- **`config.json`** (optional, `0600`): the daemon writes back only `providers` via `saveProviders`, +- **`config.json`** (optional, `0600`): the daemon writes back only structure via the config helpers, re-reading and preserving other fields. `loadConfig` validates providers **field-by-field** — one bad entry is dropped and logged, never blanks the rest. It holds **no secrets** since CODE-371 — - `providers[kind].apiKey` and each account's credential secret live in `secrets.json` below, and + `providers[kind].apiKey`, each account's credential secret, and custom MCP env/header values live + in `secrets.json` below, and `withAccountSecret` merges them back *before* zod validation, so a secret that is gone fails `AccountSchema` and drops through that same per-entry path. -- **`secrets.json`** (`0600`) — every long-lived credential, keyed `namespace:key` where the key is - the id the owning record already carries: `cloud:session`, `provider:`, `account:`, - `device:software-key`. Custody is a 32-byte AES-256-GCM master key in the OS keyring +- **`secrets.json`** (`0600`) — every long-lived credential, keyed `namespace:key`: `cloud:session`, + `provider:`, `account:`, custom MCP server/field tuples, `device:software-key`. Custody + is a 32-byte AES-256-GCM master key in the OS keyring (`@napi-rs/keyring`, service = `keyringServiceName(channel, profile)` so a development daemon cannot read the release one's), and the file is ciphertext. - **`vault.namespace(name)` is the only way in** — there is no whole-store handle. That is what makes `SecretStore.replaceAll` safe to hand out: a `save*` replaces its own namespace in one write, so pruning a deleted account is implicit and cannot reach a neighbour's secrets. Adding a - subsystem is one entry in the `SecretNamespace` union plus its own key names; the vault stays + subsystem is one entry in the `SecretNamespace` union plus its own key names; custom MCP values + use the `custom-mcp` namespace. The vault stays ignorant of what any of them mean. - **The vault is constructed once, in `main()`, and passed down.** Every consumer takes a `SecretVault` parameter and opens its own namespace — nothing reaches `secretVault()` by import. diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index ff33634fd..adb776df0 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -1,7 +1,7 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { Account } from '@linkcode/schema'; +import type { Account, Accounts, CustomMcpServer } from '@linkcode/schema'; import { DAEMON_DEFAULT_PORT, DAEMON_PORT_HUNT_SPAN, daemonBasePort } from '@linkcode/schema'; import { noop } from 'foxts/noop'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -13,9 +13,11 @@ import { loadConfig, runtimeFilePath, saveAccounts, + saveCustomMcpServers, } from '../config'; import { logger } from '../logger'; import { daemonChannel, telemetryConfigCachePath } from '../paths'; +import { createProviderConfigStore } from '../provider-store'; import type { InMemoryVault } from './fixtures/in-memory-vault'; import { createInMemoryVault } from './fixtures/in-memory-vault'; @@ -62,7 +64,7 @@ const validAccount: Account = { label: 'Personal key', credential: { type: 'api-key', key: 'sk-test' }, createdAt: 0, -}; +} satisfies Accounts[number]; describe('loadConfig providers', () => { it('keeps valid provider entries and drops an invalid one, logging the error', () => { @@ -267,6 +269,104 @@ describe('loadConfig accounts', () => { }); }); +describe('loadConfig custom MCP servers', () => { + const validServer = { + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + env: { GITHUB_TOKEN: 'secret' }, + }, + createdAt: 1, + } as const satisfies CustomMcpServer; + + function writeCustomMcpConfig(customMcpServers: unknown): void { + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers })); + } + + it('keeps valid servers and drops an invalid one without blanking the rest', () => { + const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); + writeCustomMcpConfig([validServer, { id: 'broken', server: { type: 'stdio' } }]); + + const config = loadConfig(vault); + + expect(config.customMcpServers).toEqual([validServer]); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('round-trips through saveCustomMcpServers preserving other fields at mode 0600', () => { + writeCustomMcpConfig([]); + const path = join(process.env.HOME ?? '', '.linkcode', 'config.json'); + writeFileSync(path, JSON.stringify({ providers: {}, customMcpServers: [] })); + + saveCustomMcpServers(vault, [validServer], []); + + const written: unknown = JSON.parse(readFileSync(path, 'utf8')); + expect(written).toEqual({ + providers: {}, + customMcpServers: [ + { + ...validServer, + server: { ...validServer.server, env: { GITHUB_TOKEN: null } }, + }, + ], + }); + expect(vault.refs.get('custom-mcp:["custom-1","env","GITHUB_TOKEN"]')).toBe('secret'); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(loadConfig(vault).customMcpServers).toEqual([validServer]); + }); + + it('keeps secrets distinct when server ids and keys contain delimiters', () => { + const servers: CustomMcpServer[] = [ + { + ...validServer, + id: 'a', + server: { ...validServer.server, env: { 'b:env:c': 'first' } }, + }, + { + ...validServer, + id: 'a:env:b', + server: { ...validServer.server, name: 'second', env: { c: 'second' } }, + }, + ]; + + saveCustomMcpServers(vault, servers, []); + + expect(loadConfig(vault).customMcpServers).toEqual(servers); + }); +}); + +describe('createProviderConfigStore', () => { + it('does not publish provider, account, or custom MCP state when persistence fails', () => { + const oldProviders = { codex: { enabled: true } } as const; + const oldAccounts: Accounts = [validAccount]; + const oldCustomMcpServers: CustomMcpServer[] = []; + const store = createProviderConfigStore(vault, oldProviders, oldAccounts, oldCustomMcpServers); + writeFileSync(join(process.env.HOME ?? '', '.linkcode'), 'not a directory'); + + expect(() => store.set({ 'claude-code': { enabled: true } })).toThrow(); + expect(() => store.setAccounts([])).toThrow(); + expect(() => + store.setCustomMcpServers([ + { + id: 'custom-1', + enabled: true, + server: { type: 'stdio', name: 'test', command: 'test' }, + createdAt: 1, + }, + ]), + ).toThrow(); + expect(store.get()).toBe(oldProviders); + expect(store.getAccounts()).toBe(oldAccounts); + expect(store.getCustomMcpServers()).toBe(oldCustomMcpServers); + expect([...vault.refs.keys()].some((ref) => ref.startsWith('custom-mcp:'))).toBe(false); + }); +}); + // CODE-371: config.json used to hold provider api keys and account credentials in the clear. The // vault owns them now, and an upgrade has to move them without the user re-entering anything. describe('credential storage', () => { @@ -295,6 +395,33 @@ describe('credential storage', () => { expect(raw).not.toContain('sk-test'); }); + it('lazily moves inline custom MCP values while preserving their keys', () => { + vi.spyOn(logger, 'warn').mockImplementation(noop); + const server: CustomMcpServer = { + id: 'custom-http', + enabled: true, + server: { + type: 'http', + name: 'search', + url: 'https://mcp.example', + headers: { Authorization: 'Bearer legacy' }, + }, + createdAt: 1, + }; + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers: [server] })); + + expect(loadConfig(vault).customMcpServers).toEqual([server]); + expect(vault.refs.get('custom-mcp:["custom-http","headers","Authorization"]')).toBe( + 'Bearer legacy', + ); + expect(readConfigFile().customMcpServers).toEqual([ + { ...server, server: { ...server.server, headers: { Authorization: null } } }, + ]); + expect(loadConfig(vault).customMcpServers).toEqual([server]); + }); + it('round-trips an account through the vault without ever writing the secret', () => { saveAccounts(vault, [validAccount]); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index b69b74983..b01629c66 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -1,11 +1,18 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, unlinkSync, 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, SimulatorConsentState } from '@linkcode/schema'; +import type { + Accounts, + CustomMcpServer, + ProvidersConfig, + SimulatorConsentState, +} from '@linkcode/schema'; import { AccountSchema, AgentKindSchema, + CustomMcpServerSchema, daemonBasePort, ProviderConfigSchema, SimulatorConsentStateSchema, @@ -15,6 +22,7 @@ import type { TransportServerOptions } from '@linkcode/transport/server'; import { logger } from './logger'; import { daemonChannel, daemonProfile, daemonStateDir } from './paths'; import type { SecretStore, SecretVault } from './secrets'; +import { detachCustomMcpSecrets, withCustomMcpSecrets } from './secrets/custom-mcp-credentials'; import { detachAccountSecrets, detachProviderSecrets, @@ -37,6 +45,8 @@ export interface DaemonConfig { providers?: ProvidersConfig; /** Global account pool (data plane); undefined when nothing is configured. */ accounts?: Accounts; + /** LinkCode-owned custom MCP servers (data plane); undefined when nothing is configured. */ + customMcpServers?: CustomMcpServer[]; /** Which simulators agents may drive, plus the global agent-tools switch (CODE-420). */ simulatorConsent: SimulatorConsentState; } @@ -49,6 +59,7 @@ interface ConfigFile { listeners?: unknown; providers?: unknown; accounts?: unknown; + customMcpServers?: unknown; simulatorConsent?: unknown; } @@ -105,9 +116,10 @@ export function chatWorkspaceRoot(): string { return join(homedir(), workspacesDirName(daemonChannel())); } -/** `config.json` owns two vault namespaces; it opens them itself rather than being handed refs. */ +/** `config.json` opens its vault namespaces itself rather than being handed refs. */ const providerSecrets = (vault: SecretVault): SecretStore => vault.namespace('provider'); const accountSecrets = (vault: SecretVault): SecretStore => vault.namespace('account'); +const customMcpSecrets = (vault: SecretVault): SecretStore => vault.namespace('custom-mcp'); export function loadConfig(vault: SecretVault): DaemonConfig { let file: ConfigFile = {}; @@ -130,13 +142,13 @@ export function loadConfig(vault: SecretVault): DaemonConfig { // Parsing already moved every inline secret into the vault and told us so; rewriting is what takes // the exposed copies off disk. Done here, at the read that found them, so an upgrade needs no user // action. - if (parsedProviders.migrated || parsedAccounts.migrated) { + const parsedCustomMcp = parseCustomMcpServers(customMcpSecrets(vault), file.customMcpServers); + if (parsedProviders.migrated || parsedAccounts.migrated || parsedCustomMcp.migrated) { logger.warn( { operation: 'config.load' }, 'Moving credentials out of config.json into the secret vault', ); - saveProviders(vault, parsedProviders.value); - saveAccounts(vault, parsedAccounts.value); + saveConfigSnapshot(vault, parsedProviders.value, parsedAccounts.value, parsedCustomMcp.value); } return { @@ -145,6 +157,7 @@ export function loadConfig(vault: SecretVault): DaemonConfig { ), providers: parsedProviders.value, accounts: parsedAccounts.value, + customMcpServers: parsedCustomMcp.value, simulatorConsent: parseSimulatorConsent(file.simulatorConsent), }; } @@ -202,6 +215,31 @@ function parseAccounts(store: SecretStore, raw: unknown): Parsed { return { value: accounts, migrated }; } +/** + * Parse element by element like {@link parseAccounts}: one invalid server is dropped and logged, + * never blanking the rest. + */ +function parseCustomMcpServers(store: SecretStore, raw: unknown): Parsed { + if (raw === undefined) return { value: [], migrated: false }; + if (!Array.isArray(raw)) { + logger.warn({ operation: 'config.load' }, 'Invalid custom MCP config: expected an array'); + return { value: [], migrated: false }; + } + const servers: CustomMcpServer[] = []; + let migrated = false; + for (const value of raw) { + const attached = withCustomMcpSecrets(store, value); + migrated ||= attached.migrated; + const server = CustomMcpServerSchema.safeParse(attached.value); + if (!server.success) { + logger.warn({ operation: 'config.load' }, 'Dropping invalid custom MCP server config'); + continue; + } + servers.push(server.data); + } + return { value: servers, migrated }; +} + /** * Parse field by field: an invalid entry is dropped and logged, never blanking the other entries — * `saveProviders` would persist that loss on the next write. @@ -245,10 +283,53 @@ export function saveAccounts(vault: SecretVault, accounts: Accounts): void { writeConfigField('accounts', detachAccountSecrets(accountSecrets(vault), accounts)); } +/** Persist custom MCP structure and key names; values go to the vault. */ +export function saveCustomMcpServers( + vault: SecretVault, + servers: CustomMcpServer[], + previous: CustomMcpServer[], +): void { + const store = customMcpSecrets(vault); + const stripped = detachCustomMcpSecrets(store, servers); + try { + writeConfigField('customMcpServers', stripped); + } catch (error) { + try { + detachCustomMcpSecrets(store, previous); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Failed to persist or restore custom MCP', { + cause: rollbackError, + }); + } + throw error; + } +} + +export function saveConfigSnapshot( + vault: SecretVault, + providers: ProvidersConfig, + accounts: Accounts, + customMcpServers: CustomMcpServer[], +): void { + writeConfigFields({ + providers: detachProviderSecrets(providerSecrets(vault), providers), + accounts: detachAccountSecrets(accountSecrets(vault), accounts), + customMcpServers: detachCustomMcpSecrets(customMcpSecrets(vault), customMcpServers), + }); +} + /** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */ function writeConfigField( - key: 'providers' | 'accounts' | 'simulatorConsent', + key: 'providers' | 'accounts' | 'customMcpServers' | 'simulatorConsent', value: unknown, +): void { + writeConfigFields({ [key]: value }); +} + +function writeConfigFields( + values: Partial< + Record<'providers' | 'accounts' | 'customMcpServers' | 'simulatorConsent', unknown> + >, ): void { const path = configPath(); let file: Record = {}; @@ -258,9 +339,18 @@ function writeConfigField( } catch { // Start from an empty document if the file is missing or malformed. } - file[key] = value; + Object.assign(file, values); mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + const temporaryFile = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(temporaryFile, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporaryFile, path); + } catch (error) { + try { + unlinkSync(temporaryFile); + } catch {} + throw error; + } } function createDefaultSocketIoListener(file: ConfigFile): DaemonListenerConfig { diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 4596cafe1..d65c4eb67 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -179,7 +179,12 @@ async function main(): Promise { const EngineSubsystemLive = Layer.unwrap( Effect.gen(function* () { const { config, hub, previewRoutes } = yield* Shared; - const store = createProviderConfigStore(vault, config.providers ?? {}, config.accounts ?? []); + const store = createProviderConfigStore( + vault, + config.providers ?? {}, + config.accounts ?? [], + config.customMcpServers ?? [], + ); const assets = new AssetManager(); const consentedAgents = consentedManagedAgents(assets); const gc = assets.gcAtBoot(); diff --git a/apps/daemon/src/provider-store.ts b/apps/daemon/src/provider-store.ts index d14f21f1f..a24dd01ff 100644 --- a/apps/daemon/src/provider-store.ts +++ b/apps/daemon/src/provider-store.ts @@ -1,30 +1,38 @@ import type { ProviderConfigStore } from '@linkcode/engine'; -import type { Accounts, ProvidersConfig } from '@linkcode/schema'; -import { saveAccounts, saveProviders } from './config'; +import type { Accounts, CustomMcpServer, ProvidersConfig } from '@linkcode/schema'; +import { saveAccounts, saveCustomMcpServers, saveProviders } from './config'; import type { SecretVault } from './secrets'; /** - * Daemon-backed data-plane config store: in-memory providers + account pool seeded at boot, each - * persisted to `~/.linkcode/config.json` on write. Injected into the Engine so `config.get` / - * `config.set` and per-session provider defaults read and write the same persisted values. + * Daemon-backed data-plane config store: in-memory providers + account pool + custom MCP servers + * seeded at boot, each persisted to `~/.linkcode/config.json` on write. Injected into the Engine + * so `config.get` / `config.set` and per-session provider defaults read and write the same + * persisted values. */ export function createProviderConfigStore( vault: SecretVault, initialProviders: ProvidersConfig, initialAccounts: Accounts, + initialCustomMcpServers: CustomMcpServer[] = [], ): ProviderConfigStore { let providers = initialProviders; let accounts = initialAccounts; + let customMcpServers = initialCustomMcpServers; return { get: () => providers, set(next) { - providers = next; saveProviders(vault, next); + providers = next; }, getAccounts: () => accounts, setAccounts(next) { - accounts = next; saveAccounts(vault, next); + accounts = next; + }, + getCustomMcpServers: () => customMcpServers, + setCustomMcpServers(next) { + saveCustomMcpServers(vault, next, customMcpServers); + customMcpServers = next; }, }; } diff --git a/apps/daemon/src/secrets/custom-mcp-credentials.ts b/apps/daemon/src/secrets/custom-mcp-credentials.ts new file mode 100644 index 000000000..45db492f9 --- /dev/null +++ b/apps/daemon/src/secrets/custom-mcp-credentials.ts @@ -0,0 +1,51 @@ +import type { CustomMcpServer } from '@linkcode/schema'; +import type { AttachedSecret } from './provider-credentials'; +import type { SecretStore } from './vault'; + +function secretKey(id: string, field: 'env' | 'headers', key: string): string { + return JSON.stringify([id, field, key]); +} + +export function withCustomMcpSecrets(store: SecretStore, raw: unknown): AttachedSecret { + if (typeof raw !== 'object' || raw === null) return { value: raw, migrated: false }; + const entry = { ...(raw as Record) }; + if (typeof entry.id !== 'string' || typeof entry.server !== 'object' || entry.server === null) { + return { value: raw, migrated: false }; + } + const server = { ...(entry.server as Record) }; + const field = server.type === 'stdio' ? 'env' : server.type === 'http' ? 'headers' : null; + if (field === null || typeof server[field] !== 'object' || server[field] === null) { + return { value: raw, migrated: false }; + } + const values = { ...(server[field] as Record) }; + let migrated = false; + for (const [key, value] of Object.entries(values)) { + if (typeof value === 'string') { + store.set(secretKey(entry.id, field, key), value); + migrated = true; + } else { + const stored = store.get(secretKey(entry.id, field, key)); + if (stored !== null) values[key] = stored; + } + } + server[field] = values; + entry.server = server; + return { value: entry, migrated }; +} + +export function detachCustomMcpSecrets(store: SecretStore, servers: CustomMcpServer[]): unknown[] { + const secrets = new Map(); + const stripped = servers.map((entry) => { + const field = entry.server.type === 'stdio' ? 'env' : 'headers'; + const values = entry.server.type === 'stdio' ? entry.server.env : entry.server.headers; + if (values === undefined) return entry; + const placeholders: Record = {}; + for (const [key, value] of Object.entries(values)) { + secrets.set(secretKey(entry.id, field, key), value); + placeholders[key] = null; + } + return { ...entry, server: { ...entry.server, [field]: placeholders } }; + }); + store.replaceAll(secrets); + return stripped; +} diff --git a/apps/daemon/src/secrets/vault.ts b/apps/daemon/src/secrets/vault.ts index ac132eee5..2f710caf0 100644 --- a/apps/daemon/src/secrets/vault.ts +++ b/apps/daemon/src/secrets/vault.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import process from 'node:process'; import { logger } from '../logger'; @@ -51,7 +51,7 @@ export interface SecretStore { * impossible. Domain knowledge (which keys exist, what they mean) belongs to the owning module, not * here — this is only the list of who has a slice. */ -export type SecretNamespace = 'cloud' | 'provider' | 'account' | 'device'; +export type SecretNamespace = 'cloud' | 'provider' | 'account' | 'custom-mcp' | 'device'; export type SecretProtection = 'os-keyring' | 'plaintext'; @@ -104,7 +104,16 @@ export function createSecretVault(file: string, loadKey: () => MasterKey | null) data: encrypted ? encrypt(key, JSON.stringify(map)) : map, ...(distrusted && { keyringDistrusted: true }), }; - writeFileSync(file, `${JSON.stringify({ v: 1, ...document })}\n`, { mode: 0o600 }); + const temporaryFile = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + writeFileSync(temporaryFile, `${JSON.stringify({ v: 1, ...document })}\n`, { mode: 0o600 }); + renameSync(temporaryFile, file); + } catch (error) { + try { + unlinkSync(temporaryFile); + } catch {} + throw error; + } }; // Either transition must land without waiting for a write from the caller: an upgrade (plaintext @@ -131,19 +140,48 @@ export function createSecretVault(file: string, loadKey: () => MasterKey | null) protection, get: (key) => secrets.get(prefix + key) ?? null, set(key, secret) { - secrets.set(prefix + key, secret); - persist(); + const ref = prefix + key; + const previous = secrets.get(ref); + secrets.set(ref, secret); + try { + persist(); + } catch (error) { + if (previous === undefined) secrets.delete(ref); + else secrets.set(ref, previous); + throw error; + } }, delete(key) { - if (!secrets.delete(prefix + key)) return; - persist(); + const ref = prefix + key; + const previous = secrets.get(ref); + if (previous === undefined) return; + secrets.delete(ref); + try { + persist(); + } catch (error) { + secrets.set(ref, previous); + throw error; + } }, replaceAll(entries) { + const previous = new Map(); for (const ref of secrets.keys()) { - if (ref.startsWith(prefix)) secrets.delete(ref); + if (ref.startsWith(prefix)) { + const value = secrets.get(ref); + if (value !== undefined) previous.set(ref, value); + secrets.delete(ref); + } } for (const [key, secret] of entries) secrets.set(prefix + key, secret); - persist(); + try { + persist(); + } catch (error) { + for (const ref of secrets.keys()) { + if (ref.startsWith(prefix)) secrets.delete(ref); + } + for (const [ref, secret] of previous) secrets.set(ref, secret); + throw error; + } }, }; }, diff --git a/apps/daemon/src/sim/mcp-endpoint.ts b/apps/daemon/src/sim/mcp-endpoint.ts index 3494b348a..313a3780a 100644 --- a/apps/daemon/src/sim/mcp-endpoint.ts +++ b/apps/daemon/src/sim/mcp-endpoint.ts @@ -6,6 +6,7 @@ import type { SimulatorMcpProvider, SimulatorService, } from '@linkcode/engine'; +import { SIMULATOR_MCP_SERVER_NAME } from '@linkcode/engine'; import type { McpServer as McpServerEntry, SessionId } from '@linkcode/schema'; // eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths (./server/*.js) defeat the resolver; tsc resolves them fine import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -36,7 +37,7 @@ export interface SimulatorMcpNotifications { devicesChanged?: (devices: Awaited>) => void; } -const SERVER_NAME = 'linkcode-sim'; +const SERVER_NAME = SIMULATOR_MCP_SERVER_NAME; const RE_MCP_PATH = /^\/mcp\/([\w-]+)$/; /** HID keyboard usages (page 7) for the keys an agent cannot express as text. Decimal because diff --git a/apps/desktop/src/renderer/src/settings/plugins-tab.tsx b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx new file mode 100644 index 000000000..0d0880460 --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/plugins-tab.tsx @@ -0,0 +1,7 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; + +// A transport-backed workbench container: reachable above the connection gate, degrading to +// loading while the daemon is unreachable — same posture as the providers tab. +export function PluginsTab(): React.ReactNode { + return ; +} diff --git a/apps/desktop/src/renderer/src/settings/settings-view.tsx b/apps/desktop/src/renderer/src/settings/settings-view.tsx index 112028ea2..6d71ada49 100644 --- a/apps/desktop/src/renderer/src/settings/settings-view.tsx +++ b/apps/desktop/src/renderer/src/settings/settings-view.tsx @@ -21,6 +21,7 @@ import { HistoryIcon, InfoIcon, KeyRoundIcon, + PuzzleIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -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'; @@ -147,6 +149,14 @@ export function SettingsView(): React.ReactNode { active: category === 'providers', onClick: () => setCategory('providers'), }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: category === 'plugins', + onClick: () => setCategory('plugins'), + }, { key: 'imChannel', icon: , @@ -299,6 +309,8 @@ function renderSettingsPanel( return ; case 'providers': return ; + case 'plugins': + return ; case 'agents': return ; case 'imChannel': diff --git a/apps/desktop/src/renderer/src/settings/store.ts b/apps/desktop/src/renderer/src/settings/store.ts index 2173ba02b..453d989e1 100644 --- a/apps/desktop/src/renderer/src/settings/store.ts +++ b/apps/desktop/src/renderer/src/settings/store.ts @@ -14,6 +14,7 @@ export type SettingsCategory = | 'providers' | 'agents' | 'imChannel' + | 'plugins' | 'history-import'; /** diff --git a/apps/webview/src/router.tsx b/apps/webview/src/router.tsx index ee7e127bb..bfccf3899 100644 --- a/apps/webview/src/router.tsx +++ b/apps/webview/src/router.tsx @@ -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'; @@ -32,6 +33,7 @@ export function createWebviewRouter( { path: 'developer', element: }, { path: 'notifications', element: }, { path: 'providers', element: }, + { path: 'plugins', element: }, { path: 'agents', element: }, { path: 'messaging', element: }, ], diff --git a/apps/webview/src/routes/settings/plugins.tsx b/apps/webview/src/routes/settings/plugins.tsx new file mode 100644 index 000000000..c0a442883 --- /dev/null +++ b/apps/webview/src/routes/settings/plugins.tsx @@ -0,0 +1,10 @@ +import { PluginsSettingsPanel } from '@linkcode/workbench'; +import { usePageTitle } from '@webview/hooks/use-page-title'; +import { useTranslations } from 'use-intl'; + +/** The shared plugins page lives in `@linkcode/workbench`; webview only adds the page title. */ +export function PluginsSettings(): React.ReactNode { + const tTabs = useTranslations('settings.tabs'); + usePageTitle(tTabs('plugins')); + return ; +} diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index d12dd56e9..4f022b84e 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -5,6 +5,7 @@ import { BotIcon, CodeXmlIcon, KeyRoundIcon, + PuzzleIcon, SendIcon, SettingsIcon, SunMoonIcon, @@ -21,6 +22,7 @@ const SETTINGS_ROUTES: Record = { notifications: '/settings/notifications', agents: '/settings/agents', providers: '/settings/providers', + plugins: '/settings/plugins', messaging: '/settings/messaging', developer: '/settings/developer', }; @@ -92,6 +94,14 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'providers'), render: , }, + { + key: 'plugins', + icon: , + label: t('tabs.plugins'), + keywords: searchKeywords.plugins, + active: isActive(pathname, 'plugins'), + render: , + }, { key: 'messaging', icon: , @@ -163,6 +173,7 @@ function isActive( | 'developer' | 'notifications' | 'providers' + | 'plugins' | 'agents' | 'messaging', ): boolean { diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 712c5edbb..5ee06cf8c 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -9,6 +9,8 @@ import type { AgentRuntimes, AgentStartCatalog, ContentBlock, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitBranchList, @@ -29,6 +31,8 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -54,6 +58,8 @@ import type { SimulatorStatus, SimulatorStreamCodec, SimulatorTouchPhase, + StandaloneSkill, + StandaloneSkillScope, StartOptions, TerminalMetadata, TerminalReplayEvent, @@ -78,7 +84,13 @@ import { ControlChannel } from './client/control-channel'; import type { SequencedAgentEvent } from './client/event-buffer'; import { EventBuffer } from './client/event-buffer'; import { LoopLogBuffer } from './client/loop-log-buffer'; -import type { RandomUUID, RequestAck } from './client/pending-registry'; +import type { + PluginList, + PluginMutation, + RandomUUID, + RequestAck, + SessionStartResult, +} from './client/pending-registry'; import { PendingRegistry, resolveRandomUUID } from './client/pending-registry'; import { TerminalChannel } from './client/terminal-channel'; @@ -86,6 +98,7 @@ export type { AgentLoginHandlers, AgentLoginSettled } from './client/agent-login export type { BrowserCommandExecutor } from './client/browser-host-channel'; export type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; export type { SequencedAgentEvent } from './client/event-buffer'; +export type { PluginList, PluginMutation, SessionStartResult } from './client/pending-registry'; type EventCb = (event: AgentEvent, seq: number) => void; type TerminalOutputCb = (data: string) => void; @@ -363,7 +376,10 @@ export class LinkCodeClient { const p = msg.payload; switch (p.kind) { case 'session.started': - this.pending.resolve('start', p.replyTo, p.sessionId); + this.pending.resolve('start', p.replyTo, { + sessionId: p.sessionId, + mcpWarnings: p.mcpWarnings ?? [], + }); break; case 'session.listed': this.pending.resolve('list', p.replyTo, p.sessions); @@ -378,9 +394,26 @@ export class LinkCodeClient { this.pending.resolve('historyRead', p.replyTo, p.result); break; case 'config.get.result': - // One result carries both; each resolve is a no-op unless a request awaits that reply id. + // One result carries all three; each resolve is a no-op unless a request awaits that reply id. this.pending.resolve('configGet', p.replyTo, p.providers); this.pending.resolve('accountsGet', p.replyTo, p.accounts); + this.pending.resolve('customMcpGet', p.replyTo, p.customMcpServers); + break; + case 'plugin.list.result': + this.pending.resolve('pluginList', p.replyTo, { + plugins: p.plugins, + standaloneSkills: p.standaloneSkills, + providerStatus: p.providerStatus, + }); + break; + case 'plugin.updated': + this.pending.resolve('pluginMutation', p.replyTo, { + plugin: p.plugin, + pendingAuthApps: p.pendingAuthApps, + }); + break; + case 'skill.updated': + this.pending.resolve('skillSetEnabled', p.replyTo, p.skill); break; case 'agent-runtime.listed': this.pending.resolve('agentRuntimeList', p.replyTo, p.runtimes); @@ -617,6 +650,10 @@ export class LinkCodeClient { } startSession(opts: StartOptions): Promise { + return this.startSessionWithWarnings(opts).then((result) => result.sessionId); + } + + startSessionWithWarnings(opts: StartOptions): Promise { return this.control.startSession(opts); } @@ -629,6 +666,10 @@ export class LinkCodeClient { } resumeSession(sessionId: SessionId): Promise { + return this.resumeSessionWithWarnings(sessionId).then((result) => result.sessionId); + } + + resumeSessionWithWarnings(sessionId: SessionId): Promise { return this.control.resumeSession(sessionId); } @@ -655,6 +696,16 @@ export class LinkCodeClient { historyId: AgentHistoryId, startOpts: StartOptions, ): Promise { + return this.resumeHistoryWithWarnings(agentKind, historyId, startOpts).then( + (result) => result.sessionId, + ); + } + + resumeHistoryWithWarnings( + agentKind: AgentKind, + historyId: AgentHistoryId, + startOpts: StartOptions, + ): Promise { return this.control.resumeHistory(agentKind, historyId, startOpts); } @@ -766,6 +817,62 @@ export class LinkCodeClient { return this.control.getAccounts(); } + /** Masked custom MCP servers (env/header keys only — the daemon never returns values). */ + getCustomMcpServers(): Promise { + return this.control.getCustomMcpServers(); + } + + /** Apply custom-MCP patch ops; the masked read model makes whole-array writes unsound. */ + setCustomMcpServers(patches: CustomMcpServerPatchOp[]): Promise { + return this.control.setCustomMcpServers(patches); + } + + /** Discover provider plugins + standalone skills (slow: a CLI shell-out on the daemon). */ + listPlugins(cwd?: string): Promise { + return this.control.listPlugins(cwd); + } + + /** Toggle a plugin; resolves with the re-listed plugin for single-entry cache patching. */ + setPluginEnabled(params: { + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }): Promise { + return this.control.setPluginEnabled(params); + } + + /** Install a catalog entry the host does not have yet. */ + installPlugin(params: { + provider: PluginProvider; + id: string; + cwd?: string; + }): Promise { + return this.control.installPlugin(params); + } + + /** Remove an installed plugin; the marketplace entry survives with no installations. */ + uninstallPlugin(params: { + provider: PluginProvider; + id: string; + cwd?: string; + }): Promise { + return this.control.uninstallPlugin(params); + } + + /** Toggle one skill through its provider; resolves with the re-read skill. */ + setSkillEnabled(params: { + provider: PluginProvider; + skillId: string; + path: string; + scope?: StandaloneSkillScope; + enabled: boolean; + cwd?: string; + }): Promise { + return this.control.setSkillEnabled(params); + } + listAgentRuntimes(): Promise { return this.control.listAgentRuntimes(); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 7d21002a5..2163f6170 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -10,6 +10,8 @@ import type { AgentRuntimes, AgentStartCatalog, ContentBlock, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitBranchList, @@ -27,6 +29,8 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -50,6 +54,8 @@ import type { SimulatorStatus, SimulatorStreamCodec, SimulatorTouchPhase, + StandaloneSkill, + StandaloneSkillScope, StartOptions, WirePayload, WorkspaceFile, @@ -60,7 +66,14 @@ import type { } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; -import type { PendingRegistry, PendingValueMap, RequestAck } from './pending-registry'; +import type { + PendingRegistry, + PendingValueMap, + PluginList, + PluginMutation, + RequestAck, + SessionStartResult, +} from './pending-registry'; import { sendCorrelated } from './pending-registry'; export type HistoryListClientOptions = AgentHistoryListOptions & { @@ -81,7 +94,7 @@ export class ControlChannel { private readonly pending: PendingRegistry, ) {} - startSession(opts: StartOptions): Promise { + startSession(opts: StartOptions): Promise { return this.sendCorrelated('start', (clientReqId) => ({ kind: 'session.start', clientReqId, @@ -103,7 +116,7 @@ export class ControlChannel { } /** Resume a persisted (cold) session by its Link Code id; resolves with the same id. */ - resumeSession(sessionId: SessionId): Promise { + resumeSession(sessionId: SessionId): Promise { return this.sendCorrelated('start', (clientReqId) => ({ kind: 'session.resume', clientReqId, @@ -149,7 +162,7 @@ export class ControlChannel { agentKind: AgentKind, historyId: AgentHistoryId, startOpts: StartOptions, - ): Promise { + ): Promise { return this.sendCorrelated('start', (clientReqId) => ({ kind: 'history.resume', clientReqId, @@ -412,6 +425,90 @@ export class ControlChannel { })); } + /** Read the daemon-owned custom MCP servers (masked projection — never carries a secret). */ + getCustomMcpServers(): Promise { + return this.sendCorrelated('customMcpGet', (clientReqId) => ({ + kind: 'config.get', + clientReqId, + })); + } + + /** Apply custom-MCP patch ops (add / per-key secret update / remove). Preserves other config. */ + setCustomMcpServers(patches: CustomMcpServerPatchOp[]): Promise { + return this.sendCorrelated('ack', (clientReqId) => ({ + kind: 'config.set', + clientReqId, + customMcpServers: patches, + })); + } + + /** Discover provider plugins and standalone skills (a real CLI shell-out on the daemon). */ + listPlugins(cwd?: string): Promise { + return this.sendCorrelated('pluginList', (clientReqId) => ({ + kind: 'plugin.list.get', + clientReqId, + cwd, + })); + } + + /** Toggle a plugin through its provider; resolves with the re-listed, updated plugin. */ + setPluginEnabled(params: { + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }): Promise { + return this.sendCorrelated('pluginMutation', (clientReqId) => ({ + kind: 'plugin.set-enabled', + clientReqId, + ...params, + })); + } + + /** Install a catalog entry. `pendingAuthApps` names provider apps the install left unauthorized — + * codex reports them for most of its catalog and LinkCode cannot complete those flows. */ + installPlugin(params: { + provider: PluginProvider; + id: string; + cwd?: string; + }): Promise { + return this.sendCorrelated('pluginMutation', (clientReqId) => ({ + kind: 'plugin.install', + clientReqId, + ...params, + })); + } + + /** Drop an installed plugin's local state; the marketplace entry itself survives. */ + uninstallPlugin(params: { + provider: PluginProvider; + id: string; + cwd?: string; + }): Promise { + return this.sendCorrelated('pluginMutation', (clientReqId) => ({ + kind: 'plugin.uninstall', + clientReqId, + ...params, + })); + } + + /** Toggle one skill through its provider; resolves with the re-read skill. */ + setSkillEnabled(params: { + provider: PluginProvider; + skillId: string; + path: string; + scope?: StandaloneSkillScope; + enabled: boolean; + cwd?: string; + }): Promise { + return this.sendCorrelated('skillSetEnabled', (clientReqId) => ({ + kind: 'skill.set-enabled', + clientReqId, + ...params, + })); + } + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ listAgentRuntimes(): Promise { return this.sendCorrelated('agentRuntimeList', (clientReqId) => ({ diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index fab9907f1..36862705d 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -4,6 +4,7 @@ import type { AgentHistoryReadResult, AgentRuntimes, AgentStartCatalog, + CustomMcpServerPublic, FileSuggestion, GitBranchList, GitDiff, @@ -15,6 +16,9 @@ import type { LoopInspection, LoopRecord, ManagedAssetStatus, + McpWarning, + Plugin, + PluginProviderStatus, ProvidersConfig, Schedule, ScheduleRun, @@ -28,6 +32,7 @@ import type { SimulatorImageFormat, SimulatorStatus, SimulatorStreamCodec, + StandaloneSkill, TerminalMetadata, WirePayload, WorkspaceFile, @@ -48,6 +53,27 @@ export interface RequestAck { ok: true; } +export interface SessionStartResult { + sessionId: SessionId; + mcpWarnings: McpWarning[]; +} + +/** The `plugin.list.result` payload as one value: catalogs, standalone skills, and per-provider + * discovery outcomes travel together so the UI can tell "empty" from "provider CLI failed". */ +export interface PluginList { + plugins: Plugin[]; + standaloneSkills: StandaloneSkill[]; + providerStatus: PluginProviderStatus[]; +} + +/** The `plugin.updated` payload: every plugin mutation (toggle, install, uninstall) answers with + * the re-listed plugin, so they share one pending tag. */ +export interface PluginMutation { + plugin: Plugin; + /** Install only: provider apps the install left unauthorized. */ + pendingAuthApps?: string[]; +} + export type RandomUUID = () => string; export function resolveRandomUUID(provider?: RandomUUID): RandomUUID { @@ -66,13 +92,17 @@ export function resolveRandomUUID(provider?: RandomUUID): RandomUUID { * kind — several kinds (e.g. `session.start`/`session.resume`/`history.resume`) share one tag. */ export interface PendingValueMap { - start: SessionId; + start: SessionStartResult; list: SessionInfo[]; import: SessionRecord; historyList: AgentHistoryListResult; historyRead: AgentHistoryReadResult; configGet: ProvidersConfig; accountsGet: Accounts; + customMcpGet: CustomMcpServerPublic[]; + pluginList: PluginList; + pluginMutation: PluginMutation; + skillSetEnabled: StandaloneSkill; agentRuntimeList: AgentRuntimes; agentCatalog: AgentStartCatalog; assetList: ManagedAssetStatus[]; @@ -129,6 +159,10 @@ export class PendingRegistry { historyRead: new Map(), configGet: new Map(), accountsGet: new Map(), + customMcpGet: new Map(), + pluginList: new Map(), + pluginMutation: new Map(), + skillSetEnabled: new Map(), agentRuntimeList: new Map(), agentCatalog: new Map(), assetList: new Map(), diff --git a/packages/client/core/tests/integration/control-client.test.ts b/packages/client/core/tests/integration/control-client.test.ts index ae97a0ac5..800fcbf41 100644 --- a/packages/client/core/tests/integration/control-client.test.ts +++ b/packages/client/core/tests/integration/control-client.test.ts @@ -16,6 +16,31 @@ import { createConnectedLocalClient } from '../support/local-client'; const sessionId = 'sess-control' as SessionId; describe('LinkCodeClient control API', () => { + it('preserves MCP warnings on detailed session-start results', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + serverTransport.onMessage((msg) => { + if (msg.payload.kind !== 'session.start') return; + serverTransport.send( + createWireMessage({ + kind: 'session.started', + replyTo: msg.payload.clientReqId, + sessionId, + mcpWarnings: [{ serverName: 'github', reason: 'provider-unsupported' }], + }), + ); + }); + + await expect(client.startSessionWithWarnings({ kind: 'codex', cwd: '/repo' })).resolves.toEqual( + { + sessionId, + mcpWarnings: [{ serverName: 'github', reason: 'provider-unsupported' }], + }, + ); + + client.dispose(); + serverTransport.close(); + }); + it('gets the matching pre-session agent catalog with agent kind and cwd', async () => { const { client, serverTransport } = await createConnectedLocalClient({ randomUUID: () => 'catalog-request', diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 8035617d6..5569b1d2a 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -3,6 +3,9 @@ import type { AssetSettledEvent, HistoryListClientOptions, HistoryReadClientOptions, + PluginList, + PluginMutation, + SessionStartResult, } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; import type { @@ -14,6 +17,8 @@ import type { AgentKind, AgentRuntimes, AgentStartCatalog, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitBranchList, @@ -31,6 +36,8 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -44,6 +51,8 @@ import type { SessionRecord, SessionResource, SessionResourceId, + StandaloneSkill, + StandaloneSkillScope, StartOptions, WorkspaceFile, WorkspaceId, @@ -113,6 +122,10 @@ export class LinkCodeSdkClient { return toResult(this.raw.startSession(opts)); } + startSessionWithWarnings(opts: StartOptions): RequestResult { + return toResult(this.raw.startSessionWithWarnings(opts)); + } + getAgentCatalog(agentKind: AgentKind, cwd?: string): RequestResult { return toResult(this.raw.getAgentCatalog(agentKind, cwd)); } @@ -152,6 +165,10 @@ export class LinkCodeSdkClient { return toResult(this.raw.resumeSession(sessionId)); } + resumeSessionWithWarnings(sessionId: SessionId): RequestResult { + return toResult(this.raw.resumeSessionWithWarnings(sessionId)); + } + /** Import a provider-local history session as a cold record (listed, not started). */ importSession(agentKind: AgentKind, historyId: AgentHistoryId): RequestResult { return toResult(this.raw.importSession(agentKind, historyId)); @@ -179,6 +196,14 @@ export class LinkCodeSdkClient { return toResult(this.raw.resumeHistory(agentKind, historyId, startOpts)); } + resumeHistoryWithWarnings( + agentKind: AgentKind, + historyId: AgentHistoryId, + startOpts: StartOptions, + ): RequestResult { + return toResult(this.raw.resumeHistoryWithWarnings(agentKind, historyId, startOpts)); + } + sendInput(sessionId: SessionId, input: AgentInput): RequestResult<{ ok: true }> { return toResult(this.raw.send(sessionId, input)); } @@ -243,6 +268,62 @@ export class LinkCodeSdkClient { return toResult(this.raw.setAccounts(accounts)); } + /** Masked custom MCP servers (data plane) — env/header keys only, never a secret value. */ + getCustomMcpServers(): RequestResult { + return toResult(this.raw.getCustomMcpServers()); + } + + /** Apply custom-MCP patch ops (add / per-key secret update / remove). */ + setCustomMcpServers(patches: CustomMcpServerPatchOp[]): RequestResult<{ ok: true }> { + return toResult(this.raw.setCustomMcpServers(patches)); + } + + /** Discover provider plugins + standalone skills (slow: a CLI shell-out on the daemon). */ + listPlugins(cwd?: string): RequestResult { + return toResult(this.raw.listPlugins(cwd)); + } + + /** Toggle a plugin; resolves with the re-listed plugin for single-entry cache patching. */ + setPluginEnabled(params: { + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }): RequestResult { + return toResult(this.raw.setPluginEnabled(params)); + } + + /** Install a catalog entry the host does not have yet. */ + installPlugin(params: { + provider: PluginProvider; + id: string; + cwd?: string; + }): RequestResult { + return toResult(this.raw.installPlugin(params)); + } + + /** Uninstall a plugin; the marketplace entry survives with no installations. */ + uninstallPlugin(params: { + provider: PluginProvider; + id: string; + cwd?: string; + }): RequestResult { + return toResult(this.raw.uninstallPlugin(params)); + } + + /** Toggle one skill through its provider; resolves with the re-read skill. */ + setSkillEnabled(params: { + provider: PluginProvider; + skillId: string; + path: string; + scope?: StandaloneSkillScope; + enabled: boolean; + cwd?: string; + }): RequestResult { + return toResult(this.raw.setSkillEnabled(params)); + } + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ listAgentRuntimes(): RequestResult { return toResult(this.raw.listAgentRuntimes()); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index 65660ce53..600351e3e 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -1,4 +1,10 @@ -import type { HistoryListClientOptions, HistoryReadClientOptions } from '@linkcode/client-core'; +import type { + HistoryListClientOptions, + HistoryReadClientOptions, + PluginList, + PluginMutation, + SessionStartResult, +} from '@linkcode/client-core'; import type { Accounts, AgentHistoryId, @@ -8,6 +14,8 @@ import type { AgentKind, AgentRuntimes, AgentStartCatalog, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, FileSuggestion, GitBranchList, @@ -25,6 +33,8 @@ import type { ManagedAssetId, ManagedAssetStatus, PermissionOutcome, + PluginProvider, + PluginScope, ProvidersConfig, QuestionOutcome, Schedule, @@ -37,6 +47,8 @@ import type { SessionRecord, SessionResource, SessionResourceId, + StandaloneSkill, + StandaloneSkillScope, StartOptions, WorkspaceFile, WorkspaceId, @@ -55,6 +67,12 @@ export function startSession(options: Options<{ opts: StartOptions }>): RequestR return resolveClient(options).startSession(options.opts); } +export function startSessionWithWarnings( + options: Options<{ opts: StartOptions }>, +): RequestResult { + return resolveClient(options).startSessionWithWarnings(options.opts); +} + export function getAgentCatalog( options: Options<{ agentKind: AgentKind; cwd?: string }>, ): RequestResult { @@ -105,6 +123,12 @@ export function resumeSession( return resolveClient(options).resumeSession(options.sessionId); } +export function resumeSessionWithWarnings( + options: Options<{ sessionId: SessionId }>, +): RequestResult { + return resolveClient(options).resumeSessionWithWarnings(options.sessionId); +} + export function importSession( options: Options<{ agentKind: AgentKind; historyId: AgentHistoryId }>, ): RequestResult { @@ -133,6 +157,16 @@ export function resumeHistory( ); } +export function resumeHistoryWithWarnings( + options: Options<{ agentKind: AgentKind; historyId: AgentHistoryId; startOpts: StartOptions }>, +): RequestResult { + return resolveClient(options).resumeHistoryWithWarnings( + options.agentKind, + options.historyId, + options.startOpts, + ); +} + export function sendInput( options: Options<{ sessionId: SessionId; input: AgentInput }>, ): RequestResult<{ ok: true }> { @@ -213,6 +247,69 @@ export function setAccounts(options: Options<{ accounts: Accounts }>): RequestRe return resolveClient(options).setAccounts(options.accounts); } +/** Masked custom MCP servers — env/header keys only, never a secret value. */ +export function getCustomMcpServers(options?: Options): RequestResult { + return resolveClient(options).getCustomMcpServers(); +} + +export function setCustomMcpServers( + options: Options<{ patches: CustomMcpServerPatchOp[] }>, +): RequestResult<{ ok: true }> { + return resolveClient(options).setCustomMcpServers(options.patches); +} + +/** Discover provider plugins + standalone skills. Slow (a CLI shell-out on the daemon) — + * consumers refresh manually, never on focus. */ +export function getPlugins(options?: Options<{ cwd?: string }>): RequestResult { + return resolveClient(options).listPlugins(options?.cwd); +} + +/** Toggle a plugin; resolves with the re-listed plugin so callers patch one cache entry. */ +export function setPluginEnabled( + options: Options<{ + provider: PluginProvider; + id: string; + enabled: boolean; + scope?: PluginScope; + cwd?: string; + }>, +): RequestResult { + const { provider, id, enabled, scope, cwd } = options; + return resolveClient(options).setPluginEnabled({ provider, id, enabled, scope, cwd }); +} + +/** Install a catalog entry. Only providers reporting `managementCapabilities.install` accept it; + * the result names any provider apps the install left unauthorized. */ +export function installPlugin( + options: Options<{ provider: PluginProvider; id: string; cwd?: string }>, +): RequestResult { + const { provider, id, cwd } = options; + return resolveClient(options).installPlugin({ provider, id, cwd }); +} + +/** Uninstall a plugin; resolves with the marketplace entry that survives, now uninstalled. */ +export function uninstallPlugin( + options: Options<{ provider: PluginProvider; id: string; cwd?: string }>, +): RequestResult { + const { provider, id, cwd } = options; + return resolveClient(options).uninstallPlugin({ provider, id, cwd }); +} + +/** Toggle one skill; resolves with the re-read skill so callers patch one cache entry. */ +export function setSkillEnabled( + options: Options<{ + provider: PluginProvider; + skillId: string; + path: string; + scope?: StandaloneSkillScope; + enabled: boolean; + cwd?: string; + }>, +): RequestResult { + const { provider, skillId, path, scope, enabled, cwd } = options; + return resolveClient(options).setSkillEnabled({ provider, skillId, path, scope, enabled, cwd }); +} + /** Which agent CLIs the host can actually spawn (probed once at daemon boot). */ export function listAgentRuntimes(options?: Options): RequestResult { return resolveClient(options).listAgentRuntimes(); diff --git a/packages/client/workbench/src/index.ts b/packages/client/workbench/src/index.ts index d8674b2bc..0f74f31da 100644 --- a/packages/client/workbench/src/index.ts +++ b/packages/client/workbench/src/index.ts @@ -44,6 +44,10 @@ export * from './settings/appearance-effects'; export * from './settings/appearance-render-prefs'; export * from './settings/appearance-settings'; export * from './settings/appearance-store'; +export * from './settings/plugins/custom-mcp-patch'; +export * from './settings/plugins/hooks'; +export * from './settings/plugins/plugins-settings'; +export * from './settings/plugins/view'; export * from './settings/providers/providers-settings'; export * from './settings/providers/store'; export * from './settings/search'; diff --git a/packages/client/workbench/src/mock/data/plugins.ts b/packages/client/workbench/src/mock/data/plugins.ts new file mode 100644 index 000000000..8b0d3ebc8 --- /dev/null +++ b/packages/client/workbench/src/mock/data/plugins.ts @@ -0,0 +1,132 @@ +import type { Plugin, PluginProviderStatus, StandaloneSkill } from '@linkcode/schema'; + +/** Mirrors `CodexPluginAdapter`: everything but `update` is backed by a real RPC. */ +const CODEX_MANAGEMENT = { + install: true, + uninstall: true, + update: false, + enable: true, + disable: true, +} as const; + +/** Canned plugin discovery covering the shapes the settings page must render: an installed and + * enabled claude plugin with skills + an MCP server, a blocked codex plugin, marketplace-only + * (not installed) listings with and without install support, and a multi-scope install. */ +export const SEED_PLUGINS: Plugin[] = [ + { + provider: 'claude-code', + id: 'latex@team-tools', + name: 'latex', + displayName: 'LaTeX Toolkit', + description: 'Compile LaTeX documents and preview build artifacts.', + version: '1.2.3', + keywords: ['latex', 'pdf'], + marketplace: { name: 'team-tools', displayName: 'Team Tools' }, + source: { type: 'local', path: '/marketplaces/team-tools/plugins/latex' }, + availability: 'available', + installations: [ + { enabled: true, version: '1.2.3', scope: 'user' }, + { enabled: false, version: '1.2.0', scope: 'project' }, + ], + components: [ + { kind: 'skill', name: 'compile-latex', description: 'Compile a LaTeX project to PDF' }, + { kind: 'skill', name: 'bibtex-cleanup', description: 'Normalize BibTeX entries' }, + { kind: 'command', name: 'render' }, + { kind: 'mcp-server', name: 'latex-tools' }, + ], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: true, + disable: true, + }, + }, + { + provider: 'claude-code', + id: 'reviewer@claude-plugins-official', + name: 'reviewer', + description: 'Marketplace listing that has not been installed yet.', + version: '0.9.0', + keywords: ['review'], + marketplace: { name: 'claude-plugins-official' }, + source: { type: 'remote' }, + availability: 'available', + installations: [], + components: [{ kind: 'agent', name: 'reviewer' }], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: true, + disable: true, + }, + }, + { + provider: 'codex', + id: 'search@openai-bundled', + name: 'search', + displayName: 'Web Search', + description: 'Bundled search tools, installed and enabled.', + keywords: [], + marketplace: { name: 'openai-bundled', displayName: 'OpenAI Bundled' }, + source: { type: 'remote' }, + availability: 'blocked', + installations: [{ enabled: true }], + components: [ + { kind: 'skill', name: 'web-search', enabled: true }, + { kind: 'mcp-server', name: 'search-tools' }, + ], + assets: [], + managementCapabilities: CODEX_MANAGEMENT, + }, + { + provider: 'codex', + id: 'github@openai-curated-remote', + name: 'github', + displayName: 'GitHub', + // An `app` component is what makes the install answer with pendingAuthApps in the mock host. + description: 'Installable marketplace listing whose apps need authorizing after install.', + keywords: ['github', 'pr'], + marketplace: { name: 'openai-curated-remote', displayName: 'OpenAI Curated' }, + source: { type: 'remote' }, + availability: 'available', + installations: [], + components: [ + { kind: 'skill', name: 'review-pr' }, + { kind: 'app', name: 'GitHub' }, + ], + assets: [], + managementCapabilities: CODEX_MANAGEMENT, + }, +]; + +export const SEED_STANDALONE_SKILLS: StandaloneSkill[] = [ + { + provider: 'claude-code', + id: 'docx', + name: 'docx', + description: 'Create and edit Word documents.', + scope: 'user', + path: '/home/user/.claude/skills/docx', + enabled: true, + toggleable: true, + }, + { + provider: 'codex', + id: 'linear', + name: 'linear', + description: 'Linear workflow conventions.', + scope: 'project', + path: '/workspace/.agents/skills/linear/SKILL.md', + enabled: false, + toggleable: true, + }, +]; + +export const SEED_PLUGIN_PROVIDER_STATUS: PluginProviderStatus[] = [ + { provider: 'claude-code', ok: true }, + { provider: 'codex', ok: true }, +]; diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index b95aea963..71ecd197f 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -7,12 +7,16 @@ import type { AgentKind, AgentRuntimes, ContentBlock, + CustomMcpServer, + CustomMcpServerPatchOp, + CustomMcpServerPublic, EffortLevel, ManagedAssetId, ManagedAssetKey, ManagedAssetStatus, MessageId, PermissionOutcome, + Plugin, ProvidersConfig, QuestionOutcome, SessionId, @@ -20,6 +24,7 @@ import type { SessionResource, SessionResourceId, SessionStatus, + StandaloneSkill, TerminalMetadata, TerminalReplayEvent, ToolCall, @@ -47,6 +52,7 @@ import { MOCK_WORKSPACE_FILES, mockFileFixture } from './data/files'; import { gitFixtureFor } from './data/git'; import { SEED_HISTORY } from './data/history'; import { SEED_MODEL_CATALOGS } from './data/models'; +import { SEED_PLUGIN_PROVIDER_STATUS, SEED_PLUGINS, SEED_STANDALONE_SKILLS } from './data/plugins'; import { CHUNK_LATENCY_MS, CONTROL_LATENCY_MS, @@ -179,6 +185,10 @@ export class DevMockHost { private readonly workspaces = new Map(); private providers: ProvidersConfig = {}; private accounts: Accounts = []; + /** Stored with full secrets like the daemon; config.get serves the masked projection. */ + private customMcpServers: CustomMcpServer[] = []; + private readonly plugins: Plugin[] = structuredClone(SEED_PLUGINS); + private readonly standaloneSkills: StandaloneSkill[] = structuredClone(SEED_STANDALONE_SKILLS); private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -363,6 +373,7 @@ export class DevMockHost { replyTo: p.clientReqId, providers: this.providers, accounts: this.accounts, + customMcpServers: this.customMcpServers.map((entry) => maskCustomMcpServer(entry)), }); break; case 'agent-runtime.list': @@ -388,8 +399,80 @@ export class DevMockHost { await wait(CONTROL_LATENCY_MS); if (p.providers !== undefined) this.providers = structuredClone(p.providers); if (p.accounts !== undefined) this.accounts = structuredClone(p.accounts); + if (p.customMcpServers !== undefined) { + this.customMcpServers = applyCustomMcpPatches(this.customMcpServers, p.customMcpServers); + } this.sendSuccess(p.clientReqId); break; + case 'plugin.list.get': + await wait(CONTROL_LATENCY_MS); + this.send({ + kind: 'plugin.list.result', + replyTo: p.clientReqId, + plugins: this.plugins, + standaloneSkills: this.standaloneSkills, + providerStatus: SEED_PLUGIN_PROVIDER_STATUS, + }); + break; + case 'skill.set-enabled': { + await wait(CONTROL_LATENCY_MS); + const skill = this.standaloneSkills.find( + (entry) => entry.provider === p.provider && entry.id === p.skillId, + ); + if (!skill?.toggleable) { + this.sendFailure(p.clientReqId, 'skill management is not supported'); + break; + } + skill.enabled = p.enabled; + this.send({ kind: 'skill.updated', replyTo: p.clientReqId, skill }); + break; + } + case 'plugin.set-enabled': { + await wait(CONTROL_LATENCY_MS); + const plugin = this.plugins.find( + (entry) => entry.provider === p.provider && entry.id === p.id, + ); + if (!plugin?.managementCapabilities.enable) { + this.sendFailure(p.clientReqId, 'plugin management is not supported'); + break; + } + for (const installation of plugin.installations) { + if (p.scope === undefined || installation.scope === p.scope) { + installation.enabled = p.enabled; + } + } + this.send({ kind: 'plugin.updated', replyTo: p.clientReqId, plugin }); + break; + } + case 'plugin.install': + case 'plugin.uninstall': { + await wait(CONTROL_LATENCY_MS); + const installing = p.kind === 'plugin.install'; + const plugin = this.plugins.find( + (entry) => entry.provider === p.provider && entry.id === p.id, + ); + const capable = installing + ? plugin?.managementCapabilities.install + : plugin?.managementCapabilities.uninstall; + if (!plugin || !capable) { + this.sendFailure(p.clientReqId, `${p.provider}: plugin ${p.kind} is not supported`); + break; + } + // Mirrors the daemon: the marketplace entry survives an uninstall with no installations. + plugin.installations = installing + ? [{ enabled: true, version: plugin.version, scope: 'user' }] + : []; + this.send({ + kind: 'plugin.updated', + replyTo: p.clientReqId, + plugin, + ...(installing && + plugin.components.some((component) => component.kind === 'app') && { + pendingAuthApps: ['Mock Connector'], + }), + }); + break; + } case 'workspace.list': await wait(CONTROL_LATENCY_MS); this.send({ @@ -1566,3 +1649,79 @@ const PATH_SEPARATORS_RE = /[/\\]+/; function lastPathSegment(cwd: string): string { return cwd.split(PATH_SEPARATORS_RE).findLast((part) => part.length > 0) ?? cwd; } + +/** Mirror of the daemon's masked projection: env/header values never reach the client. */ +function maskCustomMcpServer(entry: CustomMcpServer): CustomMcpServerPublic { + const { server } = entry; + return { + id: entry.id, + enabled: entry.enabled, + createdAt: entry.createdAt, + server: + server.type === 'stdio' + ? { + type: 'stdio', + name: server.name, + command: server.command, + args: server.args, + envKeys: Object.keys(server.env ?? {}), + } + : { + type: 'http', + name: server.name, + url: server.url, + headerKeys: Object.keys(server.headers ?? {}), + }, + }; +} + +/** Mirror of the daemon's patch semantics: add / enabled flip / per-key secret set-remove / + * remove. Validation (name uniqueness etc.) is deliberately not replicated in the mock. */ +function applyCustomMcpPatches( + current: CustomMcpServer[], + ops: readonly CustomMcpServerPatchOp[], +): CustomMcpServer[] { + let next = structuredClone(current); + for (const op of ops) { + switch (op.op) { + case 'add': + next.push(structuredClone(op.server)); + break; + case 'update': { + const entry = next.find((candidate) => candidate.id === op.id); + if (!entry) break; + if (op.enabled !== undefined) entry.enabled = op.enabled; + if (op.server?.type !== entry.server.type) break; + entry.server.name = op.server.name; + if (entry.server.type === 'stdio' && op.server.type === 'stdio') { + entry.server.command = op.server.command; + entry.server.args = op.server.args; + entry.server.env = applyMockSecretPatch(entry.server.env, op.server.env); + } else if (entry.server.type === 'http' && op.server.type === 'http') { + entry.server.url = op.server.url; + entry.server.headers = applyMockSecretPatch(entry.server.headers, op.server.headers); + } + break; + } + case 'remove': + next = next.filter((candidate) => candidate.id !== op.id); + break; + default: + break; + } + } + return next; +} + +function applyMockSecretPatch( + current: Record | undefined, + patch: { set?: Record; remove?: string[] } | undefined, +): Record | undefined { + if (!patch) return current; + const removed = new Set(patch.remove); + const next: Record = {}; + for (const [key, value] of Object.entries({ ...current, ...patch.set })) { + if (!removed.has(key)) next[key] = value; + } + return next; +} diff --git a/packages/client/workbench/src/settings/plugins/__tests__/custom-mcp-patch.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/custom-mcp-patch.test.ts new file mode 100644 index 000000000..f2ae7f171 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/custom-mcp-patch.test.ts @@ -0,0 +1,148 @@ +import type { CustomMcpServerPublic } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import type { CustomMcpServerDraft } from '../custom-mcp-patch'; +import { buildCustomMcpPatch } from '../custom-mcp-patch'; + +const MINT = { id: 'custom-new', createdAt: 100 }; + +const previousStdio: CustomMcpServerPublic = { + id: 'custom-1', + enabled: true, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + envKeys: ['GITHUB_TOKEN', 'LOG_LEVEL'], + }, + createdAt: 1, +}; + +function stdioDraft(overrides: Partial> = {}) { + return { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + secrets: [ + { key: 'GITHUB_TOKEN', value: '' }, + { key: 'LOG_LEVEL', value: '' }, + ], + ...overrides, + } satisfies CustomMcpServerDraft; +} + +describe('buildCustomMcpPatch', () => { + it('creates a full-plaintext add op for a new server', () => { + const ops = buildCustomMcpPatch( + undefined, + stdioDraft({ secrets: [{ key: 'GITHUB_TOKEN', value: 'secret' }] }), + MINT, + ); + + expect(ops).toEqual([ + { + op: 'add', + server: { + id: 'custom-new', + enabled: true, + createdAt: 100, + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + env: { GITHUB_TOKEN: 'secret' }, + }, + }, + }, + ]); + }); + + it('returns no ops when every secret is left blank and nothing else changed', () => { + expect(buildCustomMcpPatch(previousStdio, stdioDraft(), MINT)).toEqual([]); + }); + + it('never resends untouched secrets: blank = keep, typed = set, flagged = remove', () => { + const ops = buildCustomMcpPatch( + previousStdio, + stdioDraft({ + secrets: [ + { key: 'GITHUB_TOKEN', value: 'rotated' }, + { key: 'LOG_LEVEL', value: '', remove: true }, + { key: 'NEW_VAR', value: 'added' }, + { key: 'BLANK_NEW', value: '' }, + ], + }), + MINT, + ); + + expect(ops).toEqual([ + { + op: 'update', + id: 'custom-1', + server: { + type: 'stdio', + name: 'github', + command: 'gh-mcp', + args: ['--stdio'], + env: { set: { GITHUB_TOKEN: 'rotated', NEW_VAR: 'added' }, remove: ['LOG_LEVEL'] }, + }, + }, + ]); + }); + + it('emits a secret-free update when only non-secret fields changed', () => { + const ops = buildCustomMcpPatch(previousStdio, stdioDraft({ command: 'gh-mcp-v2' }), MINT); + + expect(ops).toEqual([ + { + op: 'update', + id: 'custom-1', + server: { type: 'stdio', name: 'github', command: 'gh-mcp-v2', args: ['--stdio'] }, + }, + ]); + }); + + it('ignores a remove flag for a key the server never had', () => { + const ops = buildCustomMcpPatch( + previousStdio, + stdioDraft({ secrets: [{ key: 'NEVER_EXISTED', value: '', remove: true }] }), + MINT, + ); + + expect(ops).toEqual([]); + }); + + it('expresses a transport switch as remove + add, preserving enablement', () => { + const disabledPrevious = { ...previousStdio, enabled: false }; + const ops = buildCustomMcpPatch( + disabledPrevious, + { + type: 'http', + name: 'github', + url: 'https://mcp.example', + secrets: [{ key: 'Authorization', value: 'Bearer x' }], + }, + MINT, + ); + + expect(ops).toEqual([ + { op: 'remove', id: 'custom-1' }, + { + op: 'add', + server: { + id: 'custom-new', + enabled: false, + createdAt: 100, + server: { + type: 'http', + name: 'github', + url: 'https://mcp.example', + headers: { Authorization: 'Bearer x' }, + }, + }, + }, + ]); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts new file mode 100644 index 000000000..2af413682 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -0,0 +1,274 @@ +import type { PluginList } from '@linkcode/client-core'; +import type { Plugin } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { + filterPluginCards, + pluginCardView, + pluginMcpServerRows, + pluginProviderGroups, + skillRows, +} from '../view'; + +function plugin(overrides: Partial = {}): Plugin { + return { + provider: 'claude-code', + id: 'latex@team-tools', + name: 'latex', + displayName: 'LaTeX Toolkit', + description: 'Compile LaTeX documents.', + version: '2.0.0', + keywords: ['latex'], + marketplace: { name: 'team-tools', displayName: 'Team Tools' }, + availability: 'available', + installations: [{ enabled: true, version: '1.2.3', scope: 'user' }], + components: [ + { kind: 'skill', name: 'compile-latex', description: 'Compile to PDF' }, + { kind: 'skill', name: 'bibtex-cleanup' }, + { kind: 'mcp-server', name: 'latex-tools' }, + ], + assets: [], + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: true, + disable: true, + }, + ...overrides, + }; +} + +function list(overrides: Partial = {}): PluginList { + return { + plugins: [plugin()], + standaloneSkills: [], + providerStatus: [ + { provider: 'claude-code', ok: true }, + { provider: 'codex', ok: false, reason: 'binary not found' }, + ], + ...overrides, + }; +} + +describe('pluginCardView', () => { + it('derives title, install state, capability gating, and component counts', () => { + const card = pluginCardView(plugin()); + + expect(card).toMatchObject({ + key: 'claude-code:latex@team-tools', + title: 'LaTeX Toolkit', + version: '1.2.3', + marketplaceLabel: 'Team Tools', + installed: true, + installations: [{ scope: 'user', enabled: true, canToggle: true }], + componentCounts: { skill: 2, 'mcp-server': 1 }, + }); + }); + + it('gates the toggle off when the provider reports no management capability', () => { + const card = pluginCardView( + plugin({ + provider: 'codex', + managementCapabilities: { + install: false, + uninstall: false, + update: false, + enable: false, + disable: false, + }, + }), + ); + + expect(card.installations[0].canToggle).toBe(false); + }); + + it('allows install and toggles only when availability is available', () => { + const card = pluginCardView( + plugin({ + availability: 'blocked', + managementCapabilities: { + install: true, + uninstall: true, + update: false, + enable: true, + disable: true, + }, + }), + ); + + expect(card.canInstall).toBe(false); + expect(card.canUninstall).toBe(true); + expect(card.installations[0].canToggle).toBe(false); + }); + + it('does not offer Settings toggles for project or managed installations', () => { + const card = pluginCardView( + plugin({ + installations: [ + { enabled: true, scope: 'project' }, + { enabled: true, scope: 'managed' }, + ], + }), + ); + + expect(card.installations.map((installation) => installation.canToggle)).toEqual([ + false, + false, + ]); + }); +}); + +describe('pluginProviderGroups', () => { + it('keeps failed discovery distinguishable from an empty catalog', () => { + const groups = pluginProviderGroups(list(), { installed: true }); + + expect(groups).toHaveLength(2); + expect(groups[0]).toMatchObject({ provider: 'claude-code', discoveryFailed: false }); + expect(groups[0].plugins).toHaveLength(1); + expect(groups[1]).toMatchObject({ + provider: 'codex', + discoveryFailed: true, + failureReason: 'binary not found', + plugins: [], + }); + }); +}); + +describe('pluginProviderGroups partitioning', () => { + it('separates installed plugins from uninstalled marketplace listings', () => { + const catalog = list({ + plugins: [plugin(), plugin({ id: 'market-only@m', installations: [] })], + }); + + const installed = pluginProviderGroups(catalog, { installed: true }); + const market = pluginProviderGroups(catalog, { installed: false }); + + expect(installed[0].plugins.map((card) => card.id)).toEqual(['latex@team-tools']); + expect(market[0].plugins.map((card) => card.id)).toEqual(['market-only@m']); + }); +}); + +describe('filterPluginCards', () => { + it('matches id, title, description, keywords, and component names', () => { + const cards = [pluginCardView(plugin())]; + + expect(filterPluginCards(cards, 'bibtex')).toHaveLength(1); + expect(filterPluginCards(cards, 'TOOLKIT')).toHaveLength(1); + expect(filterPluginCards(cards, 'nonexistent')).toHaveLength(0); + expect(filterPluginCards(cards, ' ')).toHaveLength(1); + }); +}); + +describe('skillRows', () => { + it('groups plugin skills with sibling counts and appends standalone skills', () => { + const rows = skillRows( + list({ + standaloneSkills: [ + { + provider: 'codex', + id: 'linear', + name: 'linear', + scope: 'project', + path: '/x', + enabled: false, + toggleable: true, + }, + ], + }), + ); + + expect(rows.map((row) => row.name)).toEqual(['compile-latex', 'bibtex-cleanup', 'linear']); + expect(rows[0]).toMatchObject({ + pluginTitle: 'LaTeX Toolkit', + canToggle: false, + standaloneScope: undefined, + }); + expect(rows[2]).toMatchObject({ + pluginKey: undefined, + skillId: 'linear', + path: '/x', + // Standalone skills now carry the provider's own state and toggle individually. + enabled: false, + canToggle: true, + standaloneScope: 'project', + }); + }); + + it('excludes skills from plugins that are not installed', () => { + const rows = skillRows(list({ plugins: [plugin({ installations: [] })] })); + + expect(rows).toEqual([]); + }); + + it('keeps bundled skills read-only regardless of plugin installation mutability', () => { + const mutable = skillRows(list()); + const multiScope = skillRows( + list({ + plugins: [ + plugin({ + installations: [ + { enabled: true, scope: 'user' }, + { enabled: false, scope: 'project' }, + ], + }), + ], + }), + ); + const managed = skillRows( + list({ plugins: [plugin({ installations: [{ enabled: true, scope: 'managed' }] })] }), + ); + + expect(mutable.every((row) => !row.canToggle)).toBe(true); + expect(multiScope.every((row) => !row.canToggle)).toBe(true); + expect(managed.every((row) => !row.canToggle)).toBe(true); + }); + + it('uses provider and exact path as a standalone skill identity', () => { + const rows = skillRows( + list({ + plugins: [], + standaloneSkills: [ + { + provider: 'claude-code', + id: 'review', + name: 'review', + scope: 'user', + path: '/home/user/.claude/skills/review', + enabled: true, + toggleable: true, + }, + { + provider: 'claude-code', + id: 'review', + name: 'review', + scope: 'project', + path: '/repo/.claude/skills/review', + enabled: false, + toggleable: true, + }, + ], + }), + ); + + expect(rows.map((row) => row.key)).toEqual([ + 'claude-code:standalone:/home/user/.claude/skills/review', + 'claude-code:standalone:/repo/.claude/skills/review', + ]); + }); +}); + +describe('pluginMcpServerRows', () => { + it('projects installed plugins’ mcp-server components read-only', () => { + const rows = pluginMcpServerRows([plugin(), plugin({ id: 'x@y', installations: [] })]); + + expect(rows).toEqual([ + { + key: 'claude-code:latex@team-tools:latex-tools', + provider: 'claude-code', + pluginTitle: 'LaTeX Toolkit', + serverName: 'latex-tools', + enabled: true, + }, + ]); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/custom-mcp-patch.ts b/packages/client/workbench/src/settings/plugins/custom-mcp-patch.ts new file mode 100644 index 000000000..fc3b09ec5 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/custom-mcp-patch.ts @@ -0,0 +1,155 @@ +import type { + CustomMcpServerPatchOp, + CustomMcpServerPublic, + McpSecretPatch, + McpServer, +} from '@linkcode/schema'; +import { isObjectEmpty } from 'foxts/is-object-empty'; + +/** + * The add/edit dialog's normalized output. Secret rows follow the masked-edit contract: an + * existing key renders with an EMPTY value ("configured" placeholder) — empty means keep, a typed + * value means replace, `remove` stages deletion. New rows with empty values are dropped. + */ +export interface CustomMcpSecretRow { + key: string; + value: string; + remove?: boolean; +} + +export type CustomMcpServerDraft = + | { + type: 'stdio'; + name: string; + command: string; + args: string[]; + secrets: CustomMcpSecretRow[]; + } + | { + type: 'http'; + name: string; + url: string; + secrets: CustomMcpSecretRow[]; + }; + +export interface CustomMcpMint { + id: string; + createdAt: number; +} + +/** + * Diff a dialog draft against the masked previous state into config.set patch ops. This is the + * single place secret-clobber bugs could hide: the client never holds secret values, so it must + * express edits per key and never resend whole servers. + * + * - no previous → one `add` (mint supplies id/createdAt); + * - transport changed → `remove` + `add` (secrets must be re-entered — nothing carries over); + * - same transport → one `update` with per-key `{set, remove}`, or `[]` when nothing changed. + */ +export function buildCustomMcpPatch( + previous: CustomMcpServerPublic | undefined, + draft: CustomMcpServerDraft, + mint: CustomMcpMint, +): CustomMcpServerPatchOp[] { + if (!previous) return [{ op: 'add', server: mintedServer(draft, mint, true) }]; + if (previous.server.type !== draft.type) { + return [ + { op: 'remove', id: previous.id }, + { op: 'add', server: mintedServer(draft, mint, previous.enabled) }, + ]; + } + const previousKeys = new Set( + previous.server.type === 'stdio' ? previous.server.envKeys : previous.server.headerKeys, + ); + const secrets = diffSecrets(draft.secrets, previousKeys); + const nonSecretChanged = + previous.server.name !== draft.name || + (previous.server.type === 'stdio' && draft.type === 'stdio' + ? previous.server.command !== draft.command || + !sameArgs(previous.server.args ?? [], draft.args) + : previous.server.type === 'http' && + draft.type === 'http' && + previous.server.url !== draft.url); + if (!nonSecretChanged && !secrets) return []; + return [ + { + op: 'update', + id: previous.id, + server: + draft.type === 'stdio' + ? { + type: 'stdio', + name: draft.name, + command: draft.command, + args: draft.args.length > 0 ? draft.args : undefined, + ...(secrets && { env: secrets }), + } + : { + type: 'http', + name: draft.name, + url: draft.url, + ...(secrets && { headers: secrets }), + }, + }, + ]; +} + +/** Empty value on an existing key = keep (no entry at all); typed value = set; remove = remove. + * A brand-new key needs a value to count. */ +function diffSecrets( + rows: readonly CustomMcpSecretRow[], + previousKeys: ReadonlySet, +): McpSecretPatch | undefined { + const set: Record = {}; + const remove: string[] = []; + let touched = false; + for (const row of rows) { + const key = row.key.trim(); + if (!key) continue; + if (row.remove) { + if (previousKeys.has(key)) { + remove.push(key); + touched = true; + } + continue; + } + if (row.value !== '') { + set[key] = row.value; + touched = true; + } + } + if (!touched) return undefined; + return { + ...(!isObjectEmpty(set) && { set }), + ...(remove.length > 0 && { remove }), + }; +} + +function mintedServer(draft: CustomMcpServerDraft, mint: CustomMcpMint, enabled: boolean) { + const secrets: Record = {}; + for (const row of draft.secrets) { + const key = row.key.trim(); + if (key && !row.remove && row.value !== '') secrets[key] = row.value; + } + const hasSecrets = !isObjectEmpty(secrets); + const server: McpServer = + draft.type === 'stdio' + ? { + type: 'stdio', + name: draft.name, + command: draft.command, + args: draft.args.length > 0 ? draft.args : undefined, + env: hasSecrets ? secrets : undefined, + } + : { + type: 'http', + name: draft.name, + url: draft.url, + headers: hasSecrets ? secrets : undefined, + }; + return { id: mint.id, enabled, server, createdAt: mint.createdAt }; +} + +function sameArgs(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/packages/client/workbench/src/settings/plugins/hooks.ts b/packages/client/workbench/src/settings/plugins/hooks.ts new file mode 100644 index 000000000..d17955772 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/hooks.ts @@ -0,0 +1,64 @@ +import { + getCustomMcpServers, + getPlugins, + installPlugin, + setCustomMcpServers, + setPluginEnabled, + setSkillEnabled, + uninstallPlugin, +} from '@linkcode/sdk'; +import { toastManager } from 'coss-ui/components/toast'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { useTranslations } from 'use-intl'; +import { useData, useMutation } from '../../runtime/tayori'; + +function useMutationError(): (error: unknown) => void { + const t = useTranslations('settings.plugins'); + return (error) => { + toastManager.add({ + type: 'error', + title: t('updateFailed'), + description: extractErrorMessage(error), + }); + }; +} + +/** + * Provider-plugin + standalone-skill discovery. The daemon shells out to the agent CLIs (codex + * bounds one pass at 30s), so this deliberately never revalidates on focus/reconnect — the page + * offers a manual refresh (`mutate()`) instead, and a toggle patches its single entry via + * `mutate(map, { revalidate: false })` rather than re-running discovery. + * + * Settings is a global surface with no active-workspace concept, so discovery runs without a + * `cwd`; project-scoped marketplaces and skills intentionally don't appear here. + */ +export function usePlugins() { + return useData(getPlugins, {}, { revalidateOnFocus: false, revalidateOnReconnect: false }); +} + +export function useSetPluginEnabled() { + return useMutation(setPluginEnabled, { onError: useMutationError() }); +} + +export function useSetSkillEnabled() { + return useMutation(setSkillEnabled, { onError: useMutationError() }); +} + +/** Install/uninstall run through the provider (codex: `plugin/install`, a real network + disk + * operation), so they are explicit user actions with no optimistic UI. */ +export function useInstallPlugin() { + return useMutation(installPlugin, { onError: useMutationError() }); +} + +export function useUninstallPlugin() { + return useMutation(uninstallPlugin, { onError: useMutationError() }); +} + +/** Masked custom MCP servers; cheap config read, normal trigger-then-revalidate rhythm. */ +export function useCustomMcpServers() { + return useData(getCustomMcpServers, {}); +} + +export function useSetCustomMcpServers() { + return useMutation(setCustomMcpServers, { onError: useMutationError() }); +} diff --git a/packages/client/workbench/src/settings/plugins/mcp-settings.tsx b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx new file mode 100644 index 000000000..b955aca70 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx @@ -0,0 +1,350 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { CustomMcpServerPublic } from '@linkcode/schema'; +import type { CustomMcpServerRow, PluginMcpServerRow } from '@linkcode/ui'; +import { CustomServerList, PluginProvidedServers } from '@linkcode/ui'; +import { Button } from 'coss-ui/components/button'; +import { + Dialog, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from 'coss-ui/components/dialog'; +import { Field, FieldError, FieldLabel } from 'coss-ui/components/field'; +import { Form } from 'coss-ui/components/form'; +import { Input } from 'coss-ui/components/input'; +import { RadioGroup, RadioGroupItem } from 'coss-ui/components/radio-group'; +import { Textarea } from 'coss-ui/components/textarea'; +import { noop } from 'foxts/noop'; +import { Trash2Icon, UndoIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import { z } from 'zod'; +import { rhfErrorsToFormErrors } from '../../lib/form'; +import type { CustomMcpServerDraft } from './custom-mcp-patch'; +import { buildCustomMcpPatch } from './custom-mcp-patch'; +import { useCustomMcpServers, useSetCustomMcpServers } from './hooks'; + +/** Client-minted identity for a new server (module scope: `Date.now` must not run in render). */ +function mintCustomServer(): { id: string; createdAt: number } { + return { id: `custom_${crypto.randomUUID()}`, createdAt: Date.now() }; +} + +const SecretRowSchema = z.object({ + key: z.string(), + value: z.string(), + remove: z.boolean(), +}); + +const McpFormSchema = z + .object({ + name: z.string().trim().min(1), + transport: z.enum(['stdio', 'http']), + command: z.string(), + args: z.string(), + url: z.string(), + secrets: z.array(SecretRowSchema), + }) + .superRefine((form, ctx) => { + if (form.transport === 'stdio' && form.command.trim() === '') { + ctx.addIssue({ code: 'custom', path: ['command'], message: 'required' }); + } + if (form.transport === 'http' && form.url.trim() === '') { + ctx.addIssue({ code: 'custom', path: ['url'], message: 'required' }); + } + }); + +type McpForm = z.infer; + +type DialogState = { mode: 'closed' } | { mode: 'add' } | { mode: 'edit'; id: string }; + +export interface McpTabProps { + pluginRows: PluginMcpServerRow[]; +} + +/** The MCP tab: editable LinkCode-owned servers on top, plugin-provided servers read-only below. */ +export function McpTab({ pluginRows }: McpTabProps): React.ReactNode { + const { data: servers, mutate } = useCustomMcpServers(); + const save = useSetCustomMcpServers(); + const [dialog, setDialog] = useState({ mode: 'closed' }); + + const rows = servers?.map((server) => customServerRow(server)); + const serversById = new Map((servers ?? []).map((server) => [server.id, server])); + const editing = dialog.mode === 'edit' ? serversById.get(dialog.id) : undefined; + + const apply = async (patches: ReturnType): Promise => { + if (patches.length > 0) { + await save.trigger({ patches }); + await mutate(); + } + setDialog({ mode: 'closed' }); + }; + + return ( +
+ setDialog({ mode: 'add' })} + onEdit={(id) => setDialog({ mode: 'edit', id })} + onRemove={(id) => { + void apply([{ op: 'remove', id }]).catch(noop); + }} + onToggle={(id, enabled) => { + void apply([{ op: 'update', id, enabled }]).catch(noop); + }} + /> + + {dialog.mode === 'closed' ? null : ( + setDialog({ mode: 'closed' })} + onSubmit={(draft) => { + void apply(buildCustomMcpPatch(maskOf(editing), draft, mintCustomServer())).catch(noop); + }} + /> + )} +
+ ); +} + +/** The dialog edits against the masked projection — the same view the wire exposes. */ +function maskOf(server: CustomMcpServerPublic | undefined): CustomMcpServerPublic | undefined { + return server; +} + +function customServerRow(server: CustomMcpServerPublic): CustomMcpServerRow { + return { + id: server.id, + name: server.server.name, + transport: server.server.type, + detail: server.server.type === 'stdio' ? server.server.command : server.server.url, + enabled: server.enabled, + secretKeys: server.server.type === 'stdio' ? server.server.envKeys : server.server.headerKeys, + }; +} + +function formDefaults(previous: CustomMcpServerPublic | undefined): McpForm { + if (!previous) { + return { name: '', transport: 'stdio', command: '', args: '', url: '', secrets: [] }; + } + const { server } = previous; + return { + name: server.name, + transport: server.type, + command: server.type === 'stdio' ? server.command : '', + args: server.type === 'stdio' ? (server.args ?? []).join('\n') : '', + url: server.type === 'http' ? server.url : '', + // Existing keys render with an EMPTY value: blank = keep, typed = replace, remove = delete. + secrets: (server.type === 'stdio' ? server.envKeys : server.headerKeys).map((key) => ({ + key, + value: '', + remove: false, + })), + }; +} + +function CustomServerDialog({ + previous, + busy, + onClose, + onSubmit, +}: { + previous: CustomMcpServerPublic | undefined; + busy: boolean; + onClose: () => void; + onSubmit: (draft: CustomMcpServerDraft) => void; +}): React.ReactNode { + const t = useTranslations('settings.plugins.mcp'); + const { + control, + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(McpFormSchema), + defaultValues: formDefaults(previous), + }); + const secrets = useFieldArray({ control, name: 'secrets' }); + const transport = useWatch({ control, name: 'transport' }); + const existingKeyCount = + previous === undefined + ? 0 + : previous.server.type === 'stdio' + ? previous.server.envKeys.length + : previous.server.headerKeys.length; + + const submit = handleSubmit((form) => { + const secretRows: Array<{ key: string; value: string; remove: boolean }> = []; + for (const row of form.secrets) { + const key = row.key.trim(); + if (key !== '') secretRows.push({ key, value: row.value, remove: row.remove }); + } + const args: string[] = []; + for (const line of form.args.split('\n')) { + const trimmed = line.trim(); + if (trimmed !== '') args.push(trimmed); + } + onSubmit( + form.transport === 'stdio' + ? { + type: 'stdio', + name: form.name.trim(), + command: form.command.trim(), + args, + secrets: secretRows, + } + : { type: 'http', name: form.name.trim(), url: form.url.trim(), secrets: secretRows }, + ); + }); + + return ( + open || onClose()}> + + + {previous === undefined ? t('add') : t('edit')} + + +
+ + {t('form.name')} + + + + + {t('form.transport')} + {previous === undefined ? ( + ( + + {(['stdio', 'http'] as const).map((value) => ( + + ))} + + )} + /> + ) : ( +

{t('form.transportLocked')}

+ )} +
+ {transport === 'stdio' ? ( + <> + + {t('form.command')} + + + + + {t('form.args')} +