From a072cc8b2a297997b99eb9725ddf806427080ed4 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Tue, 25 Aug 2026 02:01:54 +0800 Subject: [PATCH 01/19] feat: add LinkCode plugin marketplace with manifest-driven settings and mail plugin --- apps/daemon/package.json | 1 + apps/daemon/src/__tests__/config.test.ts | 85 +++++ apps/daemon/src/__tests__/marketplace.test.ts | 232 ++++++++++++ apps/daemon/src/config.ts | 135 +++++++ apps/daemon/src/index.ts | 6 + apps/daemon/src/marketplace/paths.ts | 18 + apps/daemon/src/marketplace/service.ts | 224 +++++++++++ apps/daemon/src/plugin-store/paths.ts | 42 ++ apps/daemon/src/plugin-store/store.ts | 270 +++++++++++++ apps/daemon/src/secrets/vault.ts | 2 +- docs/ENVIRONMENT.md | 1 + eslint.config.cjs | 1 + .../core/src/__tests__/plugin-market.test.ts | 226 +++++++++++ packages/client/core/src/client.ts | 83 +++- .../client/core/src/client/control-channel.ts | 65 ++++ .../core/src/client/pending-registry.ts | 50 +++ packages/client/sdk/src/client.ts | 43 +++ packages/client/sdk/src/operations.ts | 57 +++ .../src/mock/data/linkcode-marketplace.ts | 115 ++++++ .../workbench/src/mock/dev-mock-host.ts | 162 ++++++++ .../__tests__/linkcode-config-dialog.test.tsx | 101 +++++ .../plugins/__tests__/linkcode-config.test.ts | 126 ++++++ .../settings/plugins/__tests__/view.test.ts | 101 +++++ .../workbench/src/settings/plugins/hooks.ts | 41 ++ .../plugins/linkcode-config-dialog.tsx | 198 ++++++++++ .../src/settings/plugins/linkcode-config.ts | 96 +++++ .../src/settings/plugins/linkcode-tab.tsx | 174 +++++++++ .../src/settings/plugins/mcp-settings.tsx | 55 +++ .../src/settings/plugins/plugins-settings.tsx | 2 + .../workbench/src/settings/plugins/view.ts | 65 +++- .../schema/src/model/__tests__/plugin.test.ts | 79 ++++ .../schema/src/model/linkcode-marketplace.ts | 15 +- .../schema/src/model/linkcode-plugin.ts | 117 +++++- .../foundation/schema/src/wire/message.ts | 2 +- .../foundation/schema/src/wire/payload.ts | 4 + .../schema/src/wire/plugin-config.ts | 45 +++ .../schema/src/wire/plugin-market.ts | 71 ++++ .../tests/contract/wire/plugin-market.test.ts | 126 ++++++ packages/host/assets/src/index.ts | 1 + .../src/__tests__/plugin-market.test.ts | 351 +++++++++++++++++ .../src/__tests__/start-options-mcp.test.ts | 73 ++++ packages/host/engine/src/deps.ts | 9 + packages/host/engine/src/engine.ts | 20 + packages/host/engine/src/index.ts | 12 + .../src/plugin/config-request-handler.ts | 67 ++++ .../host/engine/src/plugin/config-service.ts | 95 +++++ .../host/engine/src/plugin/linkcode-store.ts | 97 +++++ .../src/plugin/market-request-handler.ts | 176 +++++++++ .../host/engine/src/plugin/market-service.ts | 33 ++ .../src/session/start-options-resolver.ts | 77 +++- .../host/engine/src/wire/request-router.ts | 14 + packages/integrations/mail-mcp/package.json | 30 ++ .../mail-mcp/src/__tests__/body.test.ts | 156 ++++++++ .../mail-mcp/src/__tests__/config.test.ts | 104 +++++ .../mail-mcp/src/__tests__/imap.test.ts | 223 +++++++++++ .../mail-mcp/src/__tests__/smtp.test.ts | 73 ++++ .../mail-mcp/src/__tests__/tools.test.ts | 181 +++++++++ packages/integrations/mail-mcp/src/body.ts | 90 +++++ packages/integrations/mail-mcp/src/config.ts | 100 +++++ packages/integrations/mail-mcp/src/imap.ts | 358 ++++++++++++++++++ packages/integrations/mail-mcp/src/index.ts | 54 +++ packages/integrations/mail-mcp/src/smtp.ts | 86 +++++ packages/integrations/mail-mcp/src/tools.ts | 225 +++++++++++ packages/integrations/mail-mcp/src/types.ts | 24 ++ packages/integrations/mail-mcp/tsconfig.json | 4 + packages/integrations/mail-mcp/tsup.config.ts | 17 + packages/presentation/i18n/src/locales/en.ts | 25 ++ .../presentation/i18n/src/locales/zh-cn.ts | 25 ++ .../ui/src/shell/plugins/index.ts | 1 + .../ui/src/shell/plugins/linkcode-catalog.tsx | 231 +++++++++++ .../ui/src/shell/plugins/plugins-shell.tsx | 5 + .../ui/src/shell/plugins/types.ts | 25 ++ pnpm-lock.yaml | 107 +++++- tsconfig.json | 1 + 74 files changed, 6385 insertions(+), 21 deletions(-) create mode 100644 apps/daemon/src/__tests__/marketplace.test.ts create mode 100644 apps/daemon/src/marketplace/paths.ts create mode 100644 apps/daemon/src/marketplace/service.ts create mode 100644 apps/daemon/src/plugin-store/paths.ts create mode 100644 apps/daemon/src/plugin-store/store.ts create mode 100644 packages/client/core/src/__tests__/plugin-market.test.ts create mode 100644 packages/client/workbench/src/mock/data/linkcode-marketplace.ts create mode 100644 packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx create mode 100644 packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts create mode 100644 packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx create mode 100644 packages/client/workbench/src/settings/plugins/linkcode-config.ts create mode 100644 packages/client/workbench/src/settings/plugins/linkcode-tab.tsx create mode 100644 packages/foundation/schema/src/wire/plugin-config.ts create mode 100644 packages/foundation/schema/src/wire/plugin-market.ts create mode 100644 packages/foundation/schema/tests/contract/wire/plugin-market.test.ts create mode 100644 packages/host/engine/src/__tests__/plugin-market.test.ts create mode 100644 packages/host/engine/src/plugin/config-request-handler.ts create mode 100644 packages/host/engine/src/plugin/config-service.ts create mode 100644 packages/host/engine/src/plugin/linkcode-store.ts create mode 100644 packages/host/engine/src/plugin/market-request-handler.ts create mode 100644 packages/host/engine/src/plugin/market-service.ts create mode 100644 packages/integrations/mail-mcp/package.json create mode 100644 packages/integrations/mail-mcp/src/__tests__/body.test.ts create mode 100644 packages/integrations/mail-mcp/src/__tests__/config.test.ts create mode 100644 packages/integrations/mail-mcp/src/__tests__/imap.test.ts create mode 100644 packages/integrations/mail-mcp/src/__tests__/smtp.test.ts create mode 100644 packages/integrations/mail-mcp/src/__tests__/tools.test.ts create mode 100644 packages/integrations/mail-mcp/src/body.ts create mode 100644 packages/integrations/mail-mcp/src/config.ts create mode 100644 packages/integrations/mail-mcp/src/imap.ts create mode 100644 packages/integrations/mail-mcp/src/index.ts create mode 100644 packages/integrations/mail-mcp/src/smtp.ts create mode 100644 packages/integrations/mail-mcp/src/tools.ts create mode 100644 packages/integrations/mail-mcp/src/types.ts create mode 100644 packages/integrations/mail-mcp/tsconfig.json create mode 100644 packages/integrations/mail-mcp/tsup.config.ts create mode 100644 packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 1bf04c783..df3000f3d 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -34,6 +34,7 @@ "better-sqlite3": "^12.11.1", "foxts": "^5.8.0", "pino": "^10.3.1", + "tar": "^7.5.22", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 25f906fe9..d2b0a861b 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -473,6 +473,91 @@ describe('loadConfig custom MCP servers', () => { }); }); +function writeMarketplacesConfig(marketplaces: unknown): void { + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify({ marketplaces })); +} + +describe('loadConfig marketplaces', () => { + afterEach(() => { + delete process.env.LINKCODE_MARKETPLACE_URL; + }); + + it('defaults to the official marketplace when config.json names none', () => { + expect(loadConfig(vault).marketplaces).toEqual([ + { + id: 'linkcode-official', + displayName: 'LinkCode Official', + source: { type: 'remote', url: 'https://plugins.linkcode.ai/index.json' }, + enabled: true, + }, + ]); + }); + + it('keeps valid entries and drops invalid or duplicate ones, logging each drop', () => { + const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); + writeMarketplacesConfig([ + { + id: 'community', + source: { type: 'remote', url: 'https://example.com/index.json' }, + enabled: true, + }, + { + id: 'community', + source: { type: 'remote', url: 'https://other.example/index.json' }, + enabled: true, + }, + { + id: 'insecure', + source: { type: 'remote', url: 'http://example.com/index.json' }, + enabled: true, + }, + ]); + + expect(loadConfig(vault).marketplaces).toEqual([ + { + id: 'community', + source: { type: 'remote', url: 'https://example.com/index.json' }, + enabled: true, + }, + ]); + expect(errorSpy).toHaveBeenCalledTimes(2); + }); + + it('falls back to the default when the field is not an array', () => { + const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); + writeMarketplacesConfig({ not: 'an array' }); + + expect(loadConfig(vault).marketplaces[0]?.id).toBe('linkcode-official'); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("lets LINKCODE_MARKETPLACE_URL retarget the official marketplace's index URL", () => { + process.env.LINKCODE_MARKETPLACE_URL = 'https://staging.example/index.json'; + + expect(loadConfig(vault).marketplaces).toEqual([ + { + id: 'linkcode-official', + displayName: 'LinkCode Official', + source: { type: 'remote', url: 'https://staging.example/index.json' }, + enabled: true, + }, + ]); + }); + + it('ignores a non-HTTPS LINKCODE_MARKETPLACE_URL, logging the drop', () => { + const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); + process.env.LINKCODE_MARKETPLACE_URL = 'http://insecure.example/index.json'; + + expect(loadConfig(vault).marketplaces[0]?.source).toEqual({ + type: 'remote', + url: 'https://plugins.linkcode.ai/index.json', + }); + expect(errorSpy).toHaveBeenCalled(); + }); +}); + describe('createProviderConfigStore', () => { it('does not publish provider, account, or custom MCP state when persistence fails', () => { const oldProviders = { codex: { enabled: true } } as const; diff --git a/apps/daemon/src/__tests__/marketplace.test.ts b/apps/daemon/src/__tests__/marketplace.test.ts new file mode 100644 index 000000000..ecd82f42c --- /dev/null +++ b/apps/daemon/src/__tests__/marketplace.test.ts @@ -0,0 +1,232 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { LinkCodeMarketplaceConfigList } from '@linkcode/schema'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MarketplaceIndexResponse } from '../marketplace/service'; +import { DaemonLinkCodeMarketplaceService } from '../marketplace/service'; + +let savedHome: string | undefined; + +// The marketplace cache resolves under the channel's state dir; point HOME at a fresh temp dir +// per test, the same isolation the config tests use. +beforeEach(() => { + savedHome = process.env.HOME; + process.env.HOME = mkdtempSync(join(tmpdir(), 'linkcode-marketplace-')); + process.env.LINKCODE_CHANNEL = 'release'; +}); + +afterEach(() => { + process.env.HOME = savedHome; + delete process.env.LINKCODE_CHANNEL; + vi.restoreAllMocks(); +}); + +const MARKETPLACES: LinkCodeMarketplaceConfigList = [ + { + id: 'linkcode-official', + source: { type: 'remote', url: 'https://plugins.example/index.json' }, + enabled: true, + }, +]; + +const INDEX = { + indexVersion: 1, + name: 'Example', + plugins: [ + { + id: 'arcbox/latex', + releases: [ + { + manifest: { + manifestVersion: 1, + id: 'arcbox/latex', + version: '1.2.0', + keywords: [], + components: [{ kind: 'skill', name: 'latex', entry: 'skills/latex/SKILL.md' }], + assets: [], + }, + artifact: { + urls: ['releases/arcbox-latex-1.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + }, + ], + }, + ], +}; + +function fakeResponse( + status: number, + body = '', + headers: Record = {}, +): MarketplaceIndexResponse { + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (name) => headers[name.toLowerCase()] ?? null }, + text: () => Promise.resolve(body), + }; +} + +describe('DaemonLinkCodeMarketplaceService.refresh', () => { + it('fetches the index, returns the flattened catalog, and persists validators', async () => { + const fetchIndex = vi.fn(() => + Promise.resolve(fakeResponse(200, JSON.stringify(INDEX), { etag: '"index-v1"' })), + ); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + const result = await service.refresh('linkcode-official'); + + expect(result.notModified).toBeUndefined(); + expect(result.releases).toHaveLength(1); + expect(result.releases[0]?.pluginId).toBe('arcbox/latex'); + expect(result.releases[0]?.release.manifest.version).toBe('1.2.0'); + expect(fetchIndex).toHaveBeenCalledWith( + 'https://plugins.example/index.json', + expect.objectContaining({ headers: {} }), + ); + + // The next refresh replays the stored validator against the same URL. + await service.refresh('linkcode-official'); + expect(fetchIndex).toHaveBeenLastCalledWith( + 'https://plugins.example/index.json', + expect.objectContaining({ headers: { 'if-none-match': '"index-v1"' } }), + ); + }); + + it('serves a 304 from the cached catalog and keeps it installable', async () => { + let calls = 0; + const fetchIndex = vi.fn(() => { + calls += 1; + return Promise.resolve( + calls === 1 + ? fakeResponse(200, JSON.stringify(INDEX), { etag: '"index-v1"' }) + : fakeResponse(304), + ); + }); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + await service.refresh('linkcode-official'); + const result = await service.refresh('linkcode-official'); + + expect(result).toEqual({ + releases: [{ pluginId: 'arcbox/latex', release: INDEX.plugins[0].releases[0] }], + notModified: true, + }); + expect(fetchIndex).toHaveBeenLastCalledWith( + 'https://plugins.example/index.json', + expect.objectContaining({ headers: { 'if-none-match': '"index-v1"' } }), + ); + expect( + service.resolveRelease({ + marketplaceId: 'linkcode-official', + pluginId: 'arcbox/latex', + version: '1.2.0', + }), + ).toBeDefined(); + }); + + it('discards cached validators when the configured source URL changed', async () => { + const fetchIndex = vi.fn(() => + Promise.resolve(fakeResponse(200, JSON.stringify(INDEX), { etag: '"index-v1"' })), + ); + await new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex).refresh( + 'linkcode-official', + ); + + const moved: LinkCodeMarketplaceConfigList = [ + { ...MARKETPLACES[0], source: { type: 'remote', url: 'https://mirror.example/index.json' } }, + ]; + await new DaemonLinkCodeMarketplaceService(moved, fetchIndex).refresh('linkcode-official'); + + expect(fetchIndex).toHaveBeenLastCalledWith( + 'https://mirror.example/index.json', + expect.objectContaining({ headers: {} }), + ); + }); + + it('rejects a non-OK response without touching the cache', async () => { + const fetchIndex = vi.fn(() => Promise.resolve(fakeResponse(500))); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + await expect(service.refresh('linkcode-official')).rejects.toThrow('HTTP 500'); + expect( + service.resolveRelease({ + marketplaceId: 'linkcode-official', + pluginId: 'arcbox/latex', + version: '1.2.0', + }), + ).toBeUndefined(); + }); + + it('rejects a malformed index body without touching the cache', async () => { + const fetchIndex = vi.fn(() => Promise.resolve(fakeResponse(200, 'not json'))); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + await expect(service.refresh('linkcode-official')).rejects.toThrow('not valid JSON'); + expect( + service.resolveRelease({ + marketplaceId: 'linkcode-official', + pluginId: 'arcbox/latex', + version: '1.2.0', + }), + ).toBeUndefined(); + }); + + it('rejects an index this build cannot read without touching the cache', async () => { + const body = JSON.stringify({ ...INDEX, indexVersion: 2 }); + const fetchIndex = vi.fn(() => Promise.resolve(fakeResponse(200, body))); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + await expect(service.refresh('linkcode-official')).rejects.toThrow('failed validation'); + expect( + service.resolveRelease({ + marketplaceId: 'linkcode-official', + pluginId: 'arcbox/latex', + version: '1.2.0', + }), + ).toBeUndefined(); + }); + + it('rejects an unconfigured marketplace id without fetching', async () => { + const fetchIndex = vi.fn(); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + await expect(service.refresh('community')).rejects.toThrow('Unknown marketplace: community'); + expect(fetchIndex).not.toHaveBeenCalled(); + }); +}); + +describe('DaemonLinkCodeMarketplaceService.resolveRelease', () => { + it('resolves index-relative artifact mirrors against the source URL', async () => { + const fetchIndex = vi.fn(() => Promise.resolve(fakeResponse(200, JSON.stringify(INDEX)))); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + await service.refresh('linkcode-official'); + + const release = service.resolveRelease({ + marketplaceId: 'linkcode-official', + pluginId: 'arcbox/latex', + version: '1.2.0', + }); + + expect(release?.artifact.urls).toEqual([ + 'https://plugins.example/releases/arcbox-latex-1.2.0.tgz', + ]); + }); + + it('misses on an unknown plugin id or version', async () => { + const fetchIndex = vi.fn(() => Promise.resolve(fakeResponse(200, JSON.stringify(INDEX)))); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + await service.refresh('linkcode-official'); + + for (const identity of [ + { marketplaceId: 'linkcode-official', pluginId: 'arcbox/other', version: '1.2.0' }, + { marketplaceId: 'linkcode-official', pluginId: 'arcbox/latex', version: '9.9.9' }, + { marketplaceId: 'community', pluginId: 'arcbox/latex', version: '1.2.0' }, + ]) { + expect(service.resolveRelease(identity)).toBeUndefined(); + } + }); +}); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 56ad8aa0d..811d62960 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -16,6 +16,7 @@ import { daemonRuntimeFilePath } from '@linkcode/common/node'; import type { Accounts, CustomMcpServer, + LinkCodeMarketplaceConfigList, ProvidersConfig, SimulatorConsentState, } from '@linkcode/schema'; @@ -24,12 +25,15 @@ import { AgentKindSchema, CustomMcpServerSchema, daemonBasePort, + LinkCodeMarketplaceConfigSchema, + LinkCodeMarketplaceRemoteSourceSchema, ProviderConfigSchema, SimulatorConsentStateSchema, } from '@linkcode/schema'; import { workspacesDirName } from '@linkcode/schema/product'; import type { TransportServerOptions } from '@linkcode/transport/server'; import { extractErrorMessage, isErrorLikeObject } from 'foxts/extract-error-message'; +import { isObjectEmpty } from 'foxts/is-object-empty'; import { logger } from './logger'; import { daemonChannel, daemonProfile, daemonStateDir } from './paths'; import type { SecretStore, SecretVault } from './secrets'; @@ -58,6 +62,12 @@ export interface DaemonConfig { accounts?: Accounts; /** LinkCode-owned custom MCP servers (data plane); undefined when nothing is configured. */ customMcpServers?: CustomMcpServer[]; + /** LinkCode plugin non-secret setting values, keyed by plugin id then field id. Secret values + * live in the vault's `plugin` namespace; this holds only what the manifest declares non-secret. */ + pluginConfigs?: Record>; + /** Configured plugin marketplaces; always resolved — the built-in official one fills in when + * config.json names none. */ + marketplaces: LinkCodeMarketplaceConfigList; /** Which simulators agents may drive, plus the global agent-tools switch (CODE-420). */ simulatorConsent: SimulatorConsentState; } @@ -71,6 +81,8 @@ interface ConfigFile { providers?: unknown; accounts?: unknown; customMcpServers?: unknown; + pluginConfigs?: unknown; + marketplaces?: unknown; simulatorConsent?: unknown; } @@ -131,6 +143,7 @@ export function chatWorkspaceRoot(): string { 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'); +const pluginSecrets = (vault: SecretVault): SecretStore => vault.namespace('plugin'); export function loadConfig(vault: SecretVault): DaemonConfig { const file = readConfigFile(); @@ -164,10 +177,70 @@ export function loadConfig(vault: SecretVault): DaemonConfig { providers: parsedProviders.value, accounts: parsedAccounts.value, customMcpServers: parsedCustomMcp.value, + pluginConfigs: parsePluginConfigs(file.pluginConfigs), + marketplaces: parseMarketplaces(file.marketplaces), simulatorConsent: parseSimulatorConsent(file.simulatorConsent), }; } +/** The built-in official marketplace; `LINKCODE_MARKETPLACE_URL` retargets it. */ +export const OFFICIAL_MARKETPLACE_ID = 'linkcode-official'; +const DEFAULT_MARKETPLACE_URL = 'https://plugins.linkcode.ai/index.json'; + +function defaultMarketplaces(): LinkCodeMarketplaceConfigList { + return [ + { + id: OFFICIAL_MARKETPLACE_ID, + displayName: 'LinkCode Official', + source: { type: 'remote', url: DEFAULT_MARKETPLACE_URL }, + enabled: true, + }, + ]; +} + +/** Parse entry by entry: one invalid marketplace is dropped and logged, never blanking the rest. */ +function parseMarketplaces(raw: unknown): LinkCodeMarketplaceConfigList { + let marketplaces: LinkCodeMarketplaceConfigList; + if (raw === undefined) { + marketplaces = defaultMarketplaces(); + } else if (Array.isArray(raw)) { + const seen = new Set(); + marketplaces = []; + for (const value of raw) { + const parsed = LinkCodeMarketplaceConfigSchema.safeParse(value); + if (!parsed.success || seen.has(parsed.data.id)) { + logger.warn({ operation: 'config.load' }, 'Dropping invalid marketplace config'); + continue; + } + seen.add(parsed.data.id); + marketplaces.push(parsed.data); + } + } else { + logger.warn({ operation: 'config.load' }, 'Invalid marketplaces config: expected an array'); + marketplaces = defaultMarketplaces(); + } + return applyMarketplaceEnvOverride(marketplaces); +} + +/** The env override always wins over config.json for the official marketplace's index URL. */ +function applyMarketplaceEnvOverride( + marketplaces: LinkCodeMarketplaceConfigList, +): LinkCodeMarketplaceConfigList { + const url = process.env.LINKCODE_MARKETPLACE_URL; + if (!url) return marketplaces; + const source = LinkCodeMarketplaceRemoteSourceSchema.safeParse({ type: 'remote', url }); + if (!source.success) { + logger.warn({ operation: 'config.load' }, 'Ignoring invalid LINKCODE_MARKETPLACE_URL'); + return marketplaces; + } + if (marketplaces.some((entry) => entry.id === OFFICIAL_MARKETPLACE_ID)) { + return marketplaces.map((entry) => + entry.id === OFFICIAL_MARKETPLACE_ID ? { ...entry, source: source.data } : entry, + ); + } + return [{ id: OFFICIAL_MARKETPLACE_ID, source: source.data, enabled: true }, ...marketplaces]; +} + /** A parsed collection plus whether any of its entries carried an inline secret to migrate. */ interface Parsed { value: T; @@ -194,6 +267,68 @@ export function saveSimulatorConsent(state: SimulatorConsentState): void { writeConfigFields(readConfigFile(), { simulatorConsent: state }); } +/** + * Plugin setting values that are non-secret (the manifest's `secret` flag decides; secrets live in + * the vault). Parsed field-by-field so one malformed plugin's values never blank the rest. + */ +function parsePluginConfigs( + raw: unknown, +): Record> | undefined { + if (raw === undefined) return undefined; + if (!isRecord(raw)) { + logger.warn({ operation: 'config.load' }, 'Invalid plugin configs: expected an object'); + return undefined; + } + const out: Record> = {}; + for (const [pluginId, fields] of Object.entries(raw)) { + if (!isRecord(fields)) { + logger.warn({ pluginId, operation: 'config.load' }, 'Dropping invalid plugin config values'); + continue; + } + const values: Record = {}; + for (const [fieldId, value] of Object.entries(fields)) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + values[fieldId] = value; + } + } + out[pluginId] = values; + } + return out; +} + +/** Read one plugin's non-secret values from config.json (secrets stay in the vault). */ +export function loadPluginConfigValues( + pluginId: string, +): Record { + const file = readConfigFile(); + const block = isRecord(file.pluginConfigs) ? file.pluginConfigs[pluginId] : undefined; + if (!isRecord(block)) return {}; + const values: Record = {}; + for (const [fieldId, value] of Object.entries(block)) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + values[fieldId] = value; + } + } + return values; +} + +/** Replace one plugin's non-secret values wholesale; an empty map deletes the plugin's block. */ +export function savePluginConfigValues( + pluginId: string, + values: Record, +): void { + const file = readConfigFile(); + const configs = isRecord(file.pluginConfigs) ? { ...file.pluginConfigs } : {}; + if (isObjectEmpty(values)) delete configs[pluginId]; + else configs[pluginId] = values; + writeConfigFields(file, { pluginConfigs: configs }); +} + +/** The daemon's `plugin` vault namespace, for secret setting values keyed `.`. */ +export function pluginSecretStore(vault: SecretVault): SecretStore { + return pluginSecrets(vault); +} + /** * Parse element by element: an invalid account is dropped and logged, never blanking the pool — * a later save would persist that loss. Mirrors {@link parseProviders}. diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index d65c4eb67..93ecf4046 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -44,7 +44,9 @@ import { import { DaemonLoggerLive, logger } from './logger'; import { createLoopStore } from './loop-store'; import { agentsToRefresh, consentedManagedAgents } from './managed-agent-refresh'; +import { DaemonLinkCodeMarketplaceService } from './marketplace/service'; import { daemonStateDir } from './paths'; +import { DaemonLinkCodePluginStore } from './plugin-store/store'; import { createProviderConfigStore } from './provider-store'; import { resolveSidecarPath, SidecarPtyBackend } from './pty/sidecar'; import { createResourceStore } from './resource-store'; @@ -185,6 +187,8 @@ async function main(): Promise { config.accounts ?? [], config.customMcpServers ?? [], ); + const linkCodePluginStore = new DaemonLinkCodePluginStore(vault); + const linkCodeMarketplace = new DaemonLinkCodeMarketplaceService(config.marketplaces); const assets = new AssetManager(); const consentedAgents = consentedManagedAgents(assets); const gc = assets.gcAtBoot(); @@ -250,6 +254,8 @@ async function main(): Promise { } const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, { providerStore: store, + linkCodePluginStore, + linkCodeMarketplace, ptyBackend: new SidecarPtyBackend(resolveSidecarPath()), simulators, simulatorMcp, diff --git a/apps/daemon/src/marketplace/paths.ts b/apps/daemon/src/marketplace/paths.ts new file mode 100644 index 000000000..651d06f25 --- /dev/null +++ b/apps/daemon/src/marketplace/paths.ts @@ -0,0 +1,18 @@ +import { join } from 'node:path'; +import { daemonStateDir } from '../paths'; + +/** Per-universe marketplace cache root: `/marketplaces`. Resolved per call so a fake + * `$HOME` redirects it, the same property that isolates an E2E daemon. */ +export function marketplacesRoot(): string { + return join(daemonStateDir(), 'marketplaces'); +} + +/** The last successfully parsed index for one marketplace; installs resolve from it. */ +export function marketplaceIndexCachePath(marketplaceId: string): string { + return join(marketplacesRoot(), `${marketplaceId}.index.json`); +} + +/** Mutable HTTP validators (ETag/Last-Modified), stored apart from the cached index. */ +export function marketplaceRefreshStatePath(marketplaceId: string): string { + return join(marketplacesRoot(), `${marketplaceId}.refresh.json`); +} diff --git a/apps/daemon/src/marketplace/service.ts b/apps/daemon/src/marketplace/service.ts new file mode 100644 index 000000000..de046fa04 --- /dev/null +++ b/apps/daemon/src/marketplace/service.ts @@ -0,0 +1,224 @@ +import { randomUUID } from 'node:crypto'; +import { + chmodSync, + closeSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fetchWithSystemProxy } from '@linkcode/assets'; +import type { LinkCodeMarketplaceService, MarketplaceRefreshResult } from '@linkcode/engine'; +import type { + LinkCodeMarketplaceConfigList, + LinkCodeMarketplaceIndexReader, + LinkCodeMarketplaceRefreshState, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginRelease, +} from '@linkcode/schema'; +import { + LinkCodeMarketplaceIndexReaderSchema, + LinkCodeMarketplaceRefreshStateSchema, +} from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; +import { logger } from '../logger'; +import { marketplaceIndexCachePath, marketplaceRefreshStatePath } from './paths'; + +/** The fetch surface the refresh flow needs; injectable so tests never touch the network. */ +export interface MarketplaceIndexResponse { + readonly status: number; + readonly ok: boolean; + readonly headers: { get(name: string): string | null }; + text(): Promise; +} + +export type MarketplaceFetch = ( + url: string, + options: { headers: Record; signal: AbortSignal }, +) => Promise; + +const ABSOLUTE_HTTP_URL_RE = /^https?:\/\//i; +const REFRESH_TIMEOUT_MS = 30000; + +/** + * Daemon-backed marketplace plane: refreshes each configured HTTPS index with its cached ETag / + * Last-Modified validators (304 reuses the cached catalog), persists the parsed index and the + * refresh state per marketplace id, and resolves install identities from the cache — installs + * never re-fetch the index. Artifact mirrors relative to the index resolve against the source URL + * per RFC 3986. + */ +export class DaemonLinkCodeMarketplaceService implements LinkCodeMarketplaceService { + constructor( + private readonly marketplaces: LinkCodeMarketplaceConfigList, + private readonly fetchIndex: MarketplaceFetch = (url, options) => + fetchWithSystemProxy(url, options), + ) {} + + list(): LinkCodeMarketplaceConfigList { + return this.marketplaces; + } + + async refresh(marketplaceId: string): Promise { + const config = nullthrow( + this.marketplaces.find((entry) => entry.id === marketplaceId), + `Unknown marketplace: ${marketplaceId}`, + ); + const url = config.source.url; + // Validators are only replayed against the exact URL that produced them. + const state = readRefreshState(marketplaceId); + const validators = state?.sourceUrl === url ? state : undefined; + const headers: Record = {}; + if (validators?.etag !== undefined) headers['if-none-match'] = validators.etag; + if (validators?.lastModified !== undefined) { + headers['if-modified-since'] = validators.lastModified; + } + const response = await this.fetchIndex(url, { + headers, + signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), + }); + if (response.status === 304) { + if (validators !== undefined) { + writeRefreshState({ ...validators, checkedAt: Date.now() }); + } + // A 304 means the remote index is unchanged, not that the catalog is empty. Reuse the + // daemon's persisted index so clients can replace their snapshot safely even when they do not + // retain the previous response in memory (for example after an uninstall or page remount). + const cachedIndex = readIndexCache(marketplaceId); + return { + releases: cachedIndex === undefined ? [] : flattenReleases(cachedIndex), + notModified: true, + }; + } + if (!response.ok) { + throw new Error(`Marketplace index request failed with HTTP ${response.status}`); + } + const index = parseIndex(await response.text()); + const now = Date.now(); + writeJsonAtomic(marketplaceIndexCachePath(marketplaceId), index); + writeRefreshState({ + marketplaceId, + sourceUrl: url, + etag: response.headers.get('etag') ?? undefined, + lastModified: response.headers.get('last-modified') ?? undefined, + checkedAt: now, + lastSuccessfulUpdateAt: now, + }); + return { releases: flattenReleases(index) }; + } + + resolveRelease(identity: LinkCodeMarketplaceReleaseIdentity): LinkCodePluginRelease | undefined { + const config = this.marketplaces.find((entry) => entry.id === identity.marketplaceId); + const index = readIndexCache(identity.marketplaceId); + if (config === undefined || index === undefined) return undefined; + const plugin = index.plugins.find((entry) => entry.id === identity.pluginId); + const release = plugin?.releases.find( + (candidate) => candidate.manifest.version === identity.version, + ); + if (release === undefined) return undefined; + return { + ...release, + artifact: { + ...release.artifact, + urls: release.artifact.urls.map((url) => resolveMirrorUrl(url, config.source.url)), + }, + }; + } +} + +function resolveMirrorUrl(url: string, indexUrl: string): string { + if (ABSOLUTE_HTTP_URL_RE.test(url)) return url; + return new URL(url, indexUrl).href; +} + +function parseIndex(body: string): LinkCodeMarketplaceIndexReader { + let raw: unknown; + try { + raw = JSON.parse(body); + } catch (error) { + throw new Error('Marketplace index is not valid JSON', { cause: error }); + } + const result = LinkCodeMarketplaceIndexReaderSchema.safeParse(raw); + if (!result.success) { + throw new Error( + `Marketplace index failed validation: ${result.error.issues[0]?.message ?? 'unknown'}`, + { cause: result.error }, + ); + } + return result.data; +} + +function flattenReleases( + index: LinkCodeMarketplaceIndexReader, +): MarketplaceRefreshResult['releases'] { + return index.plugins.flatMap((plugin) => + plugin.releases.map((release) => ({ pluginId: plugin.id, release })), + ); +} + +function readRefreshState(marketplaceId: string): LinkCodeMarketplaceRefreshState | undefined { + const raw = readJson(marketplaceRefreshStatePath(marketplaceId)); + if (raw === undefined) return undefined; + const result = LinkCodeMarketplaceRefreshStateSchema.safeParse(raw); + if (!result.success) { + logger.warn( + { marketplaceId, operation: 'marketplace.refresh-state' }, + 'Dropping invalid refresh state', + ); + return undefined; + } + return result.data; +} + +function writeRefreshState(state: LinkCodeMarketplaceRefreshState): void { + writeJsonAtomic(marketplaceRefreshStatePath(state.marketplaceId), state); +} + +function readIndexCache(marketplaceId: string): LinkCodeMarketplaceIndexReader | undefined { + const raw = readJson(marketplaceIndexCachePath(marketplaceId)); + if (raw === undefined) return undefined; + const result = LinkCodeMarketplaceIndexReaderSchema.safeParse(raw); + if (!result.success) { + logger.warn( + { marketplaceId, operation: 'marketplace.index-cache' }, + 'Dropping invalid cached marketplace index', + ); + return undefined; + } + return result.data; +} + +function readJson(path: string): unknown { + let contents: string; + try { + contents = readFileSync(path, 'utf8'); + } catch { + return undefined; + } + try { + return JSON.parse(contents); + } catch { + return undefined; + } +} + +function writeJsonAtomic(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = join(dirname(path), `.${process.pid}.${randomUUID()}.tmp`); + try { + const descriptor = openSync(tmp, 'wx', 0o600); + try { + writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8' }); + chmodSync(tmp, 0o600); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + renameSync(tmp, path); + } finally { + rmSync(tmp, { force: true }); + } +} diff --git a/apps/daemon/src/plugin-store/paths.ts b/apps/daemon/src/plugin-store/paths.ts new file mode 100644 index 000000000..66460fb26 --- /dev/null +++ b/apps/daemon/src/plugin-store/paths.ts @@ -0,0 +1,42 @@ +import { mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { linkcodeStateDirName } from '@linkcode/schema'; +import { resolveProductChannel } from '@linkcode/schema/product'; +import { daemonChannel, daemonProfile } from '../paths'; + +const RE_PATH_SEP = /[/\\]/g; + +/** Per-universe LinkCode plugin store root: `/plugins`. A fake `$HOME` redirects it, + * the same property that isolates an E2E daemon from the release one. */ +export function pluginsRoot(): string { + const channel = daemonChannel(); + const profile = daemonProfile(); + const stateDir = join(homedir(), linkcodeStateDirName(channel, profile)); + return join(stateDir, 'plugins'); +} + +/** The central install registry: one `InstalledLinkCodePlugin` record per installed version. */ +export function pluginRegistryPath(): string { + return join(pluginsRoot(), 'registry.json'); +} + +/** Escapes a plugin id (`publisher/name`) into a stable two-level path; the id's `/` is the level. */ +export function pluginPackageDir(pluginId: string, version: string): string { + const segments = pluginId.split('/'); + const safe = segments.length === 2 ? segments : ['unmanaged', pluginId.replace(RE_PATH_SEP, '_')]; + return join(pluginsRoot(), ...safe, version); +} + +/** Staging dir beside the package dir, so publish is one same-volume `rename`. */ +export function makePluginTmpDir(pluginId: string, version: string): string { + const dir = pluginPackageDir(pluginId, version); + const parent = join(dir, '..'); + mkdirSync(dir, { recursive: true }); + return join(parent, `.tmp-${process.pid}-${version}`); +} + +/** Resolve product channel for callers that must not reach into the paths module's side effects. */ +export function resolvedChannel(): ReturnType { + return resolveProductChannel(process.env.LINKCODE_CHANNEL, process.env.LINKCODE_BUILD_CHANNEL); +} diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts new file mode 100644 index 000000000..0df7d0a0b --- /dev/null +++ b/apps/daemon/src/plugin-store/store.ts @@ -0,0 +1,270 @@ +import { randomUUID } from 'node:crypto'; +import { + chmodSync, + closeSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; +import { downloadVerified } from '@linkcode/assets'; +import type { + InstalledLinkCodePluginEntry, + LinkCodePluginStore, + PluginConfigPatch, + PluginConfigValue, +} from '@linkcode/engine'; +import type { + InstalledLinkCodePlugin, + LinkCodePluginManifest, + LinkCodePluginRelease, + ManagedAssetArtifact, +} from '@linkcode/schema'; +import { InstalledLinkCodePluginSchema, LinkCodePluginManifestSchema } from '@linkcode/schema'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { extract as tarExtract } from 'tar'; +import { loadPluginConfigValues, pluginSecretStore, savePluginConfigValues } from '../config'; +import { logger } from '../logger'; +import type { SecretStore, SecretVault } from '../secrets'; +import { makePluginTmpDir, pluginPackageDir, pluginRegistryPath } from './paths'; + +/** Daemon-backed LinkCode plugin store: reads the install registry + on-disk manifests, splits + * setting values between `config.json` (non-secret) and the vault `plugin` namespace (secret) per + * each manifest's `secret` flag, and installs releases by downloading, SRI-verifying, extracting, + * and atomically renaming into the package dir. */ +export class DaemonLinkCodePluginStore implements LinkCodePluginStore { + constructor(private readonly vault: SecretVault) {} + + list(): InstalledLinkCodePluginEntry[] { + const entries: InstalledLinkCodePluginEntry[] = []; + for (const record of readRegistry()) { + const manifest = readManifest(record.path); + if (manifest === undefined) continue; + entries.push({ installed: record, manifest }); + } + return entries; + } + + get(pluginId: string): InstalledLinkCodePluginEntry | undefined { + return this.list().find((entry) => entry.installed.id === pluginId); + } + + getSettings(pluginId: string): Record { + const manifest = this.get(pluginId)?.manifest; + if (manifest?.settings === undefined) return {}; + const nonSecret = loadPluginConfigValues(pluginId); + const secrets = pluginSecretStore(this.vault); + const merged: Record = {}; + for (const [fieldId, field] of Object.entries(manifest.settings)) { + if (field.secret) { + const stored = secrets.get(`${pluginId}.${fieldId}`); + if (stored !== null) merged[fieldId] = stored; + } else if (fieldId in nonSecret) { + merged[fieldId] = nonSecret[fieldId]; + } + } + return merged; + } + + setSettings(pluginId: string, patch: PluginConfigPatch): Promise { + const manifest = this.get(pluginId)?.manifest; + if (manifest?.settings === undefined) { + throw new Error(`Plugin ${pluginId} declares no settings`); + } + const settings = manifest.settings; + const secrets = pluginSecretStore(this.vault); + const nonSecret = loadPluginConfigValues(pluginId); + + if (patch.remove) { + for (const fieldId of patch.remove) { + const field = settings[fieldId]; + if (field === undefined) continue; + if (field.secret) secrets.delete(`${pluginId}.${fieldId}`); + else delete nonSecret[fieldId]; + } + } + if (patch.set) { + for (const [fieldId, value] of Object.entries(patch.set)) { + const field = settings[fieldId]; + if (field === undefined) continue; + if (field.secret) secrets.set(`${pluginId}.${fieldId}`, String(value)); + else nonSecret[fieldId] = value; + } + } + savePluginConfigValues(pluginId, nonSecret); + return Promise.resolve(); + } + + async install( + release: LinkCodePluginRelease, + marketplaceId: string, + ): Promise { + const { manifest, artifact } = release; + if (artifact.format !== 'tgz') { + throw new Error(`Unsupported plugin archive format: ${artifact.format}`); + } + const httpsUrls = artifact.urls.filter((url): url is string => typeof url === 'string'); + if (httpsUrls.length === 0) { + throw new Error('Plugin release has no HTTPS download URL'); + } + const targetDir = pluginPackageDir(manifest.id, manifest.version); + const stagingDir = makePluginTmpDir(manifest.id, manifest.version); + const tgzPath = join(stagingDir, 'package.tgz'); + mkdirSync(stagingDir, { recursive: true }); + try { + const downloadArtifact: ManagedAssetArtifact = { + urls: httpsUrls, + integrity: artifact.integrity, + size: artifact.size, + format: 'tgz', + }; + await downloadVerified(downloadArtifact, tgzPath, {}); + await tarExtract({ file: tgzPath, cwd: stagingDir, strip: 1 }); + const onDisk = readManifest(stagingDir); + if (onDisk?.id !== manifest.id || onDisk?.version !== manifest.version) { + throw new Error( + `Extracted manifest does not match release ${manifest.id}@${manifest.version}`, + ); + } + rmSync(targetDir, { recursive: true, force: true }); + mkdirSync(dirname(targetDir), { recursive: true }); + renameSync(stagingDir, targetDir); + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw new Error( + `Failed to install plugin ${manifest.id}: ${extractErrorMessage(error) ?? 'unknown'}`, + { cause: error }, + ); + } + const record: InstalledLinkCodePlugin = { + id: manifest.id, + version: manifest.version, + marketplaceId, + integrity: artifact.integrity, + enabled: true, + path: targetDir, + }; + upsertRegistry(record); + logger.info( + { pluginId: manifest.id, version: manifest.version, operation: 'plugin.install' }, + 'Installed LinkCode plugin', + ); + return { installed: record, manifest }; + } + + uninstall(pluginId: string): Promise { + const record = readRegistry().find((entry) => entry.id === pluginId); + if (record) { + rmSync(record.path, { recursive: true, force: true }); + writeRegistry(readRegistry().filter((entry) => entry.id !== pluginId)); + } + // Non-secret values are dropped by writing an empty block; secret values are pruned below. + savePluginConfigValues(pluginId, {}); + prunePluginSecrets(pluginSecretStore(this.vault), pluginId); + return Promise.resolve(); + } +} + +function readRegistry(): InstalledLinkCodePlugin[] { + const path = pluginRegistryPath(); + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + logger.warn({ err, operation: 'plugin.registry' }, 'Malformed plugin registry; starting empty'); + return []; + } + if (!Array.isArray(parsed)) return []; + const records: InstalledLinkCodePlugin[] = []; + for (const value of parsed) { + const result = InstalledLinkCodePluginSchema.safeParse(value); + if (result.success) records.push(result.data); + else logger.warn({ operation: 'plugin.registry' }, 'Dropping invalid plugin install record'); + } + return records; +} + +function upsertRegistry(record: InstalledLinkCodePlugin): void { + const next = readRegistry().filter( + (entry) => entry.id !== record.id || entry.version !== record.version, + ); + next.push(record); + writeRegistry(next); +} + +function writeRegistry(records: InstalledLinkCodePlugin[]): void { + const path = pluginRegistryPath(); + const dir = dirname(path); + mkdirSync(dir, { recursive: true }); + const tmp = join(dir, `.registry.${process.pid}.${randomUUID()}.tmp`); + try { + const descriptor = openSync(tmp, 'wx', 0o600); + try { + writeFileSync(descriptor, `${JSON.stringify(records, null, 2)}\n`, { encoding: 'utf8' }); + chmodSync(tmp, 0o600); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + renameSync(tmp, path); + } finally { + rmSync(tmp, { force: true }); + } +} + +function readManifest(packageDir: string): LinkCodePluginManifest | undefined { + let raw: string; + try { + raw = readFileSync(join(packageDir, 'manifest.json'), 'utf8'); + } catch { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + logger.warn({ err, packageDir, operation: 'plugin.manifest' }, 'Malformed plugin manifest'); + return undefined; + } + const result = LinkCodePluginManifestSchema.safeParse(parsed); + if (!result.success) { + logger.warn({ packageDir, operation: 'plugin.manifest' }, 'Dropping invalid plugin manifest'); + return undefined; + } + return result.data; +} + +function prunePluginSecrets(secrets: SecretStore, pluginId: string): void { + // replaceAll on the `plugin` namespace keeps every OTHER plugin's secrets and drops this one's, + // in a single write — the same prune-on-delete property the vault hands other namespaces. + const surviving = new Map(); + for (const entry of readRegistry()) { + if (entry.id === pluginId) continue; + for (const fieldId of secretFieldIds(entry)) { + const value = secrets.get(`${entry.id}.${fieldId}`); + if (value !== null) surviving.set(`${entry.id}.${fieldId}`, value); + } + } + secrets.replaceAll(surviving); +} + +function secretFieldIds(record: InstalledLinkCodePlugin): string[] { + const manifest = readManifest(record.path); + if (manifest?.settings === undefined) return []; + const ids: string[] = []; + for (const [fieldId, field] of Object.entries(manifest.settings)) { + if (field.secret) ids.push(fieldId); + } + return ids; +} diff --git a/apps/daemon/src/secrets/vault.ts b/apps/daemon/src/secrets/vault.ts index 2f710caf0..607b03d95 100644 --- a/apps/daemon/src/secrets/vault.ts +++ b/apps/daemon/src/secrets/vault.ts @@ -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' | 'custom-mcp' | 'device'; +export type SecretNamespace = 'cloud' | 'provider' | 'account' | 'custom-mcp' | 'plugin' | 'device'; export type SecretProtection = 'os-keyring' | 'plaintext'; diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 05d58c9da..0eb450c1c 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -20,6 +20,7 @@ Read by the daemon, desktop, webview, or mobile at run time. | `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe *within a channel*: forks the state dir to the `-` sibling (`~/.linkcode.development-alpha`), plus DB, `runtime.json`, and HQ device identity. `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Workspaces and the asset store do not fork by profile. Desktop reads it too, where `--profile=` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the channel's default universe. | | `LINKCODE_PORT` | `apps/daemon/src/config.ts` | Overrides every configured listener's port. Must parse as an integer in `1..65535`, otherwise the config value stands. | | `LINKCODE_HOST` | `apps/daemon/src/config.ts` | Overrides every listener's bind host. | +| `LINKCODE_MARKETPLACE_URL` | `apps/daemon/src/config.ts` | Retargets the official LinkCode plugin marketplace index (default `https://plugins.linkcode.ai/index.json`). Must be an absolute HTTPS URL, otherwise the configured/default source stands. | | `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/src/pty/sidecar.ts` | Absolute path to the `linkcode-pty` binary; always wins. Dev falls back to `target/release/linkcode-pty`; a bundled `dist/` daemon has no fallback and disables terminals. The packaged desktop supervisor sets it to `/sidecar/`. | | `LINKCODE_SIM_SIDECAR_PATH` | `apps/daemon/src/sim/backend.ts` | Absolute path to the `linkcode-sim` iOS Simulator sidecar; always wins. macOS only — other platforms resolve to none regardless. Dev falls back to `target/release/linkcode-sim`; a bundled `dist/` daemon has no fallback and disables simulators. The packaged desktop supervisor sets it from ``. | | `LINKCODE_AIGATEWAY_PATH` | `apps/daemon/src/ai-gateway.ts` | Path to the `aigateway` translation sidecar, overriding the managed-asset install. | diff --git a/eslint.config.cjs b/eslint.config.cjs index 8674b90b9..f9f0a1623 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -52,6 +52,7 @@ module.exports = require('eslint-config-sukka').sukka( // the root solution; listing them here too would breach typescript-eslint's 8-file cap. 'vitest.config.ts', 'vitest.setup.ts', + 'scripts/*.mts', ], }, }, diff --git a/packages/client/core/src/__tests__/plugin-market.test.ts b/packages/client/core/src/__tests__/plugin-market.test.ts new file mode 100644 index 000000000..c665202b0 --- /dev/null +++ b/packages/client/core/src/__tests__/plugin-market.test.ts @@ -0,0 +1,226 @@ +import type { ValidatedWireMessage, WirePayload } from '@linkcode/schema'; +import type { Transport, Unsubscribe } from '@linkcode/transport'; +import { createWireMessage, pong } from '@linkcode/transport'; +import { describe, expect, it, vi } from 'vitest'; +import { LinkCodeClient } from '../client'; + +class ControlledTransport implements Transport { + readonly sent: WirePayload[] = []; + private readonly messages = new Set<(message: ValidatedWireMessage) => void>(); + private readonly closes = new Set<() => void>(); + + connect(): Promise { + return Promise.resolve(); + } + + send(message: ValidatedWireMessage): void { + this.sent.push(message.payload); + } + + onMessage(cb: (message: ValidatedWireMessage) => void): Unsubscribe { + this.messages.add(cb); + return () => this.messages.delete(cb); + } + + onClose(cb: () => void): Unsubscribe { + this.closes.add(cb); + return () => this.closes.delete(cb); + } + + close(): void { + for (const cb of this.closes) cb(); + } + + receive(payload: WirePayload): void { + const message = createWireMessage(payload); + for (const cb of this.messages) cb(message); + } +} + +async function connect(): Promise<{ client: LinkCodeClient; transport: ControlledTransport }> { + const transport = new ControlledTransport(); + const client = new LinkCodeClient(transport); + const connecting = client.connect(); + await vi.waitFor(() => expect(transport.sent).toContainEqual({ kind: 'ping' })); + transport.receive(pong()); + await connecting; + return { client, transport }; +} + +/** The request payload the client just sent, after the ping. */ +function lastRequest( + transport: ControlledTransport, +): Extract { + const request = transport.sent.at(-1); + if (request === undefined || !('clientReqId' in request)) { + throw new Error('expected a correlated request payload'); + } + return request; +} + +describe('LinkCodeClient plugin-market / plugin-config requests', () => { + it('lists the configured marketplaces', async () => { + const { client, transport } = await connect(); + const pending = client.listPluginMarketplaces(); + const request = lastRequest(transport); + expect(request.kind).toBe('plugin-market.list.get'); + + transport.receive({ + kind: 'plugin-market.listed', + replyTo: request.clientReqId, + marketplaces: [ + { + id: 'linkcode-official', + displayName: 'LinkCode Official', + source: { type: 'remote', url: 'https://plugins.linkcode.ai/index.json' }, + enabled: true, + }, + ], + }); + + await expect(pending).resolves.toEqual([ + { + id: 'linkcode-official', + displayName: 'LinkCode Official', + source: { type: 'remote', url: 'https://plugins.linkcode.ai/index.json' }, + enabled: true, + }, + ]); + client.dispose(); + }); + + it('refreshes a marketplace and resolves with its releases, notModified included', async () => { + const { client, transport } = await connect(); + const pending = client.refreshPluginMarketplace('linkcode-official'); + const request = lastRequest(transport); + expect(request).toMatchObject({ + kind: 'plugin-market.refresh', + marketplaceId: 'linkcode-official', + }); + + transport.receive({ + kind: 'plugin-market.refreshed', + replyTo: request.clientReqId, + marketplaceId: 'linkcode-official', + releases: [], + notModified: true, + }); + + await expect(pending).resolves.toEqual({ + marketplaceId: 'linkcode-official', + releases: [], + notModified: true, + }); + client.dispose(); + }); + + it('installs a release and resolves with the installed identity', async () => { + const { client, transport } = await connect(); + const release = { + marketplaceId: 'linkcode-official', + pluginId: 'linkcode/mail', + version: '1.0.0', + }; + const pending = client.installLinkCodePlugin(release); + const request = lastRequest(transport); + expect(request).toMatchObject({ kind: 'plugin-market.install', release }); + + transport.receive({ + kind: 'plugin-market.installed', + replyTo: request.clientReqId, + ...release, + }); + + await expect(pending).resolves.toEqual(release); + client.dispose(); + }); + + it('uninstalls a plugin and resolves with its id', async () => { + const { client, transport } = await connect(); + const pending = client.uninstallLinkCodePlugin('linkcode/mail'); + const request = lastRequest(transport); + expect(request).toMatchObject({ kind: 'plugin-market.uninstall', pluginId: 'linkcode/mail' }); + + transport.receive({ + kind: 'plugin-market.uninstalled', + replyTo: request.clientReqId, + pluginId: 'linkcode/mail', + }); + + await expect(pending).resolves.toBe('linkcode/mail'); + client.dispose(); + }); + + it('lists masked plugin configs', async () => { + const { client, transport } = await connect(); + const pending = client.listLinkCodePluginConfigs(); + const request = lastRequest(transport); + expect(request.kind).toBe('plugin-config.list.get'); + + const view = { + id: 'linkcode/mail', + version: '1.0.0', + settings: { + account: { type: 'string', required: true }, + password: { type: 'password', secret: true, required: true }, + }, + values: { account: 'you@163.com' }, + } as const; + transport.receive({ + kind: 'plugin-config.listed', + replyTo: request.clientReqId, + plugins: [view], + }); + + await expect(pending).resolves.toEqual([view]); + client.dispose(); + }); + + it('applies a per-key settings patch and resolves with the masked values', async () => { + const { client, transport } = await connect(); + const pending = client.setLinkCodePluginConfig({ + pluginId: 'linkcode/mail', + set: { preset: 'qq', readonly: true }, + remove: ['maxBodyChars'], + }); + const request = lastRequest(transport); + expect(request).toMatchObject({ + kind: 'plugin-config.set', + pluginId: 'linkcode/mail', + set: { preset: 'qq', readonly: true }, + remove: ['maxBodyChars'], + }); + + transport.receive({ + kind: 'plugin-config.updated', + replyTo: request.clientReqId, + pluginId: 'linkcode/mail', + values: { account: 'you@163.com', preset: 'qq', readonly: true }, + }); + + await expect(pending).resolves.toEqual({ + pluginId: 'linkcode/mail', + values: { account: 'you@163.com', preset: 'qq', readonly: true }, + }); + client.dispose(); + }); + + it('rejects a request when the host answers request.failed', async () => { + const { client, transport } = await connect(); + const pending = client.refreshPluginMarketplace('nope'); + const request = lastRequest(transport); + + transport.receive({ + kind: 'request.failed', + replyTo: request.clientReqId, + message: 'Unknown marketplace: nope', + code: 'not_found', + }); + + await expect(pending).rejects.toMatchObject({ + message: 'Unknown marketplace: nope', + code: 'not_found', + }); + client.dispose(); + }); +}); diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 4ffd9fa2b..898c2c637 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -26,6 +26,9 @@ import type { HostedFile, HostedSessionResource, InstalledAsset, + LinkCodeMarketplaceConfig, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginId, LoopId, LoopInspection, LoopIteration, @@ -90,7 +93,11 @@ import type { SequencedAgentEvent } from './client/event-buffer'; import { EventBuffer } from './client/event-buffer'; import { LoopLogBuffer } from './client/loop-log-buffer'; import type { + LinkCodePluginConfigUpdate, + LinkCodePluginConfigView, + PluginConfigValue, PluginList, + PluginMarketRefresh, PluginMutation, RandomUUID, RequestAck, @@ -103,7 +110,16 @@ 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'; +export type { + LinkCodePluginConfigUpdate, + LinkCodePluginConfigView, + PluginConfigValue, + PluginList, + PluginMarketRefresh, + PluginMarketReleaseEntry, + PluginMutation, + SessionStartResult, +} from './client/pending-registry'; type EventCb = (event: AgentEvent, seq: number) => void; type TerminalOutputCb = (data: string) => void; @@ -420,6 +436,35 @@ export class LinkCodeClient { case 'skill.updated': this.pending.resolve('skillSetEnabled', p.replyTo, p.skill); break; + case 'plugin-market.listed': + this.pending.resolve('pluginMarketList', p.replyTo, p.marketplaces); + break; + case 'plugin-market.refreshed': + this.pending.resolve('pluginMarketRefresh', p.replyTo, { + marketplaceId: p.marketplaceId, + releases: p.releases, + ...(p.notModified === true && { notModified: true }), + }); + break; + case 'plugin-market.installed': + this.pending.resolve('pluginMarketInstall', p.replyTo, { + marketplaceId: p.marketplaceId, + pluginId: p.pluginId, + version: p.version, + }); + break; + case 'plugin-market.uninstalled': + this.pending.resolve('pluginMarketUninstall', p.replyTo, p.pluginId); + break; + case 'plugin-config.listed': + this.pending.resolve('pluginConfigList', p.replyTo, p.plugins); + break; + case 'plugin-config.updated': + this.pending.resolve('pluginConfigUpdate', p.replyTo, { + pluginId: p.pluginId, + values: p.values, + }); + break; case 'config.probe-models.result': this.pending.resolve('accountModels', p.replyTo, p.models); break; @@ -903,6 +948,42 @@ export class LinkCodeClient { return this.control.setSkillEnabled(params); } + /** The configured LinkCode marketplaces. */ + listPluginMarketplaces(): Promise { + return this.control.listPluginMarketplaces(); + } + + /** Refresh one marketplace index; `notModified` replies carry the cached catalog. */ + refreshPluginMarketplace(marketplaceId: string): Promise { + return this.control.refreshPluginMarketplace(marketplaceId); + } + + /** Install a marketplace release; resolves with the installed release identity. */ + installLinkCodePlugin( + release: LinkCodeMarketplaceReleaseIdentity, + ): Promise { + return this.control.installLinkCodePlugin(release); + } + + /** Uninstall a LinkCode plugin; resolves with its id. */ + uninstallLinkCodePlugin(pluginId: LinkCodePluginId): Promise { + return this.control.uninstallLinkCodePlugin(pluginId); + } + + /** Masked settings read for installed LinkCode plugins — secret values are never returned. */ + listLinkCodePluginConfigs(): Promise { + return this.control.listLinkCodePluginConfigs(); + } + + /** Per-key settings patch; resolves with the plugin's post-patch masked values. */ + setLinkCodePluginConfig(params: { + pluginId: LinkCodePluginId; + set?: Record; + remove?: string[]; + }): Promise { + return this.control.setLinkCodePluginConfig(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 73458efee..7349d9755 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -26,6 +26,9 @@ import type { HostedArtifact, HostedFile, HostedSessionResource, + LinkCodeMarketplaceConfig, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginId, LoopId, LoopInspection, LoopRecord, @@ -72,9 +75,13 @@ import type { import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { + LinkCodePluginConfigUpdate, + LinkCodePluginConfigView, PendingRegistry, PendingValueMap, + PluginConfigValue, PluginList, + PluginMarketRefresh, PluginMutation, RequestAck, SessionStartResult, @@ -536,6 +543,64 @@ export class ControlChannel { })); } + /** The configured LinkCode marketplaces (HTTPS indexes the daemon refreshes). */ + listPluginMarketplaces(): Promise { + return this.sendCorrelated('pluginMarketList', (clientReqId) => ({ + kind: 'plugin-market.list.get', + clientReqId, + })); + } + + /** Refresh one marketplace index; on a 304 the reply carries `notModified` and the cached releases. */ + refreshPluginMarketplace(marketplaceId: string): Promise { + return this.sendCorrelated('pluginMarketRefresh', (clientReqId) => ({ + kind: 'plugin-market.refresh', + clientReqId, + marketplaceId, + })); + } + + /** Install a marketplace release; resolves with the installed release identity. */ + installLinkCodePlugin( + release: LinkCodeMarketplaceReleaseIdentity, + ): Promise { + return this.sendCorrelated('pluginMarketInstall', (clientReqId) => ({ + kind: 'plugin-market.install', + clientReqId, + release, + })); + } + + /** Uninstall a LinkCode plugin; resolves with its id. */ + uninstallLinkCodePlugin(pluginId: LinkCodePluginId): Promise { + return this.sendCorrelated('pluginMarketUninstall', (clientReqId) => ({ + kind: 'plugin-market.uninstall', + clientReqId, + pluginId, + })); + } + + /** Masked settings read for installed LinkCode plugins — secret values are never returned. */ + listLinkCodePluginConfigs(): Promise { + return this.sendCorrelated('pluginConfigList', (clientReqId) => ({ + kind: 'plugin-config.list.get', + clientReqId, + })); + } + + /** Per-key settings patch: typed values set, listed keys removed; resolves with masked values. */ + setLinkCodePluginConfig(params: { + pluginId: LinkCodePluginId; + set?: Record; + remove?: string[]; + }): Promise { + return this.sendCorrelated('pluginConfigUpdate', (clientReqId) => ({ + kind: 'plugin-config.set', + 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 79e7c638e..dde4e53ee 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -15,6 +15,12 @@ import type { HostedArtifact, HostedFile, HostedSessionResource, + LinkCodeMarketplaceConfig, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginId, + LinkCodePluginRelease, + LinkCodePluginSettings, + LinkCodePluginVersion, LoopInspection, LoopRecord, ManagedAssetStatus, @@ -76,6 +82,38 @@ export interface PluginMutation { pendingAuthApps?: string[]; } +/** A stored LinkCode plugin setting value: the manifest's restricted JSON-Schema subset maps to + * these primitives. */ +export type PluginConfigValue = string | number | boolean; + +/** One entry of a `plugin-market.refreshed` reply: a catalog release keyed by plugin id. */ +export interface PluginMarketReleaseEntry { + pluginId: string; + release: LinkCodePluginRelease; +} + +/** The `plugin-market.refreshed` payload as one value; `notModified` (304) carries cached releases. */ +export interface PluginMarketRefresh { + marketplaceId: string; + releases: PluginMarketReleaseEntry[]; + notModified?: boolean; +} + +/** One row of `plugin-config.listed`: a plugin's settings field schemas plus its masked values — + * secret fields appear in `settings` but never in `values`. */ +export interface LinkCodePluginConfigView { + id: LinkCodePluginId; + version: LinkCodePluginVersion; + settings: LinkCodePluginSettings; + values: Record; +} + +/** The `plugin-config.updated` payload: the plugin's post-patch masked values. */ +export interface LinkCodePluginConfigUpdate { + pluginId: LinkCodePluginId; + values: Record; +} + export type RandomUUID = () => string; export function resolveRandomUUID(provider?: RandomUUID): RandomUUID { @@ -106,6 +144,12 @@ export interface PendingValueMap { pluginList: PluginList; pluginMutation: PluginMutation; skillSetEnabled: StandaloneSkill; + pluginMarketList: LinkCodeMarketplaceConfig[]; + pluginMarketRefresh: PluginMarketRefresh; + pluginMarketInstall: LinkCodeMarketplaceReleaseIdentity; + pluginMarketUninstall: LinkCodePluginId; + pluginConfigList: LinkCodePluginConfigView[]; + pluginConfigUpdate: LinkCodePluginConfigUpdate; agentRuntimeList: AgentRuntimes; agentCatalog: AgentStartCatalog; assetList: ManagedAssetStatus[]; @@ -168,6 +212,12 @@ export class PendingRegistry { pluginList: new Map(), pluginMutation: new Map(), skillSetEnabled: new Map(), + pluginMarketList: new Map(), + pluginMarketRefresh: new Map(), + pluginMarketInstall: new Map(), + pluginMarketUninstall: new Map(), + pluginConfigList: new Map(), + pluginConfigUpdate: new Map(), agentRuntimeList: new Map(), agentCatalog: new Map(), assetList: new Map(), diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 61d59728c..f5dff5db1 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -3,7 +3,11 @@ import type { AssetSettledEvent, HistoryListClientOptions, HistoryReadClientOptions, + LinkCodePluginConfigUpdate, + LinkCodePluginConfigView, + PluginConfigValue, PluginList, + PluginMarketRefresh, PluginMutation, SessionStartResult, } from '@linkcode/client-core'; @@ -34,6 +38,9 @@ import type { HostedArtifact, HostedFile, HostedSessionResource, + LinkCodeMarketplaceConfig, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginId, LoopId, LoopInspection, LoopRecord, @@ -349,6 +356,42 @@ export class LinkCodeSdkClient { return toResult(this.raw.setSkillEnabled(params)); } + /** The configured LinkCode marketplaces (HTTPS indexes the daemon refreshes). */ + listPluginMarketplaces(): RequestResult { + return toResult(this.raw.listPluginMarketplaces()); + } + + /** Refresh one marketplace index; `notModified` replies carry no releases. */ + refreshPluginMarketplace(marketplaceId: string): RequestResult { + return toResult(this.raw.refreshPluginMarketplace(marketplaceId)); + } + + /** Install a marketplace release; resolves with the installed release identity. */ + installLinkCodePlugin( + release: LinkCodeMarketplaceReleaseIdentity, + ): RequestResult { + return toResult(this.raw.installLinkCodePlugin(release)); + } + + /** Uninstall a LinkCode plugin; resolves with its id. */ + uninstallLinkCodePlugin(pluginId: LinkCodePluginId): RequestResult { + return toResult(this.raw.uninstallLinkCodePlugin(pluginId)); + } + + /** Masked settings read for installed LinkCode plugins — secret values are never returned. */ + listLinkCodePluginConfigs(): RequestResult { + return toResult(this.raw.listLinkCodePluginConfigs()); + } + + /** Per-key settings patch; resolves with the plugin's post-patch masked values. */ + setLinkCodePluginConfig(params: { + pluginId: LinkCodePluginId; + set?: Record; + remove?: string[]; + }): RequestResult { + return toResult(this.raw.setLinkCodePluginConfig(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 b9117f961..c8343ce14 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -1,7 +1,11 @@ import type { HistoryListClientOptions, HistoryReadClientOptions, + LinkCodePluginConfigUpdate, + LinkCodePluginConfigView, + PluginConfigValue, PluginList, + PluginMarketRefresh, PluginMutation, SessionStartResult, } from '@linkcode/client-core'; @@ -31,6 +35,9 @@ import type { HostedArtifact, HostedFile, HostedSessionResource, + LinkCodeMarketplaceConfig, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginId, LoopId, LoopInspection, LoopRecord, @@ -341,6 +348,56 @@ export function setSkillEnabled( return resolveClient(options).setSkillEnabled({ provider, skillId, path, scope, enabled, cwd }); } +/** The configured LinkCode marketplaces (HTTPS indexes the daemon refreshes with ETag). */ +export function getPluginMarketplaces( + options?: Options, +): RequestResult { + return resolveClient(options).listPluginMarketplaces(); +} + +/** Refresh one marketplace index. A `notModified` reply carries no releases — the caller keeps + * the catalog it already has. */ +export function refreshPluginMarketplace( + options: Options<{ marketplaceId: string }>, +): RequestResult { + return resolveClient(options).refreshPluginMarketplace(options.marketplaceId); +} + +/** Install a marketplace release; resolves with the installed release identity. */ +export function installLinkCodePlugin( + options: Options<{ release: LinkCodeMarketplaceReleaseIdentity }>, +): RequestResult { + return resolveClient(options).installLinkCodePlugin(options.release); +} + +/** Uninstall a LinkCode plugin; resolves with its id. */ +export function uninstallLinkCodePlugin( + options: Options<{ pluginId: LinkCodePluginId }>, +): RequestResult { + return resolveClient(options).uninstallLinkCodePlugin(options.pluginId); +} + +/** Masked settings read for installed LinkCode plugins: field schemas plus non-secret values. */ +export function getLinkCodePluginConfigs( + options?: Options, +): RequestResult { + return resolveClient(options).listLinkCodePluginConfigs(); +} + +/** Per-key settings patch (`set` upserts typed values, `remove` deletes keys); resolves with the + * plugin's post-patch masked values so callers patch one cache entry. Secret fields write to the + * daemon vault and never come back. */ +export function setLinkCodePluginConfig( + options: Options<{ + pluginId: LinkCodePluginId; + set?: Record; + remove?: string[]; + }>, +): RequestResult { + const { pluginId, set, remove } = options; + return resolveClient(options).setLinkCodePluginConfig({ pluginId, set, remove }); +} + /** 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/mock/data/linkcode-marketplace.ts b/packages/client/workbench/src/mock/data/linkcode-marketplace.ts new file mode 100644 index 000000000..f457ef0da --- /dev/null +++ b/packages/client/workbench/src/mock/data/linkcode-marketplace.ts @@ -0,0 +1,115 @@ +import type { LinkCodeMarketplaceConfig, LinkCodePluginRelease } from '@linkcode/schema'; + +/** The mock LinkCode marketplace: one configured index whose catalog the mock "refresh" serves. */ +export const SEED_LINKCODE_MARKETPLACES: LinkCodeMarketplaceConfig[] = [ + { + id: 'linkcode-official', + displayName: 'LinkCode Official', + source: { type: 'remote', url: 'https://plugins.linkcode.ai/index.json' }, + enabled: true, + }, +]; + +export interface MockLinkCodeCatalogEntry { + pluginId: string; + release: LinkCodePluginRelease; +} + +/** Catalog the mock serves for `plugin-market.refresh`: a settings-bearing MCP plugin (the mail + * plugin's real env surface as manifest settings) and a skill-only plugin with nothing to configure. */ +export const SEED_LINKCODE_RELEASES: MockLinkCodeCatalogEntry[] = [ + { + pluginId: 'linkcode/mail', + release: { + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '1.0.0', + displayName: 'Mail (163 / QQ)', + description: 'Receive and send 163/QQ mail over IMAP + SMTP via an MCP server.', + keywords: ['mail', '163', 'qq', 'imap', 'smtp'], + components: [ + { + kind: 'mcp-server', + name: 'mail', + description: 'Mail tools: list/search/read/send messages', + command: 'node', + entry: 'dist/index.js', + env: { + MAIL_USER: 'account', + MAIL_PASSWORD: 'password', + MAIL_PRESET: 'preset', + MAX_BODY_CHARS: 'maxBodyChars', + }, + }, + ], + settings: { + account: { + type: 'string', + label: 'Account', + description: 'Full email address, e.g. you@163.com', + required: true, + }, + password: { + type: 'password', + label: 'Authorization code', + description: 'The IMAP/SMTP authorization code from the mailbox settings page', + secret: true, + required: true, + }, + preset: { + type: 'enum', + label: 'Provider preset', + enum: ['163', 'qq', 'exmail'], + default: '163', + }, + maxBodyChars: { + type: 'number', + label: 'Max body characters', + default: 8000, + }, + readonly: { + type: 'boolean', + label: 'Read-only', + description: 'Expose read tools only; never send or modify mail', + default: false, + }, + }, + assets: [], + }, + artifact: { + urls: ['plugins/mail-1.0.0.tgz'], + integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + format: 'tgz', + }, + publishedAt: '2026-08-01T00:00:00Z', + }, + }, + { + pluginId: 'linkcode/notes', + release: { + manifest: { + manifestVersion: 1, + id: 'linkcode/notes', + version: '0.2.0', + displayName: 'Notes', + description: 'A skill-only plugin with no configurable settings.', + keywords: ['notes'], + components: [ + { + kind: 'skill', + name: 'notes', + description: 'Capture and search notes', + entry: 'skills/notes/SKILL.md', + }, + ], + assets: [], + }, + artifact: { + urls: ['plugins/notes-0.2.0.tgz'], + integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + format: 'tgz', + }, + }, + }, +]; diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 5d9e7111d..71a8c498a 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -11,6 +11,7 @@ import type { CustomMcpServerPatchOp, CustomMcpServerPublic, EffortLevel, + LinkCodePluginSettings, ManagedAssetId, ManagedAssetKey, ManagedAssetStatus, @@ -51,6 +52,7 @@ import { MOCK_COMMAND_CATALOG, mockCommandFixture } from './data/commands'; import { MOCK_WORKSPACE_FILES, mockFileFixture } from './data/files'; import { gitFixtureFor } from './data/git'; import { SEED_HISTORY } from './data/history'; +import { SEED_LINKCODE_MARKETPLACES, SEED_LINKCODE_RELEASES } from './data/linkcode-marketplace'; import { createLongThreadScript } from './data/long-thread'; import { SEED_MODEL_CATALOGS } from './data/models'; import { SEED_PLUGIN_PROVIDER_STATUS, SEED_PLUGINS, SEED_STANDALONE_SKILLS } from './data/plugins'; @@ -192,6 +194,31 @@ export class DevMockHost { private customMcpServers: CustomMcpServer[] = []; private readonly plugins: Plugin[] = structuredClone(SEED_PLUGINS); private readonly standaloneSkills: StandaloneSkill[] = structuredClone(SEED_STANDALONE_SKILLS); + /** Installed LinkCode plugins with their full settings (secrets included, like the daemon's + * vault); `plugin-config.*` serves the masked projection. */ + private readonly linkCodeInstalled = new Map< + string, + { + marketplaceId: string; + version: string; + values: Record; + } + >([ + [ + 'linkcode/mail', + { + marketplaceId: 'linkcode-official', + version: '1.0.0', + values: { + account: 'you@163.com', + password: 'mock-authorization-code', + preset: '163', + maxBodyChars: 8000, + readonly: false, + }, + }, + ], + ]); private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -480,6 +507,105 @@ export class DevMockHost { }); break; } + case 'plugin-market.list.get': + await wait(CONTROL_LATENCY_MS); + this.send({ + kind: 'plugin-market.listed', + replyTo: p.clientReqId, + marketplaces: SEED_LINKCODE_MARKETPLACES, + }); + break; + case 'plugin-market.refresh': { + await wait(CONTROL_LATENCY_MS); + if (!SEED_LINKCODE_MARKETPLACES.some((market) => market.id === p.marketplaceId)) { + this.sendFailure(p.clientReqId, `Unknown marketplace: ${p.marketplaceId}`); + break; + } + this.send({ + kind: 'plugin-market.refreshed', + replyTo: p.clientReqId, + marketplaceId: p.marketplaceId, + releases: SEED_LINKCODE_RELEASES, + }); + break; + } + case 'plugin-market.install': { + await wait(CONTROL_LATENCY_MS); + const known = SEED_LINKCODE_RELEASES.some( + (candidate) => + candidate.pluginId === p.release.pluginId && + candidate.release.manifest.version === p.release.version, + ); + if (!known) { + this.sendFailure( + p.clientReqId, + `Unknown marketplace release: ${p.release.pluginId}@${p.release.version}`, + ); + break; + } + this.linkCodeInstalled.set(p.release.pluginId, { + marketplaceId: p.release.marketplaceId, + version: p.release.version, + values: {}, + }); + this.send({ + kind: 'plugin-market.installed', + replyTo: p.clientReqId, + marketplaceId: p.release.marketplaceId, + pluginId: p.release.pluginId, + version: p.release.version, + }); + break; + } + case 'plugin-market.uninstall': { + await wait(CONTROL_LATENCY_MS); + if (!this.linkCodeInstalled.delete(p.pluginId)) { + this.sendFailure(p.clientReqId, `Unknown plugin: ${p.pluginId}`); + break; + } + this.send({ + kind: 'plugin-market.uninstalled', + replyTo: p.clientReqId, + pluginId: p.pluginId, + }); + break; + } + case 'plugin-config.list.get': { + await wait(CONTROL_LATENCY_MS); + this.send({ + kind: 'plugin-config.listed', + replyTo: p.clientReqId, + plugins: this.linkCodeConfigViews(), + }); + break; + } + case 'plugin-config.set': { + await wait(CONTROL_LATENCY_MS); + const installed = this.linkCodeInstalled.get(p.pluginId); + const settings = SEED_LINKCODE_RELEASES.find( + (candidate) => candidate.pluginId === p.pluginId, + )?.release.manifest.settings; + if (installed === undefined || settings === undefined) { + this.sendFailure(p.clientReqId, `Unknown plugin: ${p.pluginId}`); + break; + } + if (p.remove) { + const removed = new Set(p.remove); + installed.values = Object.fromEntries( + Object.entries(installed.values).filter(([key]) => !removed.has(key)), + ); + } + if (p.set) { + for (const [key, value] of Object.entries(p.set)) installed.values[key] = value; + } + this.send({ + kind: 'plugin-config.updated', + replyTo: p.clientReqId, + pluginId: p.pluginId, + values: maskLinkCodePluginValues(settings, installed.values), + }); + break; + } case 'workspace.list': await wait(CONTROL_LATENCY_MS); this.send({ @@ -1667,6 +1793,29 @@ export class DevMockHost { this.send({ kind: 'request.failed', replyTo, message, ...reporting }); } + /** The masked `plugin-config.listed` projection: only installed plugins whose manifest declares + * settings, secret values omitted — mirrors the daemon's PluginConfigService. */ + private linkCodeConfigViews(): Array<{ + id: string; + version: string; + settings: LinkCodePluginSettings; + values: Record; + }> { + const views = []; + for (const [pluginId, installed] of this.linkCodeInstalled) { + const seed = SEED_LINKCODE_RELEASES.find((candidate) => candidate.pluginId === pluginId); + const settings = seed?.release.manifest.settings; + if (settings === undefined) continue; + views.push({ + id: pluginId, + version: installed.version, + settings, + values: maskLinkCodePluginValues(settings, installed.values), + }); + } + return views; + } + private nextSessionId(): SessionId { this.sessionSeq += 1; return `mock-sess-${Date.now().toString(36)}-${this.sessionSeq.toString(36)}` as SessionId; @@ -1727,6 +1876,19 @@ function lastPathSegment(cwd: string): string { return cwd.split(PATH_SEPARATORS_RE).findLast((part) => part.length > 0) ?? cwd; } +/** Mirror of the daemon's masked plugin-config projection: secret fields never reach the client. */ +function maskLinkCodePluginValues( + settings: LinkCodePluginSettings, + values: Readonly>, +): Record { + const masked: Record = {}; + for (const [fieldId, field] of Object.entries(settings)) { + if (field.secret) continue; + if (fieldId in values) masked[fieldId] = values[fieldId]; + } + return masked; +} + /** Mirror of the daemon's masked projection: env/header values never reach the client. */ function maskCustomMcpServer(entry: CustomMcpServer): CustomMcpServerPublic { const { server } = entry; diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx new file mode 100644 index 000000000..6cd27f433 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import type { LinkCodePluginSettings } from '@linkcode/schema'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { LinkCodePluginConfigDialog } from '../linkcode-config-dialog'; + +function translateKey(key: string, params?: Record): string { + return params === undefined ? key : `${key}:${Object.values(params).join(',')}`; +} + +vi.mock('use-intl', () => ({ + useLocale: () => 'en', + useTranslations: () => translateKey, +})); + +afterEach(cleanup); + +const SETTINGS: LinkCodePluginSettings = { + account: { + type: 'string', + label: 'Account', + description: 'Full email address', + required: true, + }, + password: { + type: 'password', + label: 'Authorization code', + secret: true, + required: true, + }, + preset: { type: 'enum', label: 'Provider preset', enum: ['163', 'qq'], default: '163' }, + maxBodyChars: { type: 'number', label: 'Max body characters', default: 8000 }, + readonly: { type: 'boolean', label: 'Read-only', default: false }, +}; + +function renderDialog( + overrides: Partial> = {}, +) { + const onSubmit = vi.fn(); + render( + , + ); + return { onSubmit }; +} + +describe('LinkCodePluginConfigDialog', () => { + it('renders one control per declared field, secrets masked', () => { + renderDialog(); + expect(screen.getByText('Account')).toBeDefined(); + expect(screen.getByText('Authorization code')).toBeDefined(); + expect(screen.getByText('Provider preset')).toBeDefined(); + expect(screen.getByText('Max body characters')).toBeDefined(); + expect(screen.getByText('Read-only')).toBeDefined(); + + const password = screen.getByPlaceholderText('form.secretPlaceholder'); + expect(password.getAttribute('type')).toBe('password'); + // The masked read never returns secrets, so the input starts blank. + expect((password as HTMLInputElement).value).toBe(''); + }); + + it('prefills non-secret values and manifest defaults', () => { + renderDialog({ values: { account: 'you@163.com', readonly: true } }); + expect(screen.getByLabelText('Account').value).toBe('you@163.com'); + expect(screen.getByLabelText('Max body characters').value).toBe('8000'); + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true'); + }); + + it('submits a typed per-key patch, keeping blank secrets out of it', async () => { + const { onSubmit } = renderDialog({ values: { account: 'old@163.com' } }); + fireEvent.change(screen.getByLabelText('Account'), { target: { value: 'new@163.com' } }); + fireEvent.change(screen.getByLabelText('Max body characters'), { target: { value: '4000' } }); + fireEvent.click(screen.getByRole('switch')); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit).toHaveBeenCalledWith({ + set: { + account: 'new@163.com', + preset: '163', + maxBodyChars: 4000, + readonly: true, + }, + }); + }); + + it('blocks submit on a blank required field', async () => { + const { onSubmit } = renderDialog(); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + await waitFor(() => expect(screen.getByText('form.required')).toBeDefined()); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts new file mode 100644 index 000000000..d2cae2f13 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts @@ -0,0 +1,126 @@ +import type { LinkCodePluginSettings } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { + buildPluginConfigPatch, + pluginConfigDefaults, + validatePluginConfigField, +} from '../linkcode-config'; + +const SETTINGS: LinkCodePluginSettings = { + account: { type: 'string', required: true }, + password: { type: 'password', secret: true, required: true }, + preset: { type: 'enum', enum: ['163', 'qq'], default: '163' }, + nickname: { type: 'string' }, + maxBodyChars: { type: 'number', default: 8000 }, + readonly: { type: 'boolean', default: false }, +}; + +describe('pluginConfigDefaults', () => { + it('prefers stored values, then manifest defaults, then type defaults', () => { + expect( + pluginConfigDefaults(SETTINGS, { + account: 'you@163.com', + maxBodyChars: 4000, + readonly: true, + }), + ).toEqual({ + account: 'you@163.com', + password: '', + preset: '163', + nickname: '', + maxBodyChars: '4000', + readonly: true, + }); + }); + + it('always starts secret fields blank — the masked read never returns them', () => { + expect(pluginConfigDefaults(SETTINGS, { password: 'never-sent' }).password).toBe(''); + }); +}); + +describe('validatePluginConfigField', () => { + it('rejects a blank required non-secret field', () => { + expect(validatePluginConfigField(SETTINGS.account, '')).toBe('required'); + expect(validatePluginConfigField(SETTINGS.account, 'you@163.com')).toBe(true); + }); + + it('never rejects a blank secret — blank means keep the stored value', () => { + expect(validatePluginConfigField(SETTINGS.password, '')).toBe(true); + }); + + it('rejects a non-numeric number field, blank optional number passes', () => { + expect(validatePluginConfigField(SETTINGS.maxBodyChars, 'abc')).toBe('invalidNumber'); + expect(validatePluginConfigField(SETTINGS.maxBodyChars, '42')).toBe(true); + expect(validatePluginConfigField(SETTINGS.maxBodyChars, '')).toBe(true); + }); + + it('always passes a boolean', () => { + expect(validatePluginConfigField(SETTINGS.readonly, false)).toBe(true); + }); +}); + +describe('buildPluginConfigPatch', () => { + it('converts types: numbers to numbers, booleans stay boolean, strings stay strings', () => { + const patch = buildPluginConfigPatch( + SETTINGS, + {}, + { + account: 'you@163.com', + password: 'secret', + preset: 'qq', + nickname: '', + maxBodyChars: '4000', + readonly: true, + }, + ); + expect(patch.set).toEqual({ + account: 'you@163.com', + password: 'secret', + preset: 'qq', + maxBodyChars: 4000, + readonly: true, + }); + expect(patch.remove).toBeUndefined(); + }); + + it('keeps a blank secret out of the patch (blank = keep the stored value)', () => { + const patch = buildPluginConfigPatch( + SETTINGS, + {}, + { + account: 'you@163.com', + password: '', + preset: '163', + nickname: '', + maxBodyChars: '8000', + readonly: false, + }, + ); + expect(patch.set).not.toHaveProperty('password'); + }); + + it('removes a cleared optional field only when it had a stored value', () => { + const withStored = buildPluginConfigPatch( + SETTINGS, + { nickname: 'old' }, + { + ...pluginConfigDefaults(SETTINGS, { nickname: '' }), + }, + ); + expect(withStored.remove).toEqual(['nickname']); + + const withoutStored = buildPluginConfigPatch( + SETTINGS, + {}, + { + ...pluginConfigDefaults(SETTINGS, {}), + }, + ); + expect(withoutStored.remove).toBeUndefined(); + }); + + it('omits both sides of an empty patch', () => { + const patch = buildPluginConfigPatch({ nickname: { type: 'string' } }, {}, { nickname: '' }); + expect(patch).toEqual({}); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts index 2af413682..8b562563d 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -2,7 +2,10 @@ import type { PluginList } from '@linkcode/client-core'; import type { Plugin } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import { + filterLinkCodeCatalogCards, filterPluginCards, + linkcodeCatalogCard, + linkcodeInstalledRow, pluginCardView, pluginMcpServerRows, pluginProviderGroups, @@ -272,3 +275,101 @@ describe('pluginMcpServerRows', () => { ]); }); }); + +describe('linkcodeCatalogCard', () => { + it('projects a marketplace release entry to a catalog card with install state', () => { + const card = linkcodeCatalogCard( + 'linkcode-official', + { + pluginId: 'linkcode/mail', + release: { + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '1.0.0', + displayName: 'Mail (163 / QQ)', + description: 'Receive and send mail.', + keywords: ['mail'], + components: [ + { kind: 'mcp-server', name: 'mail', command: 'npx', env: { MAIL_USER: 'account' } }, + ], + settings: { account: { type: 'string', required: true } }, + assets: [], + }, + artifact: { + urls: ['plugins/mail-1.0.0.tgz'], + integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + format: 'tgz', + }, + }, + }, + true, + ); + + expect(card).toMatchObject({ + key: 'linkcode-official:linkcode/mail', + marketplaceId: 'linkcode-official', + pluginId: 'linkcode/mail', + version: '1.0.0', + title: 'Mail (163 / QQ)', + installed: true, + }); + expect(card.searchText).toContain('linkcode/mail'); + }); +}); + +describe('linkcodeInstalledRow', () => { + it('derives the title from the id and flags settings-bearing plugins', () => { + expect( + linkcodeInstalledRow({ + id: 'linkcode/mail', + version: '1.0.0', + settings: { account: { type: 'string' } }, + values: {}, + }), + ).toEqual({ + key: 'linkcode/mail', + pluginId: 'linkcode/mail', + title: 'mail', + version: '1.0.0', + hasSettings: true, + }); + expect( + linkcodeInstalledRow({ id: 'linkcode/notes', version: '0.2.0', settings: {}, values: {} }) + .hasSettings, + ).toBe(false); + }); +}); + +describe('filterLinkCodeCatalogCards', () => { + it('filters by the precomputed haystack, blank query keeps all', () => { + const cards = [ + linkcodeCatalogCard( + 'linkcode-official', + { + pluginId: 'linkcode/mail', + release: { + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '1.0.0', + displayName: 'Mail', + keywords: [], + components: [{ kind: 'mcp-server', name: 'mail', command: 'npx' }], + assets: [], + }, + artifact: { + urls: ['plugins/mail-1.0.0.tgz'], + integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + format: 'tgz', + }, + }, + }, + false, + ), + ]; + expect(filterLinkCodeCatalogCards(cards, '')).toHaveLength(1); + expect(filterLinkCodeCatalogCards(cards, 'mail')).toHaveLength(1); + expect(filterLinkCodeCatalogCards(cards, 'zzz')).toHaveLength(0); + }); +}); diff --git a/packages/client/workbench/src/settings/plugins/hooks.ts b/packages/client/workbench/src/settings/plugins/hooks.ts index d17955772..c51c40666 100644 --- a/packages/client/workbench/src/settings/plugins/hooks.ts +++ b/packages/client/workbench/src/settings/plugins/hooks.ts @@ -1,10 +1,16 @@ import { getCustomMcpServers, + getLinkCodePluginConfigs, + getPluginMarketplaces, getPlugins, + installLinkCodePlugin, installPlugin, + refreshPluginMarketplace, setCustomMcpServers, + setLinkCodePluginConfig, setPluginEnabled, setSkillEnabled, + uninstallLinkCodePlugin, uninstallPlugin, } from '@linkcode/sdk'; import { toastManager } from 'coss-ui/components/toast'; @@ -62,3 +68,38 @@ export function useCustomMcpServers() { export function useSetCustomMcpServers() { return useMutation(setCustomMcpServers, { onError: useMutationError() }); } + +/** The configured LinkCode marketplaces; cheap config read. */ +export function usePluginMarketplaces() { + return useData(getPluginMarketplaces, {}); +} + +/** One marketplace's catalog. Refresh is a network read on the daemon (ETag-deduped), so it + * auto-loads on mount but never revalidates on focus/reconnect — the section's refresh button + * re-runs it via `mutate`. */ +export function usePluginMarketCatalog(marketplaceId: string) { + return useData( + refreshPluginMarketplace, + { marketplaceId }, + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ); +} + +/** Masked settings read for installed LinkCode plugins; a local file read, cheap to revalidate. */ +export function useLinkCodePluginConfigs() { + return useData(getLinkCodePluginConfigs, {}); +} + +/** Install/uninstall are real network + disk operations on the daemon — explicit user actions + * with no optimistic UI. */ +export function useInstallLinkCodePlugin() { + return useMutation(installLinkCodePlugin, { onError: useMutationError() }); +} + +export function useUninstallLinkCodePlugin() { + return useMutation(uninstallLinkCodePlugin, { onError: useMutationError() }); +} + +export function useSetLinkCodePluginConfig() { + return useMutation(setLinkCodePluginConfig, { onError: useMutationError() }); +} diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx new file mode 100644 index 000000000..6c19725d4 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx @@ -0,0 +1,198 @@ +import type { PluginConfigValue } from '@linkcode/client-core'; +import type { LinkCodePluginSettingField, LinkCodePluginSettings } from '@linkcode/schema'; +import { Button } from 'coss-ui/components/button'; +import { + Dialog, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from 'coss-ui/components/dialog'; +import { Field, FieldDescription, FieldError, FieldLabel } from 'coss-ui/components/field'; +import { Form } from 'coss-ui/components/form'; +import { Input } from 'coss-ui/components/input'; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from 'coss-ui/components/select'; +import { Switch } from 'coss-ui/components/switch'; +import type { Control, UseFormRegister } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslations } from 'use-intl'; +import { rhfErrorsToFormErrors } from '../../lib/form'; +import type { PluginConfigFormValues } from './linkcode-config'; +import { + buildPluginConfigPatch, + pluginConfigDefaults, + validatePluginConfigField, +} from './linkcode-config'; + +export interface LinkCodePluginConfigPatch { + set?: Record; + remove?: string[]; +} + +export interface LinkCodePluginConfigDialogProps { + /** Display name used in the dialog title. */ + title: string; + /** The manifest's declared settings — the form renders one control per field. */ + settings: LinkCodePluginSettings; + /** The masked read: non-secret values only; secret fields arrive absent and render blank. */ + values: Readonly>; + busy: boolean; + onClose: () => void; + onSubmit: (patch: LinkCodePluginConfigPatch) => void; +} + +/** The manifest-driven settings form: no zod schema exists for a plugin-declared field set, so + * per-field `validate` rules stand in for `zodResolver`. */ +export function LinkCodePluginConfigDialog({ + title, + settings, + values, + busy, + onClose, + onSubmit, +}: LinkCodePluginConfigDialogProps): React.ReactNode { + const t = useTranslations('settings.plugins.linkcode'); + const { + control, + register, + handleSubmit, + formState: { errors }, + } = useForm({ defaultValues: pluginConfigDefaults(settings, values) }); + + const submit = handleSubmit((form) => onSubmit(buildPluginConfigPatch(settings, values, form))); + + return ( + open || onClose()}> + + + {t('settingsTitle', { title })} + + +
+ {Object.entries(settings).map(([fieldId, field]) => ( + + ))} +
+ + +
+ +
+
+
+ ); +} + +function ConfigField({ + fieldId, + field, + control, + register, + busy, +}: { + fieldId: string; + field: LinkCodePluginSettingField; + control: Control; + register: UseFormRegister; + busy: boolean; +}): React.ReactNode { + const t = useTranslations('settings.plugins.linkcode'); + const label = field.label ?? fieldId; + + if (field.type === 'boolean') { + // Kept out of : a bare Switch is not a Field control, and base-ui's Fieldset does not + // propagate disabled — pass it explicitly. + return ( +
+
+ {label} + {field.description === undefined ? null : ( + {field.description} + )} +
+ ( + switchField.onChange(checked)} + /> + )} + /> +
+ ); + } + + const validate = (raw: string | boolean): true | string => { + const result = validatePluginConfigField(field, raw); + return result === true ? true : t(`form.${result}`); + }; + + return ( + + {label} + {field.type === 'enum' ? ( + ( + + )} + /> + ) : ( + + )} + {field.description === undefined ? null : ( + {field.description} + )} + + + ); +} diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config.ts b/packages/client/workbench/src/settings/plugins/linkcode-config.ts new file mode 100644 index 000000000..0c7d73f7c --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/linkcode-config.ts @@ -0,0 +1,96 @@ +import type { PluginConfigValue } from '@linkcode/client-core'; +import type { LinkCodePluginSettingField, LinkCodePluginSettings } from '@linkcode/schema'; +import { isObjectEmpty } from 'foxts/is-object-empty'; + +/** Pure helpers for the manifest-driven plugin settings form. No React, no I/O. */ + +/** + * Form value shape: every control edits a string except `boolean` (a switch). Conversion to the + * typed wire value happens in {@link buildPluginConfigPatch}. Secret (`password`) fields always + * start blank — the masked read never returns them, and blank means "keep the stored value". + */ +export type PluginConfigFormValues = Record; + +/** Validation outcome tokens the form maps to translated messages. */ +export type PluginConfigFieldError = 'required' | 'invalidNumber'; + +export function pluginConfigDefaults( + settings: LinkCodePluginSettings, + values: Readonly>, +): PluginConfigFormValues { + const defaults: PluginConfigFormValues = {}; + for (const [fieldId, field] of Object.entries(settings)) { + const stored = values[fieldId]; + if (field.type === 'boolean') { + defaults[fieldId] = + typeof stored === 'boolean' + ? stored + : typeof field.default === 'boolean' + ? field.default + : false; + continue; + } + if (field.secret) { + // Secret values never arrive over the wire; the blank input carries "keep as-is". + defaults[fieldId] = ''; + continue; + } + // `in` over indexed access: the masked read may omit keys the index-signature type claims exist. + if (fieldId in values) { + defaults[fieldId] = String(values[fieldId]); + continue; + } + defaults[fieldId] = field.default === undefined ? '' : String(field.default); + } + return defaults; +} + +/** Validate one raw form value against its declared field; `true` passes. */ +export function validatePluginConfigField( + field: LinkCodePluginSettingField, + raw: string | boolean, +): true | PluginConfigFieldError { + if (field.type === 'boolean') return true; + const value = typeof raw === 'string' ? raw : String(raw); + if (value === '') { + // A blank secret is "keep the stored value", never an error. + return field.required === true && !field.secret ? 'required' : true; + } + if (field.type === 'number' && Number.isNaN(Number(value))) return 'invalidNumber'; + return true; +} + +/** + * Per-key patch for `plugin-config.set`, mirroring the custom-MCP masked-edit contract: + * + * - `boolean` switches always write (they have a complete value). + * - `password` fields write only when the user typed something; blank keeps the stored secret. + * - `string` / `enum` / `number` write their typed value, or remove the key when cleared — + * but only if the key had a stored value (removing an absent key would be noise). + */ +export function buildPluginConfigPatch( + settings: LinkCodePluginSettings, + values: Readonly>, + form: PluginConfigFormValues, +): { set?: Record; remove?: string[] } { + const set: Record = {}; + const remove: string[] = []; + for (const [fieldId, field] of Object.entries(settings)) { + if (!(fieldId in form)) continue; + const raw = form[fieldId]; + if (field.type === 'boolean') { + set[fieldId] = raw === true; + continue; + } + const value = typeof raw === 'string' ? raw : String(raw); + if (value === '') { + if (!field.secret && fieldId in values) remove.push(fieldId); + continue; + } + set[fieldId] = field.type === 'number' ? Number(value) : value; + } + return { + ...(!isObjectEmpty(set) && { set }), + ...(remove.length > 0 && { remove }), + }; +} diff --git a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx new file mode 100644 index 000000000..2a49dea31 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx @@ -0,0 +1,174 @@ +import type { PluginMarketRefresh } from '@linkcode/client-core'; +import type { LinkCodeMarketplaceConfig, LinkCodePluginId } from '@linkcode/schema'; +import { refreshPluginMarketplace } from '@linkcode/sdk'; +import type { LinkCodeCatalogCardView, LinkCodeInstalledPluginRow } from '@linkcode/ui'; +import { LinkCodeCatalogSection, LinkCodeInstalledSection } from '@linkcode/ui'; +import { Card } from 'coss-ui/components/card'; +import { noop } from 'foxts/noop'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; +import { + useInstallLinkCodePlugin, + useLinkCodePluginConfigs, + usePluginMarketCatalog, + usePluginMarketplaces, + useSetLinkCodePluginConfig, + useUninstallLinkCodePlugin, +} from './hooks'; +import type { LinkCodePluginConfigPatch } from './linkcode-config-dialog'; +import { LinkCodePluginConfigDialog } from './linkcode-config-dialog'; +import { filterLinkCodeCatalogCards, linkcodeCatalogCard, linkcodeInstalledRow } from './view'; + +export interface LinkCodeMarketTabProps { + searchQuery: string; +} + +/** + * The LinkCode marketplace tab: installed plugins with their manifest-driven settings on top, + * each configured marketplace's catalog below. Installed state comes from the masked + * `plugin-config.listed` read; the catalog from `plugin-market.refresh` (the daemon is the only + * networked leg). + */ +export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): React.ReactNode { + const t = useTranslations('settings.plugins.linkcode'); + const { data: marketplaces } = usePluginMarketplaces(); + const { data: configs, mutate: mutateConfigs } = useLinkCodePluginConfigs(); + const install = useInstallLinkCodePlugin(); + const uninstall = useUninstallLinkCodePlugin(); + const save = useSetLinkCodePluginConfig(); + const [configuring, setConfiguring] = useState(null); + + const installedIds = new Set((configs ?? []).map((view) => view.id)); + const rows = configs?.map(linkcodeInstalledRow); + const configById = new Map((configs ?? []).map((view) => [view.id, view])); + const editing = configuring === null ? undefined : configById.get(configuring); + const busy = install.isMutating || uninstall.isMutating; + + const onInstall = async (card: LinkCodeCatalogCardView): Promise => { + await install.trigger({ + release: { + marketplaceId: card.marketplaceId, + pluginId: card.pluginId, + version: card.version, + }, + }); + await mutateConfigs(); + }; + + const onUninstall = async (row: LinkCodeInstalledPluginRow): Promise => { + await uninstall.trigger({ pluginId: row.pluginId }); + await mutateConfigs(); + }; + + const onSubmitConfig = async (patch: LinkCodePluginConfigPatch): Promise => { + if (editing === undefined) return; + const result = await save.trigger({ pluginId: editing.id, ...patch }); + // Fold the post-patch masked values into the cache instead of re-listing. + await mutateConfigs( + (current) => + current?.map((view) => + view.id === result.pluginId ? { ...view, values: result.values } : view, + ), + { revalidate: false }, + ); + setConfiguring(null); + }; + + return ( +
+

{t('hint')}

+ setConfiguring(row.pluginId)} + onUninstall={(row) => { + void onUninstall(row).catch(noop); + }} + /> + {marketplaces === undefined ? null : marketplaces.length === 0 ? ( + +

{t('noMarketplaces')}

+
+ ) : ( + marketplaces.map((marketplace) => ( + { + void onInstall(card).catch(noop); + }} + /> + )) + )} + {editing === undefined ? null : ( + setConfiguring(null)} + onSubmit={(patch) => { + void onSubmitConfig(patch).catch(noop); + }} + /> + )} +
+ ); +} + +function MarketplaceCatalog({ + marketplace, + installedIds, + searchQuery, + busy, + onInstall, +}: { + marketplace: LinkCodeMarketplaceConfig; + installedIds: ReadonlySet; + searchQuery: string; + busy: boolean; + onInstall: (card: LinkCodeCatalogCardView) => void; +}): React.ReactNode { + const { data, isLoading, isValidating, mutate } = usePluginMarketCatalog(marketplace.id); + + const cards = + data === undefined + ? undefined + : filterLinkCodeCatalogCards( + data.releases.map((entry) => + linkcodeCatalogCard(marketplace.id, entry, installedIds.has(entry.pluginId)), + ), + searchQuery, + ); + + // Keep this defensive merge for older daemons that still return an empty 304 payload. + const onRefresh = (): void => { + void mutate( + async (current): Promise => { + const { data: next } = await refreshPluginMarketplace({ + marketplaceId: marketplace.id, + }); + if (current !== undefined && next.notModified === true) { + return { ...next, releases: current.releases }; + } + return next; + }, + { revalidate: false }, + ).catch(noop); + }; + + return ( + + ); +} diff --git a/packages/client/workbench/src/settings/plugins/mcp-settings.tsx b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx index b955aca70..bc1e0dc81 100644 --- a/packages/client/workbench/src/settings/plugins/mcp-settings.tsx +++ b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx @@ -14,6 +14,13 @@ 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 { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from 'coss-ui/components/select'; import { Textarea } from 'coss-ui/components/textarea'; import { noop } from 'foxts/noop'; import { Trash2Icon, UndoIcon } from 'lucide-react'; @@ -59,6 +66,28 @@ type McpForm = z.infer; type DialogState = { mode: 'closed' } | { mode: 'add' } | { mode: 'edit'; id: string }; +/** Email server templates that prefill the stdio form for 163 / QQ / exmail. */ +const MAIL_TEMPLATES = [ + { value: '163', preset: '163', nameKey: 'template163' }, + { value: 'qq', preset: 'qq', nameKey: 'templateQq' }, + { value: 'exmail', preset: 'exmail', nameKey: 'templateExmail' }, +] as const; + +function mailTemplateForm(preset: string, name: string): McpForm { + return { + name, + transport: 'stdio', + command: 'npx', + args: '-y\n@linkcode/mail-mcp', + url: '', + secrets: [ + { key: 'MAIL_USER', value: '', remove: false }, + { key: 'MAIL_PASSWORD', value: '', remove: false }, + { key: 'MAIL_PRESET', value: preset, remove: false }, + ], + }; +} + export interface McpTabProps { pluginRows: PluginMcpServerRow[]; } @@ -159,10 +188,12 @@ function CustomServerDialog({ onSubmit: (draft: CustomMcpServerDraft) => void; }): React.ReactNode { const t = useTranslations('settings.plugins.mcp'); + const [template, setTemplate] = useState(''); const { control, register, handleSubmit, + reset, formState: { errors }, } = useForm({ resolver: zodResolver(McpFormSchema), @@ -177,6 +208,12 @@ function CustomServerDialog({ ? previous.server.envKeys.length : previous.server.headerKeys.length; + const applyTemplate = (value: string): void => { + setTemplate(value); + const tpl = MAIL_TEMPLATES.find((m) => m.value === value); + reset(tpl ? mailTemplateForm(tpl.preset, t(tpl.nameKey)) : formDefaults(undefined)); + }; + const submit = handleSubmit((form) => { const secretRows: Array<{ key: string; value: string; remove: boolean }> = []; for (const row of form.secrets) { @@ -213,6 +250,24 @@ function CustomServerDialog({ errors={rhfErrorsToFormErrors(errors)} onSubmit={submit} > + {previous === undefined && ( + + {t('form.mailTemplate')} + + + )} {t('form.name')} diff --git a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx index fbe7a7444..f0b31e59a 100644 --- a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx +++ b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx @@ -13,6 +13,7 @@ import { useSetSkillEnabled, useUninstallPlugin, } from './hooks'; +import { LinkCodeMarketTab } from './linkcode-tab'; import { McpTab } from './mcp-settings'; import { filterPluginCards, pluginMcpServerRows, pluginProviderGroups, skillRows } from './view'; @@ -159,6 +160,7 @@ export function PluginsSettingsPanel(): React.ReactNode { /> } mcpTab={} + linkcodeTab={} skillsTab={ card.searchText.includes(needle)); +} diff --git a/packages/foundation/schema/src/model/__tests__/plugin.test.ts b/packages/foundation/schema/src/model/__tests__/plugin.test.ts index 795b9a435..ba8ea677e 100644 --- a/packages/foundation/schema/src/model/__tests__/plugin.test.ts +++ b/packages/foundation/schema/src/model/__tests__/plugin.test.ts @@ -267,6 +267,85 @@ describe('LinkCode plugin package contracts', () => { ).toBe(false); }); + const mailManifest = { + manifestVersion: 1, + id: 'linkcode/mail', + version: '0.1.0', + keywords: ['mail', 'imap', 'smtp'], + components: [ + { + kind: 'mcp-server', + name: 'mail', + command: 'node', + entry: 'dist/index.js', + env: { MAIL_USER: 'account', MAIL_PASSWORD: 'authcode', MAIL_PRESET: 'preset' }, + }, + ], + settings: { + account: { type: 'string', label: 'Email account', required: true }, + authcode: { type: 'password', label: 'Authorization code', secret: true, required: true }, + preset: { type: 'enum', enum: ['163', 'qq', 'exmail'], default: '163' }, + }, + assets: [], + } as const; + + it('accepts an mcp-server component with declared settings and env bindings', () => { + expect(LinkCodePluginManifestSchema.safeParse(mailManifest).success).toBe(true); + }); + + it.each(['/tmp/index.js', '../dist/index.js', String.raw`dist\index.js`])( + 'rejects a non-package-relative mcp entry %s', + (entry) => { + expect( + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + components: [{ ...mailManifest.components[0], entry }], + }).success, + ).toBe(false); + }, + ); + + it('rejects an mcp-server env binding to an undeclared setting', () => { + expect( + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + components: [{ ...mailManifest.components[0], env: { MAIL_USER: 'missingSetting' } }], + }).success, + ).toBe(false); + }); + + it('rejects an enum setting without options and a non-enum setting carrying options', () => { + expect( + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + settings: { ...mailManifest.settings, preset: { type: 'enum' } }, + }).success, + ).toBe(false); + expect( + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + settings: { ...mailManifest.settings, account: { type: 'string', enum: ['a'] } }, + }).success, + ).toBe(false); + }); + + it('the forward-compatible reader strips unknown mcp-server component keys', () => { + expect( + LinkCodePluginReleaseSchema.parse({ + manifest: { + ...mailManifest, + components: [{ ...mailManifest.components[0], futureComponentMetadata: 'ignored' }], + futureManifestMetadata: 'ignored', + }, + artifact: { + urls: ['releases/linkcode-mail-0.1.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + }), + ).toMatchObject({ manifest: { id: 'linkcode/mail', version: '0.1.0' } }); + }); + it('continues to reject agent runtimes as plugin assets', () => { expect( LinkCodePluginManifestSchema.safeParse({ diff --git a/packages/foundation/schema/src/model/linkcode-marketplace.ts b/packages/foundation/schema/src/model/linkcode-marketplace.ts index f4ab15267..639dc4289 100644 --- a/packages/foundation/schema/src/model/linkcode-marketplace.ts +++ b/packages/foundation/schema/src/model/linkcode-marketplace.ts @@ -8,10 +8,23 @@ import { import { TimestampSchema } from './primitives'; const HTTPS_URL_RE = /^https:\/\//i; +const HTTP_URL_RE = /^http:\/\//i; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** http is accepted for loopback hosts only — local debug marketplaces (RFC 8252's exception). */ +function isAllowedMarketplaceUrl(url: string): boolean { + if (HTTPS_URL_RE.test(url)) return true; + if (!HTTP_URL_RE.test(url)) return false; + try { + return LOOPBACK_HOSTS.has(new URL(url).hostname); + } catch { + return false; + } +} const LinkCodeMarketplaceHttpsUrlSchema = z .url() - .refine((url) => HTTPS_URL_RE.test(url), 'Expected an absolute HTTPS URL'); + .refine((url) => isAllowedMarketplaceUrl(url), 'Expected an absolute HTTPS URL'); /** HTTPS index configured by the user; the remote document never chooses its local identity. */ export const LinkCodeMarketplaceRemoteSourceSchema = z.object({ diff --git a/packages/foundation/schema/src/model/linkcode-plugin.ts b/packages/foundation/schema/src/model/linkcode-plugin.ts index 4155f625c..eccd5e78c 100644 --- a/packages/foundation/schema/src/model/linkcode-plugin.ts +++ b/packages/foundation/schema/src/model/linkcode-plugin.ts @@ -106,6 +106,89 @@ const linkCodePluginSkillFields = { export const LinkCodePluginSkillSchema = z.strictObject(linkCodePluginSkillFields); export type LinkCodePluginSkill = z.infer; +/** Setting value types a manifest may declare; the host renders and stores only these. */ +export const LinkCodePluginSettingTypeSchema = z.enum([ + 'string', + 'password', + 'enum', + 'boolean', + 'number', +]); +export type LinkCodePluginSettingType = z.infer; + +/** + * One declared configuration input. `secret: true` routes the value to the vault (never + * `config.json`, never returned unmasked); the rest mirror a constrained JSON-Schema field so the + * host can render a form without executing plugin code. + */ +export const LinkCodePluginSettingFieldSchema = z + .strictObject({ + type: LinkCodePluginSettingTypeSchema, + label: z.string().min(1).optional(), + description: z.string().optional(), + secret: z.boolean().optional(), + required: z.boolean().optional(), + default: z.union([z.string(), z.number(), z.boolean()]).optional(), + enum: z.array(z.string().min(1)).optional(), + }) + .superRefine((field, ctx) => { + if (field.type === 'enum' && (!field.enum || field.enum.length === 0)) { + ctx.addIssue({ + code: 'custom', + message: 'An enum setting must list at least one option', + path: ['enum'], + }); + } + if (field.type !== 'enum' && field.enum !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'enum options are only valid on an enum setting', + path: ['enum'], + }); + } + }); +export type LinkCodePluginSettingField = z.infer; + +export const LinkCodePluginSettingsSchema = z.record( + z.string().refine(isSafeIdSegment, 'Expected a safe lowercase setting id'), + LinkCodePluginSettingFieldSchema, +); +export type LinkCodePluginSettings = z.infer; + +const linkCodePluginMcpServerFields = { + kind: z.literal('mcp-server'), + name: z.string().refine(isSafeIdSegment, 'Expected a safe lowercase server name'), + description: z.string().optional(), + command: z.string().min(1), + /** Package-relative entry point. When present, the host resolves it under the installed plugin + * root and prepends it to args, so manifests never need to contain machine-specific paths. */ + entry: LinkCodePluginPackagePathSchema.optional(), + args: z.array(z.string().min(1)).optional(), + /** Maps env-var name to a setting field id; the host resolves stored setting values into env at + * session start. Keys not declared here are not injected. */ + env: z.record(z.string().min(1), z.string().min(1)).optional(), +} as const; + +/** A plugin-shipped MCP server the host spawns per session, fed by the manifest's declared settings. */ +export const LinkCodePluginMcpServerComponentSchema = z.strictObject(linkCodePluginMcpServerFields); +export type LinkCodePluginMcpServerComponent = z.infer< + typeof LinkCodePluginMcpServerComponentSchema +>; + +/** A manifest component — a skill or an MCP server the host materializes. */ +export const LinkCodePluginComponentSchema = z.union([ + LinkCodePluginSkillSchema, + LinkCodePluginMcpServerComponentSchema, +]); +export type LinkCodePluginComponent = z.infer; + +/** Non-strict reader union: strips unknown component keys so a newer peer's additive field does + * not hide a compatible release from an older reader. */ +const LinkCodePluginComponentReaderSchema = z.union([ + z.object(linkCodePluginSkillFields), + z.object(linkCodePluginMcpServerFields), +]); + const linkCodePluginManifestFields = { manifestVersion: z.literal(1), id: LinkCodePluginIdSchema, @@ -116,13 +199,15 @@ const linkCodePluginManifestFields = { category: z.string().min(1).optional(), keywords: z.array(z.string().min(1)), links: PluginLinksSchema.optional(), - components: z.array(z.object(linkCodePluginSkillFields)).min(1), + components: z.array(LinkCodePluginComponentReaderSchema).min(1), + /** Declared configuration inputs the host renders and stores; an MCP-server component reads these. */ + settings: LinkCodePluginSettingsSchema.optional(), /** Trusted managed-tool requirements; URLs and exact asset versions remain host-owned. */ assets: z.array(PluginAssetRequirementSchema), } as const; function rejectDuplicateComponents( - manifest: { components: LinkCodePluginSkill[] }, + manifest: { components: LinkCodePluginComponent[] }, ctx: z.RefinementCtx, ): void { const names = new Set(); @@ -139,6 +224,26 @@ function rejectDuplicateComponents( } } +/** Rejects an MCP-server component whose `env` maps to a setting id the manifest never declares. */ +function rejectUnresolvedEnvBindings( + manifest: { components: LinkCodePluginComponent[]; settings?: LinkCodePluginSettings }, + ctx: z.RefinementCtx, +): void { + const declared = new Set(manifest.settings ? Object.keys(manifest.settings) : []); + for (const [index, component] of manifest.components.entries()) { + if (component.kind !== 'mcp-server' || !component.env) continue; + for (const [envName, settingId] of Object.entries(component.env)) { + if (!declared.has(settingId)) { + ctx.addIssue({ + code: 'custom', + message: `env ${envName} references undeclared setting "${settingId}"`, + path: ['components', index, 'env', envName], + }); + } + } + } +} + /** * Agent-independent package manifest. Provider-specific discovery remains on `PluginSchema`; this * contract is the source format LinkCode installs once and projects into supported agents. @@ -146,15 +251,17 @@ function rejectDuplicateComponents( export const LinkCodePluginManifestSchema = z .strictObject({ ...linkCodePluginManifestFields, - components: z.array(LinkCodePluginSkillSchema).min(1), + components: z.array(LinkCodePluginComponentSchema).min(1), }) - .superRefine(rejectDuplicateComponents); + .superRefine(rejectDuplicateComponents) + .superRefine(rejectUnresolvedEnvBindings); export type LinkCodePluginManifest = z.infer; /** Forward-compatible marketplace reader for additive fields within manifest version 1. */ const LinkCodePluginManifestReaderSchema = z .object(linkCodePluginManifestFields) - .superRefine(rejectDuplicateComponents); + .superRefine(rejectDuplicateComponents) + .superRefine(rejectUnresolvedEnvBindings); export const LinkCodePluginArchiveFormatSchema = z.enum(['tgz', 'zip']); export type LinkCodePluginArchiveFormat = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 35c1b5ec4..3f9b6e6cf 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 78 as const; +export const WIRE_PROTOCOL_VERSION = 79 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/src/wire/payload.ts b/packages/foundation/schema/src/wire/payload.ts index 0389ba2aa..7f3cc69a0 100644 --- a/packages/foundation/schema/src/wire/payload.ts +++ b/packages/foundation/schema/src/wire/payload.ts @@ -13,6 +13,8 @@ import { keepAliveWireVariants } from './keep-alive'; import { loopWireVariants } from './loop'; import { managedAssetWireVariants } from './managed-asset'; import { pluginWireVariants } from './plugin'; +import { pluginConfigWireVariants } from './plugin-config'; +import { pluginMarketWireVariants } from './plugin-market'; import { requestWireVariants } from './request'; import { resourceWireVariants } from './resource'; import { scheduleWireVariants } from './schedule'; @@ -33,6 +35,8 @@ const wirePayloadVariants = [ ...agentLoginWireVariants, ...managedAssetWireVariants, ...pluginWireVariants, + ...pluginConfigWireVariants, + ...pluginMarketWireVariants, ...workspaceWireVariants, ...gitWireVariants, ...fileWireVariants, diff --git a/packages/foundation/schema/src/wire/plugin-config.ts b/packages/foundation/schema/src/wire/plugin-config.ts new file mode 100644 index 000000000..b79516400 --- /dev/null +++ b/packages/foundation/schema/src/wire/plugin-config.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import { + LinkCodePluginIdSchema, + LinkCodePluginSettingsSchema, + LinkCodePluginVersionSchema, +} from '../model/linkcode-plugin'; +import { WireRequestIdSchema } from './request'; + +/** A stored setting value: the manifest's restricted JSON-Schema subset maps to these primitives. */ +const PluginConfigValueSchema = z.union([z.string(), z.number(), z.boolean()]); + +/** LinkCode plugin configuration wire variants. Read returns field schemas (so the client renders a + * form without executing plugin code) plus masked values — secret fields are omitted, mirroring the + * custom-MCP masked-edit contract. Write is a per-key patch: typed values set, keys removed. */ +export const pluginConfigWireVariants = [ + z.object({ + kind: z.literal('plugin-config.list.get'), + clientReqId: WireRequestIdSchema, + }), + z.object({ + kind: z.literal('plugin-config.listed'), + replyTo: WireRequestIdSchema, + plugins: z.array( + z.object({ + id: LinkCodePluginIdSchema, + version: LinkCodePluginVersionSchema, + settings: LinkCodePluginSettingsSchema, + values: z.record(z.string().min(1), PluginConfigValueSchema), + }), + ), + }), + z.object({ + kind: z.literal('plugin-config.set'), + clientReqId: WireRequestIdSchema, + pluginId: LinkCodePluginIdSchema, + set: z.record(z.string().min(1), PluginConfigValueSchema).optional(), + remove: z.array(z.string().min(1)).optional(), + }), + z.object({ + kind: z.literal('plugin-config.updated'), + replyTo: WireRequestIdSchema, + pluginId: LinkCodePluginIdSchema, + values: z.record(z.string().min(1), PluginConfigValueSchema), + }), +] as const; diff --git a/packages/foundation/schema/src/wire/plugin-market.ts b/packages/foundation/schema/src/wire/plugin-market.ts new file mode 100644 index 000000000..9ab0ff72f --- /dev/null +++ b/packages/foundation/schema/src/wire/plugin-market.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { + LinkCodeMarketplaceConfigSchema, + LinkCodeMarketplaceReleaseIdentitySchema, +} from '../model/linkcode-marketplace'; +import { + LinkCodeMarketplaceIdSchema, + LinkCodePluginIdSchema, + LinkCodePluginReleaseSchema, + LinkCodePluginVersionSchema, +} from '../model/linkcode-plugin'; +import { WireRequestIdSchema } from './request'; + +/** LinkCode marketplace wire variants. A marketplace is a user-configured HTTPS index; the daemon + * refreshes it with ETag/If-None-Match and the client browses/releases and installs from the + * returned catalog. The refresh reply carries the releases so a client never fetches the index + * itself — the daemon is the only networked leg. */ +export const pluginMarketWireVariants = [ + z.object({ + kind: z.literal('plugin-market.list.get'), + clientReqId: WireRequestIdSchema, + }), + z.object({ + kind: z.literal('plugin-market.listed'), + replyTo: WireRequestIdSchema, + marketplaces: z.array(LinkCodeMarketplaceConfigSchema), + }), + z.object({ + kind: z.literal('plugin-market.refresh'), + clientReqId: WireRequestIdSchema, + marketplaceId: z.string().min(1), + }), + z.object({ + kind: z.literal('plugin-market.refreshed'), + replyTo: WireRequestIdSchema, + marketplaceId: z.string().min(1), + /** Releases the index advertised, already filtered to what this client can represent. */ + releases: z.array( + z.object({ + pluginId: z.string().min(1), + release: LinkCodePluginReleaseSchema, + }), + ), + /** True when the index was unchanged (304 / matching ETag); releases is the cached catalog. */ + notModified: z.boolean().optional(), + }), + z.object({ + kind: z.literal('plugin-market.install'), + clientReqId: WireRequestIdSchema, + release: LinkCodeMarketplaceReleaseIdentitySchema, + }), + /** Success replies carry the installed identity so the client patches one cache entry instead of + * re-listing; failures use the shared request.failed reply. */ + z.object({ + kind: z.literal('plugin-market.installed'), + replyTo: WireRequestIdSchema, + marketplaceId: LinkCodeMarketplaceIdSchema, + pluginId: LinkCodePluginIdSchema, + version: LinkCodePluginVersionSchema, + }), + z.object({ + kind: z.literal('plugin-market.uninstall'), + clientReqId: WireRequestIdSchema, + pluginId: z.string().min(1), + }), + z.object({ + kind: z.literal('plugin-market.uninstalled'), + replyTo: WireRequestIdSchema, + pluginId: LinkCodePluginIdSchema, + }), +] as const; diff --git a/packages/foundation/schema/tests/contract/wire/plugin-market.test.ts b/packages/foundation/schema/tests/contract/wire/plugin-market.test.ts new file mode 100644 index 000000000..97f1bdc03 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/plugin-market.test.ts @@ -0,0 +1,126 @@ +import { parseWireMessage, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +function envelope(payload: unknown) { + return { v: WIRE_PROTOCOL_VERSION, id: 'message-1', ts: 1, payload }; +} + +const marketplace = { + id: 'linkcode-official', + displayName: 'LinkCode Official', + source: { type: 'remote', url: 'https://plugins.linkcode.ai/index.json' }, + enabled: true, +}; + +const release = { + manifest: { + manifestVersion: 1, + id: 'arcbox/latex', + version: '1.2.0', + keywords: [], + components: [{ kind: 'skill', name: 'latex', entry: 'skills/latex/SKILL.md' }], + assets: [], + }, + artifact: { + urls: ['https://plugins.linkcode.ai/arcbox/latex/1.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, +}; + +const identity = { marketplaceId: 'linkcode-official', pluginId: 'arcbox/latex', version: '1.2.0' }; + +describe('plugin-market wire schema', () => { + it('round-trips the list request and its reply', () => { + expect( + parseWireMessage(envelope({ kind: 'plugin-market.list.get', clientReqId: 'request-1' })).ok, + ).toBe(true); + + const reply = parseWireMessage( + envelope({ kind: 'plugin-market.listed', replyTo: 'request-1', marketplaces: [marketplace] }), + ); + expect(reply.ok).toBe(true); + if (!reply.ok || reply.message.payload.kind !== 'plugin-market.listed') return; + expect(reply.message.payload.marketplaces).toHaveLength(1); + }); + + it('round-trips a refresh request and both refresh reply shapes', () => { + expect( + parseWireMessage( + envelope({ + kind: 'plugin-market.refresh', + clientReqId: 'request-1', + marketplaceId: 'linkcode-official', + }), + ).ok, + ).toBe(true); + + const updated = parseWireMessage( + envelope({ + kind: 'plugin-market.refreshed', + replyTo: 'request-1', + marketplaceId: 'linkcode-official', + releases: [{ pluginId: 'arcbox/latex', release }], + }), + ); + expect(updated.ok).toBe(true); + if (!updated.ok || updated.message.payload.kind !== 'plugin-market.refreshed') return; + expect(updated.message.payload.releases).toHaveLength(1); + + const notModified = parseWireMessage( + envelope({ + kind: 'plugin-market.refreshed', + replyTo: 'request-1', + marketplaceId: 'linkcode-official', + releases: [], + notModified: true, + }), + ); + expect(notModified.ok).toBe(true); + }); + + it('round-trips install and uninstall requests with their replies', () => { + expect( + parseWireMessage( + envelope({ kind: 'plugin-market.install', clientReqId: 'request-1', release: identity }), + ).ok, + ).toBe(true); + expect( + parseWireMessage( + envelope({ + kind: 'plugin-market.uninstall', + clientReqId: 'request-1', + pluginId: 'arcbox/latex', + }), + ).ok, + ).toBe(true); + + const installed = parseWireMessage( + envelope({ kind: 'plugin-market.installed', replyTo: 'request-1', ...identity }), + ); + expect(installed.ok).toBe(true); + if (!installed.ok || installed.message.payload.kind !== 'plugin-market.installed') return; + expect(installed.message.payload.version).toBe('1.2.0'); + + expect( + parseWireMessage( + envelope({ + kind: 'plugin-market.uninstalled', + replyTo: 'request-1', + pluginId: 'arcbox/latex', + }), + ).ok, + ).toBe(true); + }); + + it('rejects an install identity from a non-HTTPS-configured marketplace id shape', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'plugin-market.install', + clientReqId: 'request-1', + release: { ...identity, pluginId: 'not-a-plugin-id' }, + }), + ); + expect(parsed.ok).toBe(false); + }); +}); diff --git a/packages/host/assets/src/index.ts b/packages/host/assets/src/index.ts index b16381f82..4b08e25ec 100644 --- a/packages/host/assets/src/index.ts +++ b/packages/host/assets/src/index.ts @@ -16,4 +16,5 @@ export * from './paths'; export * from './platform'; export * from './registry-client'; export * from './resolve'; +export * from './system-proxy'; export * from './version-pin'; diff --git a/packages/host/engine/src/__tests__/plugin-market.test.ts b/packages/host/engine/src/__tests__/plugin-market.test.ts new file mode 100644 index 000000000..d6a66e2aa --- /dev/null +++ b/packages/host/engine/src/__tests__/plugin-market.test.ts @@ -0,0 +1,351 @@ +import type { + LinkCodeMarketplaceConfig, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginRelease, + ValidatedWireMessage, + WirePayload, +} from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { nullthrow } from 'foxts/guard'; +import { asyncNoop, noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; +import type { EngineDeps } from '../deps'; +import type { InstalledLinkCodePluginEntry } from '../plugin/linkcode-store'; +import { InMemoryLinkCodePluginStore } from '../plugin/linkcode-store'; +import type { + LinkCodeMarketplaceService, + MarketplaceRefreshResult, +} from '../plugin/market-service'; +import { createTestEngine } from './fixtures/test-engine'; + +const MARKETPLACE: LinkCodeMarketplaceConfig = { + id: 'linkcode-official', + displayName: 'LinkCode Official', + source: { type: 'remote', url: 'https://plugins.linkcode.ai/index.json' }, + enabled: true, +}; + +const RELEASE: LinkCodePluginRelease = { + manifest: { + manifestVersion: 1, + id: 'arcbox/latex', + version: '1.2.0', + keywords: [], + components: [{ kind: 'skill', name: 'latex', entry: 'skills/latex/SKILL.md' }], + assets: [], + }, + artifact: { + urls: ['https://plugins.linkcode.ai/arcbox/latex/1.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, +}; + +const IDENTITY = { marketplaceId: 'linkcode-official', pluginId: 'arcbox/latex', version: '1.2.0' }; + +function catalogKey(identity: LinkCodeMarketplaceReleaseIdentity): string { + return `${identity.marketplaceId}/${identity.pluginId}/${identity.version}`; +} + +/** A marketplace stub backed by a real catalog map: seeded with RELEASE, miss = empty catalog. */ +function fakeMarketplace(overrides: Partial = {}) { + const catalog = new Map([[catalogKey(IDENTITY), RELEASE]]); + const resolveRelease = vi.fn((identity: LinkCodeMarketplaceReleaseIdentity) => + catalog.get(catalogKey(identity)), + ); + const service: LinkCodeMarketplaceService = { + list: () => [MARKETPLACE], + refresh: () => + Promise.resolve({ + releases: [{ pluginId: 'arcbox/latex', release: RELEASE }], + }), + resolveRelease, + ...overrides, + }; + return { service, resolveRelease }; +} + +/** The in-memory store with spied install/uninstall; reassign either to force a rejection. */ +function fakeStore() { + const store = new InMemoryLinkCodePluginStore(); + const install = vi.fn((release: LinkCodePluginRelease, marketplaceId: string) => + Promise.resolve({ + installed: { + id: release.manifest.id, + version: release.manifest.version, + marketplaceId, + integrity: release.artifact.integrity, + enabled: true, + path: '/store/arcbox/latex/1.2.0', + }, + manifest: release.manifest, + }), + ); + const uninstall = vi.fn(asyncNoop); + store.install = install; + store.uninstall = uninstall; + return { store, install, uninstall }; +} + +function harness(deps: EngineDeps = {}) { + const sent: WirePayload[] = []; + let handler: ((msg: ValidatedWireMessage) => void) | null = null; + const transport: Transport = { + connect: () => Promise.resolve(), + send(msg: ValidatedWireMessage) { + sent.push(msg.payload); + }, + onMessage(cb) { + handler = cb; + return noop; + }, + onClose: () => noop, + close: noop, + }; + const engine = createTestEngine(transport, deps); + function inject(payload: WirePayload): void { + nullthrow(handler, 'engine not started')(createWireMessage(payload)); + } + return { engine, sent, inject }; +} + +describe('plugin-market.list.get', () => { + it('replies with the configured marketplaces', async () => { + const { engine, sent, inject } = harness({ linkCodeMarketplace: fakeMarketplace().service }); + await engine.start(); + inject({ kind: 'plugin-market.list.get', clientReqId: 'r1' }); + expect(sent).toContainEqual({ + kind: 'plugin-market.listed', + replyTo: 'r1', + marketplaces: [MARKETPLACE], + }); + await engine.stop(); + }); + + it('replies with an empty list when the host has no marketplace plane', async () => { + const { engine, sent, inject } = harness(); + await engine.start(); + inject({ kind: 'plugin-market.list.get', clientReqId: 'r1' }); + expect(sent).toContainEqual({ kind: 'plugin-market.listed', replyTo: 'r1', marketplaces: [] }); + await engine.stop(); + }); +}); + +describe('plugin-market.refresh', () => { + it('replies refreshed with the releases the daemon parsed', async () => { + const { engine, sent, inject } = harness({ linkCodeMarketplace: fakeMarketplace().service }); + await engine.start(); + inject({ + kind: 'plugin-market.refresh', + clientReqId: 'r1', + marketplaceId: 'linkcode-official', + }); + await vi.waitFor(() => { + expect(sent).toContainEqual({ + kind: 'plugin-market.refreshed', + replyTo: 'r1', + marketplaceId: 'linkcode-official', + releases: [{ pluginId: 'arcbox/latex', release: RELEASE }], + }); + }); + await engine.stop(); + }); + + it('carries the notModified flag through on an unchanged index', async () => { + const { engine, sent, inject } = harness({ + linkCodeMarketplace: fakeMarketplace({ + refresh: () => Promise.resolve({ releases: [], notModified: true }), + }).service, + }); + await engine.start(); + inject({ + kind: 'plugin-market.refresh', + clientReqId: 'r1', + marketplaceId: 'linkcode-official', + }); + await vi.waitFor(() => { + expect(sent).toContainEqual({ + kind: 'plugin-market.refreshed', + replyTo: 'r1', + marketplaceId: 'linkcode-official', + releases: [], + notModified: true, + }); + }); + await engine.stop(); + }); + + it('fails not_found for an unconfigured marketplace without calling refresh', async () => { + const refresh = vi.fn(); + const { engine, sent, inject } = harness({ + linkCodeMarketplace: fakeMarketplace({ refresh }).service, + }); + await engine.start(); + inject({ kind: 'plugin-market.refresh', clientReqId: 'r1', marketplaceId: 'community' }); + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'not_found', + message: 'Unknown marketplace: community', + }); + expect(refresh).not.toHaveBeenCalled(); + await engine.stop(); + }); + + it('fails unsupported when the host has no marketplace plane', async () => { + const { engine, sent, inject } = harness(); + await engine.start(); + inject({ + kind: 'plugin-market.refresh', + clientReqId: 'r1', + marketplaceId: 'linkcode-official', + }); + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'unsupported', + message: 'Plugin marketplaces are unavailable on this host', + }); + await engine.stop(); + }); + + it('fails the request without leaking the refresh error detail', async () => { + const { engine, sent, inject } = harness({ + linkCodeMarketplace: fakeMarketplace({ + refresh: () => Promise.reject(new Error('secret upstream response')), + }).service, + }); + await engine.start(); + inject({ + kind: 'plugin-market.refresh', + clientReqId: 'r1', + marketplaceId: 'linkcode-official', + }); + await vi.waitFor(() => { + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'operation_failed', + message: 'Failed to refresh the marketplace index', + }); + }); + expect(JSON.stringify(sent)).not.toContain('secret upstream response'); + await engine.stop(); + }); +}); + +describe('plugin-market.install', () => { + it('resolves the release from the cached catalog and installs it', async () => { + const { store, install } = fakeStore(); + const { service, resolveRelease } = fakeMarketplace(); + const { engine, sent, inject } = harness({ + linkCodePluginStore: store, + linkCodeMarketplace: service, + }); + await engine.start(); + inject({ kind: 'plugin-market.install', clientReqId: 'r1', release: IDENTITY }); + await vi.waitFor(() => { + expect(sent).toContainEqual({ + kind: 'plugin-market.installed', + replyTo: 'r1', + ...IDENTITY, + }); + }); + expect(resolveRelease).toHaveBeenCalledWith(IDENTITY); + expect(install).toHaveBeenCalledWith(RELEASE, 'linkcode-official'); + await engine.stop(); + }); + + it('fails not_found when the release is absent from the cached catalog', async () => { + const { store, install } = fakeStore(); + const { engine, sent, inject } = harness({ + linkCodePluginStore: store, + linkCodeMarketplace: fakeMarketplace().service, + }); + await engine.start(); + inject({ + kind: 'plugin-market.install', + clientReqId: 'r1', + release: { ...IDENTITY, version: '9.9.9' }, + }); + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'not_found', + message: 'Unknown marketplace release: arcbox/latex@9.9.9', + }); + expect(install).not.toHaveBeenCalled(); + await engine.stop(); + }); + + it('fails the request when the store install rejects', async () => { + const { store } = fakeStore(); + store.install = () => Promise.reject(new Error('integrity mismatch detail')); + const { engine, sent, inject } = harness({ + linkCodePluginStore: store, + linkCodeMarketplace: fakeMarketplace().service, + }); + await engine.start(); + inject({ kind: 'plugin-market.install', clientReqId: 'r1', release: IDENTITY }); + await vi.waitFor(() => { + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'operation_failed', + message: 'Failed to install the plugin', + }); + }); + expect(JSON.stringify(sent)).not.toContain('integrity mismatch detail'); + await engine.stop(); + }); + + it('fails unsupported when the host has no marketplace plane', async () => { + const { store } = fakeStore(); + const { engine, sent, inject } = harness({ linkCodePluginStore: store }); + await engine.start(); + inject({ kind: 'plugin-market.install', clientReqId: 'r1', release: IDENTITY }); + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'unsupported', + message: 'Plugin marketplaces are unavailable on this host', + }); + await engine.stop(); + }); +}); + +describe('plugin-market.uninstall', () => { + it('uninstalls through the store and replies with the plugin id', async () => { + const { store, uninstall } = fakeStore(); + const { engine, sent, inject } = harness({ linkCodePluginStore: store }); + await engine.start(); + inject({ kind: 'plugin-market.uninstall', clientReqId: 'r1', pluginId: 'arcbox/latex' }); + await vi.waitFor(() => { + expect(sent).toContainEqual({ + kind: 'plugin-market.uninstalled', + replyTo: 'r1', + pluginId: 'arcbox/latex', + }); + }); + expect(uninstall).toHaveBeenCalledWith('arcbox/latex'); + await engine.stop(); + }); + + it('fails the request when the store uninstall rejects', async () => { + const { store } = fakeStore(); + store.uninstall = () => Promise.reject(new Error('disk full')); + const { engine, sent, inject } = harness({ linkCodePluginStore: store }); + await engine.start(); + inject({ kind: 'plugin-market.uninstall', clientReqId: 'r1', pluginId: 'arcbox/latex' }); + await vi.waitFor(() => { + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'operation_failed', + message: 'Failed to uninstall the plugin', + }); + }); + await engine.stop(); + }); +}); diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 667835fb5..819597486 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -13,6 +13,7 @@ import { noop } from 'foxts/noop'; import { describe, expect, it } from 'vitest'; import { CustomMcpServerService } from '../agent/custom-mcp-service'; import { InMemoryProviderConfigStore } from '../agent/provider-config'; +import { InMemoryLinkCodePluginStore } from '../plugin/linkcode-store'; import { PluginService } from '../plugin/service'; import { SessionStartOptionsResolver } from '../session/start-options-resolver'; import type { SimulatorMcpProvider } from '../simulator/mcp'; @@ -353,3 +354,75 @@ describe('custom MCP injection at session start', () => { expect(warnings).toEqual([{ serverName: 'github', reason: 'provider-preflight-failed' }]); }); }); + +describe('LinkCode plugin MCP injection at session start', () => { + it('resolves a package-relative entry point against the installed plugin root', async () => { + const store = new InMemoryLinkCodePluginStore(); + const packageRoot = '/store/plugins/linkcode/mail/0.1.0'; + store.seed( + { + installed: { + id: 'linkcode/mail', + version: '0.1.0', + marketplaceId: 'linkcode-official', + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + enabled: true, + path: packageRoot, + }, + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '0.1.0', + keywords: ['mail'], + components: [ + { + kind: 'mcp-server', + name: 'mail', + command: 'node', + entry: 'dist/index.js', + env: { + MAIL_USER: 'account', + MAIL_PASSWORD: 'authcode', + MAIL_PRESET: 'preset', + }, + }, + ], + settings: { + account: { type: 'string' }, + authcode: { type: 'password', secret: true }, + preset: { type: 'enum', enum: ['163', 'qq'] }, + }, + assets: [], + }, + }, + { account: 'user@qq.com', authcode: 'authorization-code', preset: 'qq' }, + ); + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + undefined, + undefined, + store, + ); + + const { options: resolved, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), + ); + + expect(resolved.mcpServers).toEqual([ + { + type: 'stdio', + name: 'mail', + command: 'node', + args: [`${packageRoot}/dist/index.js`], + env: { + MAIL_USER: 'user@qq.com', + MAIL_PASSWORD: 'authorization-code', + MAIL_PRESET: 'qq', + }, + }, + ]); + expect(warnings).toEqual([]); + }); +}); diff --git a/packages/host/engine/src/deps.ts b/packages/host/engine/src/deps.ts index 3fdce4a71..ac449425e 100644 --- a/packages/host/engine/src/deps.ts +++ b/packages/host/engine/src/deps.ts @@ -7,6 +7,8 @@ import type { TranslatorService } from './agent/translator'; import type { AssetService } from './asset/service'; import type { LoopStore, ScheduleStore } from './automation'; import type { GitService } from './git/git-service'; +import type { LinkCodePluginStore } from './plugin/linkcode-store'; +import type { LinkCodeMarketplaceService } from './plugin/market-service'; import type { PreviewRouteRegistry } from './preview/route-registry'; import type { ResourceStore } from './resource/resource-store'; import type { SessionStore } from './session/session-store'; @@ -40,6 +42,13 @@ export interface EngineDeps { * The default is volatile and never asks anyone, so an embedding without one is not gated. */ simulatorConsent?: SimulatorConsentService; providerStore?: ProviderConfigStore; + /** LinkCode-installed plugin store (manifests + declared settings + install/uninstall). The + * daemon supplies a persistent on-disk implementation; an absent Engine injects none, so + * plugin MCP-server components and `plugin-config.*` wire stay empty. */ + linkCodePluginStore?: LinkCodePluginStore; + /** Marketplace plane behind `plugin-market.*`: configured sources, index refresh, and the cached + * catalog installs resolve from. Absent Engines list nothing and reject refresh/install. */ + linkCodeMarketplace?: LinkCodeMarketplaceService; modelProbe?: ModelProbe; git?: GitService; fileSuggest?: FileSuggestService; diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index f61ee2fbe..a18817bca 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -27,6 +27,10 @@ import type { EngineFailure, OperationSubsystem } from './failure'; import { toOperationFailure } from './failure'; import { GitService } from './git/git-service'; import { GitRequestHandler } from './git/request-handler'; +import { LinkCodePluginConfigRequestHandler } from './plugin/config-request-handler'; +import { PluginConfigService } from './plugin/config-service'; +import { InMemoryLinkCodePluginStore } from './plugin/linkcode-store'; +import { LinkCodePluginMarketRequestHandler } from './plugin/market-request-handler'; import { PluginRequestHandler } from './plugin/request-handler'; import type { PluginDiscoveryResult } from './plugin/service'; import { PluginService } from './plugin/service'; @@ -86,6 +90,8 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const factory = deps.factory ?? createAdapter; const providerStore = deps.providerStore ?? new InMemoryProviderConfigStore(); const customMcp = new CustomMcpServerService(providerStore); + const linkCodePluginStore = deps.linkCodePluginStore ?? new InMemoryLinkCodePluginStore(); + const pluginConfig = new PluginConfigService(linkCodePluginStore); const records = new SessionRecordRegistry( deps.sessionStore ?? new InMemorySessionStore(), (sessionId, reason) => { @@ -109,6 +115,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( deps.simulatorMcp, customMcp, plugins, + linkCodePluginStore, ); const history = new HistoryService(factory, { injectedMcpServerNames: (kind) => startOptions.injectedMcpServerNames(kind), @@ -252,6 +259,17 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( ); const browserRequests = new BrowserRequestHandler(transport, browserBroker); const pluginRequests = new PluginRequestHandler(transport, plugins, responder); + const linkCodePluginConfigRequests = new LinkCodePluginConfigRequestHandler( + transport, + pluginConfig, + responder, + ); + const linkCodePluginMarketRequests = new LinkCodePluginMarketRequestHandler( + transport, + deps.linkCodeMarketplace, + linkCodePluginStore, + responder, + ); const requests = new WireRequestRouter(transport, { session: sessionRequests, history: historyRequests, @@ -260,6 +278,8 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( workspace: workspaceRequests, git: gitRequests, plugin: pluginRequests, + linkCodePluginConfig: linkCodePluginConfigRequests, + linkCodePluginMarket: linkCodePluginMarketRequests, file: fileRequests, script: scriptRequests, artifact: artifactRequests, diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 7d4e4f7c0..e3ba839b1 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -9,6 +9,18 @@ export type { TranslatorService, TranslatorUpstream } from './agent/translator'; export type { AssetService } from './asset/service'; export type { LoopStore, ScheduleStore } from './automation'; export type { EngineDeps } from './deps'; +export type { + InMemoryLinkCodePluginStore, + InstalledLinkCodePluginEntry, + LinkCodePluginStore, + PluginConfigPatch, + PluginConfigValue, +} from './plugin/linkcode-store'; +export type { + LinkCodeMarketplaceService, + MarketplaceCatalogEntry, + MarketplaceRefreshResult, +} from './plugin/market-service'; export { PreviewRouteRegistry } from './preview/route-registry'; export type { ResourceStore } from './resource/resource-store'; export { diff --git a/packages/host/engine/src/plugin/config-request-handler.ts b/packages/host/engine/src/plugin/config-request-handler.ts new file mode 100644 index 000000000..6da78a133 --- /dev/null +++ b/packages/host/engine/src/plugin/config-request-handler.ts @@ -0,0 +1,67 @@ +import type { WirePayload } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { Effect } from 'effect'; +import type { WireResponder } from '../wire/responder'; +import type { PluginConfigService } from './config-service'; + +type PluginConfigRequest = Extract< + WirePayload, + { kind: 'plugin-config.list.get' | 'plugin-config.set' } +>; + +/** Serves the LinkCode plugin settings wire plane: masked list and per-key patch writes. */ +export class LinkCodePluginConfigRequestHandler { + constructor( + private readonly transport: Transport, + private readonly config: PluginConfigService, + private readonly responder: WireResponder, + ) {} + + handle(payload: PluginConfigRequest): Effect.Effect { + switch (payload.kind) { + case 'plugin-config.list.get': + return this.responder.reply( + payload.clientReqId, + Effect.sync(() => { + const plugins = this.config.list().map((view) => ({ + id: view.id, + version: view.version, + settings: view.settings, + values: view.values, + })); + this.transport.send( + createWireMessage({ + kind: 'plugin-config.listed', + replyTo: payload.clientReqId, + plugins, + }), + ); + }), + ); + case 'plugin-config.set': + return this.responder.reply( + payload.clientReqId, + this.config + .applyPatch(payload.pluginId, { set: payload.set, remove: payload.remove }) + .pipe( + Effect.flatMap(() => + Effect.sync(() => { + const values = this.config.maskedValues(payload.pluginId); + this.transport.send( + createWireMessage({ + kind: 'plugin-config.updated', + replyTo: payload.clientReqId, + pluginId: payload.pluginId, + values, + }), + ); + }), + ), + ), + ); + default: + return Effect.void; + } + } +} diff --git a/packages/host/engine/src/plugin/config-service.ts b/packages/host/engine/src/plugin/config-service.ts new file mode 100644 index 000000000..e14037211 --- /dev/null +++ b/packages/host/engine/src/plugin/config-service.ts @@ -0,0 +1,95 @@ +import type { LinkCodePluginSettings } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { OperationError, RequestError } from '../failure'; +import type { + InstalledLinkCodePluginEntry, + LinkCodePluginStore, + PluginConfigValue, +} from './linkcode-store'; + +/** A plugin's settings as the wire exposes them: the manifest's field schemas plus the non-secret + * values. Secret fields appear in `settings` (so the client renders a masked input) but never in + * `values` — the same masked-edit contract custom-MCP uses. */ +export interface PluginConfigView { + readonly id: string; + readonly version: string; + readonly settings: LinkCodePluginSettings; + readonly values: Readonly>; +} + +/** + * Services the `plugin-config.*` wire plane from the {@link LinkCodePluginStore}: masked reads and + * per-key patch writes. The store owns persistence and the secret/non-secret split; this service + * only masks and validates against the store's own manifest source. + */ +export class PluginConfigService { + constructor(private readonly store: LinkCodePluginStore) {} + + list(): PluginConfigView[] { + return this.store + .list() + .flatMap((entry) => viewFor(entry, this.store.getSettings(entry.installed.id))); + } + + /** Per-key patch; the store splits secret vs non-secret per the manifest. */ + applyPatch( + pluginId: string, + patch: { set?: Readonly>; remove?: readonly string[] }, + ): Effect.Effect { + return Effect.suspend((): Effect.Effect => { + if (this.store.get(pluginId) === undefined) { + return Effect.fail( + new RequestError({ code: 'not_found', message: `Unknown plugin: ${pluginId}` }), + ); + } + return Effect.tryPromise({ + try: async () => { + await this.store.setSettings(pluginId, patch); + }, + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'plugin-config.set', + publicMessage: 'Failed to persist the plugin config', + cause, + }), + }); + }); + } + + /** Post-patch masked re-read, so the client patches one cache entry instead of re-listing. */ + maskedValues(pluginId: string): Readonly> { + const entry = this.store.get(pluginId); + if (entry === undefined) return {}; + return maskValues(entry, this.store.getSettings(pluginId)); + } +} + +function viewFor( + entry: InstalledLinkCodePluginEntry, + merged: Record, +): PluginConfigView[] { + if (entry.manifest.settings === undefined) return []; + return [ + { + id: entry.installed.id, + version: entry.installed.version, + settings: entry.manifest.settings, + values: maskValues(entry, merged), + }, + ]; +} + +function maskValues( + entry: InstalledLinkCodePluginEntry, + merged: Record, +): Record { + const settings = entry.manifest.settings; + if (settings === undefined) return {}; + const masked: Record = {}; + for (const [fieldId, field] of Object.entries(settings)) { + if (field.secret) continue; + if (fieldId in merged) masked[fieldId] = merged[fieldId]; + } + return masked; +} diff --git a/packages/host/engine/src/plugin/linkcode-store.ts b/packages/host/engine/src/plugin/linkcode-store.ts new file mode 100644 index 000000000..318ae7f6f --- /dev/null +++ b/packages/host/engine/src/plugin/linkcode-store.ts @@ -0,0 +1,97 @@ +import type { + InstalledLinkCodePlugin, + LinkCodeMarketplaceId, + LinkCodePluginManifest, + LinkCodePluginRelease, +} from '@linkcode/schema'; + +/** An installed LinkCode plugin: its install record plus the parsed manifest. */ +export interface InstalledLinkCodePluginEntry { + readonly installed: InstalledLinkCodePlugin; + readonly manifest: LinkCodePluginManifest; +} + +/** A stored setting value: the restricted JSON-Schema subset a manifest may declare. */ +export type PluginConfigValue = string | number | boolean; + +/** Per-key patch over one plugin's settings; untouched keys keep their stored value. */ +export interface PluginConfigPatch { + readonly set?: Readonly>; + readonly remove?: readonly string[]; +} + +/** + * Daemon-owned LinkCode plugin store. Enumerates installed plugins (manifest + install record), + * reads/writes their declared settings (non-secret in `config.json`, secret in the vault — the + * manifest's `secret` flag decides), and installs/uninstalls releases (download + SRI + extract). + * + * The Engine reads manifests at session start to inject an MCP-server component's command, args, + * and env into StartOptions, and services the `plugin-config.*` wire from the masked settings view. + */ +export interface LinkCodePluginStore { + list(): InstalledLinkCodePluginEntry[]; + get(pluginId: string): InstalledLinkCodePluginEntry | undefined; + /** Merged setting values (non-secret from config, secret from the vault) for a plugin. */ + getSettings(pluginId: string): Record; + /** Per-key patch; the store splits secret vs non-secret per the manifest. */ + setSettings(pluginId: string, patch: PluginConfigPatch): Promise; + install( + release: LinkCodePluginRelease, + marketplaceId: LinkCodeMarketplaceId, + ): Promise; + uninstall(pluginId: string): Promise; +} + +/** In-memory store for tests and standalone Engine use; no persistence, no install. */ +export class InMemoryLinkCodePluginStore implements LinkCodePluginStore { + private readonly entries = new Map(); + private readonly values = new Map>(); + + seed( + entry: InstalledLinkCodePluginEntry, + settings: Record = {}, + ): void { + this.entries.set(entry.installed.id, entry); + const map = new Map(); + for (const [k, v] of Object.entries(settings)) map.set(k, v); + this.values.set(entry.installed.id, map); + } + + list(): InstalledLinkCodePluginEntry[] { + return [...this.entries.values()]; + } + + get(pluginId: string): InstalledLinkCodePluginEntry | undefined { + return this.entries.get(pluginId); + } + + getSettings(pluginId: string): Record { + const map = this.values.get(pluginId); + if (!map) return {}; + return Object.fromEntries(map); + } + + setSettings(pluginId: string, patch: PluginConfigPatch): Promise { + let map = this.values.get(pluginId); + if (!map) { + map = new Map(); + this.values.set(pluginId, map); + } + if (patch.remove) for (const key of patch.remove) map.delete(key); + if (patch.set) for (const [k, v] of Object.entries(patch.set)) map.set(k, v); + return Promise.resolve(); + } + + install( + _release: LinkCodePluginRelease, + _marketplaceId: LinkCodeMarketplaceId, + ): Promise { + return Promise.reject(new Error('InMemoryLinkCodePluginStore cannot install')); + } + + uninstall(pluginId: string): Promise { + this.entries.delete(pluginId); + this.values.delete(pluginId); + return Promise.resolve(); + } +} diff --git a/packages/host/engine/src/plugin/market-request-handler.ts b/packages/host/engine/src/plugin/market-request-handler.ts new file mode 100644 index 000000000..873ba6c19 --- /dev/null +++ b/packages/host/engine/src/plugin/market-request-handler.ts @@ -0,0 +1,176 @@ +import type { WirePayload } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { Effect } from 'effect'; +import { OperationError, RequestError } from '../failure'; +import type { WireResponder } from '../wire/responder'; +import type { LinkCodePluginStore } from './linkcode-store'; +import type { LinkCodeMarketplaceService, MarketplaceRefreshResult } from './market-service'; + +type PluginMarketRequest = Extract< + WirePayload, + { + kind: + | 'plugin-market.list.get' + | 'plugin-market.refresh' + | 'plugin-market.install' + | 'plugin-market.uninstall'; + } +>; + +/** Serves the LinkCode marketplace wire plane: catalog list/refresh and install/uninstall. */ +export class LinkCodePluginMarketRequestHandler { + constructor( + private readonly transport: Transport, + private readonly marketplace: LinkCodeMarketplaceService | undefined, + private readonly store: LinkCodePluginStore, + private readonly responder: WireResponder, + ) {} + + handle(payload: PluginMarketRequest): Effect.Effect { + switch (payload.kind) { + case 'plugin-market.list.get': + return this.responder.reply( + payload.clientReqId, + Effect.sync(() => { + this.transport.send( + createWireMessage({ + kind: 'plugin-market.listed', + replyTo: payload.clientReqId, + marketplaces: this.marketplace?.list() ?? [], + }), + ); + }), + ); + case 'plugin-market.refresh': + return this.responder.reply( + payload.clientReqId, + this.refresh(payload.marketplaceId).pipe( + Effect.flatMap((result) => + Effect.sync(() => + this.transport.send( + createWireMessage({ + kind: 'plugin-market.refreshed', + replyTo: payload.clientReqId, + marketplaceId: payload.marketplaceId, + releases: [...result.releases], + ...(result.notModified === true && { notModified: true }), + }), + ), + ), + ), + ), + ); + case 'plugin-market.install': + return this.responder.reply(payload.clientReqId, this.install(payload)); + case 'plugin-market.uninstall': + return this.responder.reply( + payload.clientReqId, + Effect.tryPromise({ + try: async () => { + await this.store.uninstall(payload.pluginId); + this.transport.send( + createWireMessage({ + kind: 'plugin-market.uninstalled', + replyTo: payload.clientReqId, + pluginId: payload.pluginId, + }), + ); + }, + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'plugin-market.uninstall', + publicMessage: 'Failed to uninstall the plugin', + cause, + }), + }), + ); + default: + return Effect.void; + } + } + + private refresh( + marketplaceId: string, + ): Effect.Effect { + return Effect.suspend( + (): Effect.Effect => { + const marketplace = this.marketplace; + if (marketplace === undefined) { + return Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'Plugin marketplaces are unavailable on this host', + }), + ); + } + if (!marketplace.list().some((entry) => entry.id === marketplaceId)) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown marketplace: ${marketplaceId}`, + }), + ); + } + return Effect.tryPromise({ + try: () => marketplace.refresh(marketplaceId), + catch: (cause) => + new OperationError({ + subsystem: 'plugin', + operation: 'plugin-market.refresh', + publicMessage: 'Failed to refresh the marketplace index', + cause, + }), + }); + }, + ); + } + + private install( + payload: Extract, + ): Effect.Effect { + return Effect.suspend((): Effect.Effect => { + const marketplace = this.marketplace; + if (marketplace === undefined) { + return Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'Plugin marketplaces are unavailable on this host', + }), + ); + } + const release = marketplace.resolveRelease(payload.release); + if (release === undefined) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown marketplace release: ${payload.release.pluginId}@${payload.release.version}`, + }), + ); + } + const identity = payload.release; + return Effect.tryPromise({ + try: async () => { + await this.store.install(release, identity.marketplaceId); + this.transport.send( + createWireMessage({ + kind: 'plugin-market.installed', + replyTo: payload.clientReqId, + marketplaceId: identity.marketplaceId, + pluginId: identity.pluginId, + version: identity.version, + }), + ); + }, + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'plugin-market.install', + publicMessage: 'Failed to install the plugin', + cause, + }), + }); + }); + } +} diff --git a/packages/host/engine/src/plugin/market-service.ts b/packages/host/engine/src/plugin/market-service.ts new file mode 100644 index 000000000..ed79f5f06 --- /dev/null +++ b/packages/host/engine/src/plugin/market-service.ts @@ -0,0 +1,33 @@ +import type { + LinkCodeMarketplaceConfig, + LinkCodeMarketplaceId, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginId, + LinkCodePluginRelease, +} from '@linkcode/schema'; + +/** One catalog row: a plugin id paired with a release its marketplace index advertised. */ +export interface MarketplaceCatalogEntry { + readonly pluginId: LinkCodePluginId; + readonly release: LinkCodePluginRelease; +} + +/** Result of one marketplace index refresh. */ +export interface MarketplaceRefreshResult { + /** Releases the index advertised, already filtered to what this build can represent. */ + readonly releases: readonly MarketplaceCatalogEntry[]; + /** True when the index was unchanged (304 / matching validators); releases is the cached catalog. */ + readonly notModified?: boolean; +} + +/** + * Daemon-owned marketplace plane: the configured marketplace list, an HTTP index refresh with + * cached validators, and a network-free lookup over the last cached index. The daemon supplies the + * persistent implementation; an absent Engine replies `unsupported` for refresh/install. + */ +export interface LinkCodeMarketplaceService { + list(): LinkCodeMarketplaceConfig[]; + refresh(marketplaceId: LinkCodeMarketplaceId): Promise; + /** Reads the cached index only — a refresh is what moves the catalog. */ + resolveRelease(identity: LinkCodeMarketplaceReleaseIdentity): LinkCodePluginRelease | undefined; +} diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index f22ac181c..08e894f61 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -1,4 +1,5 @@ -import type { AgentKind, McpWarning, SessionId, StartOptions } from '@linkcode/schema'; +import { resolve as resolvePath } from 'node:path'; +import type { AgentKind, McpServer, McpWarning, SessionId, StartOptions } from '@linkcode/schema'; import { Effect } from 'effect'; import { isObjectEmpty } from 'foxts/is-object-empty'; import type { CustomMcpServerService } from '../agent/custom-mcp-service'; @@ -7,6 +8,7 @@ import { applyProviderDefaults } from '../agent/provider-config'; import type { TranslatorService } from '../agent/translator'; import { translationUpstream, withTranslatorEndpoint } from '../agent/translator'; import { OperationError, RequestError } from '../failure'; +import type { LinkCodePluginStore } from '../plugin/linkcode-store'; import type { PluginService } from '../plugin/service'; import type { SimulatorMcpProvider } from '../simulator/mcp'; import { MCP_CAPABLE_AGENT_KINDS, SIMULATOR_MCP_SERVER_NAME } from './mcp-capability'; @@ -28,6 +30,7 @@ export class SessionStartOptionsResolver { private readonly simulatorMcp?: SimulatorMcpProvider, private readonly customMcp?: CustomMcpServerService, private readonly plugins?: PluginService, + private readonly linkCodePluginStore?: LinkCodePluginStore, ) {} resolve( @@ -44,6 +47,7 @@ export class SessionStartOptionsResolver { const { translator } = this; const withCustomMcpServers = this.withCustomMcpServers.bind(this); const withSimulatorMcp = this.withSimulatorMcp.bind(this); + const withPluginMcpServers = this.withPluginMcpServers.bind(this); return Effect.gen(function* () { if (defaults.unavailable) { // Starting anyway would point the agent at an endpoint it cannot speak, which surfaces @@ -66,9 +70,10 @@ export class SessionStartOptionsResolver { ); } const custom = yield* withCustomMcpServers(defaults.options); - const resolved = withSimulatorMcp(custom.options, sessionId); + const pluginInjected = withPluginMcpServers(custom.options, custom.warnings); + const resolved = withSimulatorMcp(pluginInjected.options, sessionId); const upstream = translationUpstream(resolved); - if (!upstream) return { options: resolved, ...account, warnings: custom.warnings }; + if (!upstream) return { options: resolved, ...account, warnings: pluginInjected.warnings }; if (!translator) { return yield* Effect.fail( new RequestError({ @@ -102,6 +107,14 @@ export class SessionStartOptionsResolver { if (!MCP_CAPABLE_AGENT_KINDS.has(kind)) return []; const names = (this.customMcp?.listEnabled() ?? []).map((entry) => entry.server.name); if (this.simulatorMcp) names.push(SIMULATOR_MCP_SERVER_NAME); + if (this.linkCodePluginStore) { + for (const entry of this.linkCodePluginStore.list()) { + if (!entry.installed.enabled) continue; + for (const component of entry.manifest.components) { + if (component.kind === 'mcp-server') names.push(component.name); + } + } + } return names; } @@ -165,6 +178,64 @@ export class SessionStartOptionsResolver { ); } + /** Fold enabled LinkCode plugin mcp-server components into the session's server list, resolving + * each component's `env` mapping against the plugin's stored settings. Same warning contract as + * custom-MCP: an unsupported agent or a name collision is a user-visible advisory, not a drop. */ + private withPluginMcpServers( + options: StartOptions, + warnings: McpWarning[], + ): { options: StartOptions; warnings: McpWarning[] } { + const store = this.linkCodePluginStore; + if (store === undefined) return { options, warnings }; + const entries = store.list().filter((entry) => entry.installed.enabled); + if (entries.length === 0) return { options, warnings }; + if (!MCP_CAPABLE_AGENT_KINDS.has(options.kind)) { + for (const entry of entries) { + for (const component of entry.manifest.components) { + if (component.kind === 'mcp-server') { + warnings.push({ serverName: component.name, reason: 'agent-unsupported' }); + } + } + } + return { options, warnings }; + } + const servers = [...(options.mcpServers ?? [])]; + for (const entry of entries) { + const settings = store.getSettings(entry.installed.id); + for (const component of entry.manifest.components) { + if (component.kind !== 'mcp-server') continue; + if (servers.some((server) => server.name === component.name)) { + warnings.push({ serverName: component.name, reason: 'name-conflict' }); + continue; + } + const env: Record = {}; + if (component.env) { + for (const [envVar, settingId] of Object.entries(component.env)) { + if (settingId in settings) env[envVar] = String(settings[settingId]); + } + } + const server: McpServer = { + type: 'stdio', + name: component.name, + command: component.command, + ...(component.entry && { + args: [resolvePath(entry.installed.path, component.entry), ...(component.args ?? [])], + }), + ...(!component.entry && component.args && { args: component.args }), + ...(!isObjectEmpty(env) && { env }), + }; + servers.push(server); + } + } + return { + options: + servers.length === 0 && options.mcpServers === undefined + ? options + : { ...options, mcpServers: servers }, + warnings, + }; + } + /** Append the session's simulator MCP endpoint for agents whose SDK can consume it. */ private withSimulatorMcp(options: StartOptions, sessionId: SessionId): StartOptions { if (!this.simulatorMcp || !MCP_CAPABLE_AGENT_KINDS.has(options.kind)) return options; diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index cede0f6e7..32787007f 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -8,6 +8,8 @@ import type { AutomationRequestHandler } from '../automation/request-handler'; import type { BrowserRequestHandler } from '../browser/request-handler'; import type { GitRequestHandler } from '../git/request-handler'; import { observeRequest } from '../observability'; +import type { LinkCodePluginConfigRequestHandler } from '../plugin/config-request-handler'; +import type { LinkCodePluginMarketRequestHandler } from '../plugin/market-request-handler'; import type { PluginRequestHandler } from '../plugin/request-handler'; import type { ArtifactRequestHandler } from '../preview/request-handler'; import type { ResourceRequestHandler } from '../resource/request-handler'; @@ -27,6 +29,8 @@ interface RequestHandlers { readonly workspace: WorkspaceRequestHandler; readonly git: GitRequestHandler; readonly plugin: PluginRequestHandler; + readonly linkCodePluginConfig: LinkCodePluginConfigRequestHandler; + readonly linkCodePluginMarket: LinkCodePluginMarketRequestHandler; readonly file: FileRequestHandler; readonly script: ScriptRequestHandler; readonly artifact: ArtifactRequestHandler; @@ -108,6 +112,16 @@ export class WireRequestRouter { case 'skill.set-enabled': { return this.handlers.plugin.handle(p); } + case 'plugin-config.list.get': + case 'plugin-config.set': { + return this.handlers.linkCodePluginConfig.handle(p); + } + case 'plugin-market.list.get': + case 'plugin-market.refresh': + case 'plugin-market.install': + case 'plugin-market.uninstall': { + return this.handlers.linkCodePluginMarket.handle(p); + } case 'file.read': case 'file.list': case 'file.suggest': diff --git a/packages/integrations/mail-mcp/package.json b/packages/integrations/mail-mcp/package.json new file mode 100644 index 000000000..847c53ced --- /dev/null +++ b/packages/integrations/mail-mcp/package.json @@ -0,0 +1,30 @@ +{ + "name": "@linkcode/mail-mcp", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "bin": { + "mail-mcp": "./dist/index.js" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "lint": "eslint --format=sukka ." + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "foxts": "^5.8.0", + "imapflow": "^1.7.2", + "nodemailer": "^9.0.5", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/nodemailer": "^8.0.1", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/integrations/mail-mcp/src/__tests__/body.test.ts b/packages/integrations/mail-mcp/src/__tests__/body.test.ts new file mode 100644 index 000000000..35c4fead7 --- /dev/null +++ b/packages/integrations/mail-mcp/src/__tests__/body.test.ts @@ -0,0 +1,156 @@ +import type { MessageStructureObject } from 'imapflow'; +import { describe, expect, it } from 'vitest'; +import { + collectAttachments, + decodeBodyPart, + pickPreferredPart, + selectReadableParts, + truncate, +} from '../body'; + +function part(opts: Partial & { type: string }): MessageStructureObject { + return { ...opts }; +} + +describe('selectReadableParts', () => { + it('returns nothing for an empty structure', () => { + expect(selectReadableParts()).toEqual([]); + }); + + it('collects text/plain and text/html leaves from a multipart/alternative', () => { + const root = part({ + type: 'multipart/alternative', + childNodes: [ + part({ type: 'text/plain', part: '1', parameters: { charset: 'utf-8' } }), + part({ type: 'text/html', part: '2' }), + ], + }); + const readable = selectReadableParts(root); + expect(readable.map((p) => p.part)).toEqual(['1', '2']); + expect(readable[0].contentType).toBe('text/plain'); + }); + + it('ignores non-text leaves', () => { + const root = part({ + type: 'multipart/mixed', + childNodes: [ + part({ type: 'text/plain', part: '1' }), + part({ + type: 'application/pdf', + part: '2', + disposition: 'attachment', + dispositionParameters: { filename: 'a.pdf' }, + }), + ], + }); + expect(selectReadableParts(root).map((p) => p.part)).toEqual(['1']); + }); +}); + +describe('pickPreferredPart', () => { + it('prefers text/plain over html', () => { + const parts = selectReadableParts( + part({ + type: 'multipart/alternative', + childNodes: [ + part({ type: 'text/html', part: '1' }), + part({ type: 'text/plain', part: '2' }), + ], + }), + ); + expect(pickPreferredPart(parts)?.contentType).toBe('text/plain'); + }); + + it('returns undefined when empty', () => { + expect(pickPreferredPart([])).toBeUndefined(); + }); +}); + +describe('collectAttachments', () => { + it('captures attachments and inline non-text parts, skipping the body', () => { + const root = part({ + type: 'multipart/mixed', + childNodes: [ + part({ type: 'text/plain', part: '1' }), + part({ + type: 'application/pdf', + part: '2', + disposition: 'attachment', + dispositionParameters: { filename: 'a.pdf' }, + size: 1024, + }), + part({ + type: 'image/png', + part: '3', + disposition: 'inline', + dispositionParameters: { filename: 'img.png' }, + }), + ], + }); + const attachments = collectAttachments(root); + expect(attachments).toHaveLength(2); + expect(attachments[0]).toMatchObject({ + part: '2', + filename: 'a.pdf', + contentType: 'application/pdf', + size: 1024, + }); + expect(attachments[1]).toMatchObject({ + part: '3', + filename: 'img.png', + contentType: 'image/png', + }); + }); + + it('treats a text/* part with disposition=attachment as an attachment', () => { + const root = part({ + type: 'multipart/mixed', + childNodes: [ + part({ type: 'text/plain', part: '1' }), + part({ + type: 'text/csv', + part: '2', + disposition: 'attachment', + dispositionParameters: { filename: 'data.csv' }, + }), + ], + }); + const attachments = collectAttachments(root); + expect(attachments).toHaveLength(1); + expect(attachments[0].filename).toBe('data.csv'); + }); +}); + +describe('decodeBodyPart', () => { + it('decodes utf-8 bytes', () => { + const buf = Buffer.from('héllo', 'utf-8'); + expect(decodeBodyPart(buf, 'utf-8')).toBe('héllo'); + }); + + it('decodes gbk bytes when charset is gb2312', () => { + // "中" in GBK is 0xD6 0xD0. + const buf = Buffer.from([0xd6, 0xd0]); + expect(decodeBodyPart(buf, 'gb2312')).toBe('中'); + }); + + it('falls back to utf-8 on an unknown charset', () => { + const buf = Buffer.from('ok', 'utf-8'); + expect(decodeBodyPart(buf, 'not-a-real-charset')).toBe('ok'); + }); + + it('returns empty for undefined buffer', () => { + expect(decodeBodyPart(undefined)).toBe(''); + }); +}); + +describe('truncate', () => { + it('returns the text unchanged when within the limit', () => { + expect(truncate('abc', 10)).toBe('abc'); + }); + + it('slices and notes the overflow', () => { + const out = truncate('abcdefghij', 4); + expect(out.startsWith('abcd')).toBe(true); + expect(out).toContain('6 chars'); + }); +}); diff --git a/packages/integrations/mail-mcp/src/__tests__/config.test.ts b/packages/integrations/mail-mcp/src/__tests__/config.test.ts new file mode 100644 index 000000000..ccb744718 --- /dev/null +++ b/packages/integrations/mail-mcp/src/__tests__/config.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigError, loadConfig } from '../config'; + +describe('loadConfig presets', () => { + it.each([ + ['163', 'imap.163.com', 993, 'smtp.163.com', 465], + ['qq', 'imap.qq.com', 993, 'smtp.qq.com', 465], + ['exmail', 'imap.exmail.qq.com', 993, 'smtp.exmail.qq.com', 465], + ] as const)( + 'preset=%s fills host/port and secure=true', + (preset, imapHost, imapPort, smtpHost, smtpPort) => { + const cfg = loadConfig({ MAIL_USER: 'u@x.com', MAIL_PASSWORD: 'code', MAIL_PRESET: preset }); + expect(cfg.imap.host).toBe(imapHost); + expect(cfg.imap.port).toBe(imapPort); + expect(cfg.imap.secure).toBe(true); + expect(cfg.smtp.host).toBe(smtpHost); + expect(cfg.smtp.port).toBe(smtpPort); + expect(cfg.smtp.secure).toBe(true); + }, + ); + + it('defaults SMTP_USER/SMTP_PASSWORD/SMTP_FROM to the mail account', () => { + const cfg = loadConfig({ MAIL_USER: 'u@163.com', MAIL_PASSWORD: 'code', MAIL_PRESET: '163' }); + expect(cfg.smtp.user).toBe('u@163.com'); + expect(cfg.smtp.password).toBe('code'); + expect(cfg.smtpFrom).toBe('u@163.com'); + }); +}); + +describe('loadConfig overrides', () => { + it('custom IMAP host with IMAP_SECURE=false derives port 143', () => { + const cfg = loadConfig({ + MAIL_USER: 'u', + MAIL_PASSWORD: 'p', + MAIL_PRESET: '163', + IMAP: 'imap.x.com', + IMAP_SECURE: 'false', + }); + expect(cfg.imap.host).toBe('imap.x.com'); + expect(cfg.imap.secure).toBe(false); + expect(cfg.imap.port).toBe(143); + }); + + it('SMTP_USER/SMTP_PASSWORD/SMTP_FROM override', () => { + const cfg = loadConfig({ + MAIL_USER: 'u', + MAIL_PASSWORD: 'p', + MAIL_PRESET: '163', + SMTP_USER: 'smtpu', + SMTP_PASSWORD: 'smtpp', + SMTP_FROM: 'from@x.com', + }); + expect(cfg.smtp.user).toBe('smtpu'); + expect(cfg.smtp.password).toBe('smtpp'); + expect(cfg.smtpFrom).toBe('from@x.com'); + }); + + it('no preset falls back to default ports from secure', () => { + const cfg = loadConfig({ + MAIL_USER: 'u', + MAIL_PASSWORD: 'p', + IMAP: 'imap.x.com', + SMTP: 'smtp.x.com', + }); + expect(cfg.imap.port).toBe(993); + expect(cfg.smtp.port).toBe(465); + }); +}); + +describe('loadConfig errors', () => { + it('throws on missing MAIL_USER', () => { + expect(() => loadConfig({ MAIL_PASSWORD: 'p', MAIL_PRESET: '163' })).toThrow(ConfigError); + }); + it('throws on missing MAIL_PASSWORD', () => { + expect(() => loadConfig({ MAIL_USER: 'u', MAIL_PRESET: '163' })).toThrow(ConfigError); + }); + it('throws on missing host without preset', () => { + expect(() => loadConfig({ MAIL_USER: 'u', MAIL_PASSWORD: 'p' })).toThrow(ConfigError); + }); + it('throws on unknown preset', () => { + expect(() => loadConfig({ MAIL_USER: 'u', MAIL_PASSWORD: 'p', MAIL_PRESET: 'gmail' })).toThrow( + ConfigError, + ); + }); +}); + +describe('loadConfig maxBodyChars', () => { + const base = { MAIL_USER: 'u', MAIL_PASSWORD: 'p', MAIL_PRESET: '163' }; + it('defaults to 8000', () => { + expect(loadConfig(base).maxBodyChars).toBe(8000); + }); + it('clamps below the minimum up to 100', () => { + expect(loadConfig({ ...base, MAX_BODY_CHARS: '50' }).maxBodyChars).toBe(100); + }); + it('clamps above the maximum down to 100000', () => { + expect(loadConfig({ ...base, MAX_BODY_CHARS: '999999' }).maxBodyChars).toBe(100000); + }); + it('falls back to default on garbage', () => { + expect(loadConfig({ ...base, MAX_BODY_CHARS: 'garbage' }).maxBodyChars).toBe(8000); + }); + it('accepts a valid value', () => { + expect(loadConfig({ ...base, MAX_BODY_CHARS: '5000' }).maxBodyChars).toBe(5000); + }); +}); diff --git a/packages/integrations/mail-mcp/src/__tests__/imap.test.ts b/packages/integrations/mail-mcp/src/__tests__/imap.test.ts new file mode 100644 index 000000000..b080e6db5 --- /dev/null +++ b/packages/integrations/mail-mcp/src/__tests__/imap.test.ts @@ -0,0 +1,223 @@ +import type { MailboxObject } from 'imapflow'; +import { describe, expect, it, vi } from 'vitest'; +import type { ImapFlowPort, MailImapClient, ReplyOrigin } from '../imap'; +import { MailImap } from '../imap'; +import type { MailConfig } from '../types'; + +function makeConfig(user = 'me@x.com'): MailConfig { + return { + imap: { host: 'h', port: 993, secure: true, user, password: 'p' }, + smtp: { host: 'h', port: 465, secure: true, user, password: 'p' }, + smtpFrom: user, + maxBodyChars: 8000, + }; +} + +function makeMailbox(exists: number): MailboxObject { + return { + path: 'INBOX', + delimiter: '/', + flags: new Set(), + uidValidity: 1n, + uidNext: 1, + exists, + }; +} + +const lock = { release: vi.fn() }; + +function makeFlow(overrides: Partial = {}, mailboxExists = 10): ImapFlowPort { + return { + mailbox: makeMailbox(mailboxExists), + connect: vi.fn(), + logout: vi.fn(), + close: vi.fn(), + list: vi.fn(), + getMailboxLock: vi.fn().mockResolvedValue(lock), + search: vi.fn(), + fetchOne: vi.fn(), + fetchAll: vi.fn(), + messageFlagsAdd: vi.fn(), + messageFlagsRemove: vi.fn(), + messageMove: vi.fn(), + ...overrides, + }; +} + +function makeImap(flow: ImapFlowPort): MailImapClient { + return new MailImap(makeConfig(), () => flow); +} + +describe('MailImap.listFolders', () => { + it('maps folders with status counts', async () => { + const list = vi.fn().mockResolvedValue([ + { + path: 'INBOX', + specialUse: String.raw`\Inbox`, + status: { path: 'INBOX', messages: 5, unseen: 2 }, + }, + { path: 'Sent', specialUse: String.raw`\Sent`, status: { path: 'Sent', messages: 3 } }, + ]); + const folders = await makeImap(makeFlow({ list })).listFolders(); + expect(folders).toEqual([ + { path: 'INBOX', specialUse: String.raw`\Inbox`, messages: 5, unseen: 2 }, + { path: 'Sent', specialUse: String.raw`\Sent`, messages: 3, unseen: undefined }, + ]); + }); +}); + +describe('MailImap.listMessages', () => { + it('fetches the last N by sequence range and returns newest-first', async () => { + const fetchAll = vi.fn().mockImplementation((range: string) => { + expect(range).toBe('1:10'); + return Promise.resolve([ + { uid: 1, seq: 1, envelope: { subject: 'old' } }, + { uid: 9, seq: 9, envelope: { subject: 'new' } }, + ]); + }); + const msgs = await makeImap(makeFlow({ fetchAll })).listMessages('INBOX', 10); + expect(msgs.map((m) => m.uid)).toEqual([9, 1]); + }); + + it('returns empty when the folder has no messages', async () => { + expect(await makeImap(makeFlow({}, 0)).listMessages('INBOX', 20)).toEqual([]); + }); +}); + +describe('MailImap.searchMessages', () => { + it('caps matched UIDs to the limit and sorts newest-first', async () => { + const search = vi.fn().mockResolvedValue([1, 2, 3, 4, 5]); + const fetchAll = vi.fn().mockImplementation((uids: number[]) => { + expect(uids).toEqual([4, 5]); + return Promise.resolve([ + { uid: 5, seq: 5, envelope: {} }, + { uid: 4, seq: 4, envelope: {} }, + ]); + }); + const msgs = await makeImap(makeFlow({ search, fetchAll })).searchMessages( + 'INBOX', + { subject: 'x' }, + 2, + ); + expect(msgs.map((m) => m.uid)).toEqual([5, 4]); + }); + + it('returns empty when search yields nothing', async () => { + const search = vi.fn().mockResolvedValue(false); + expect(await makeImap(makeFlow({ search })).searchMessages('INBOX', { from: 'x' }, 20)).toEqual( + [], + ); + }); +}); + +describe('MailImap.getMessage', () => { + const meta = { + uid: 7, + seq: 7, + envelope: { + subject: 'Hi', + from: [{ name: 'A', address: 'a@x.com' }], + to: [{ address: 'me@x.com' }], + messageId: '', + date: new Date('2026-01-01T00:00:00Z'), + }, + bodyStructure: { + type: 'multipart/mixed', + childNodes: [ + { type: 'text/plain', part: '1', parameters: { charset: 'utf-8' } }, + { + type: 'application/pdf', + part: '2', + disposition: 'attachment', + dispositionParameters: { filename: 'a.pdf' }, + size: 100, + }, + ], + }, + flags: new Set([String.raw`\Seen`]), + size: 42, + }; + const bodyMsg = { bodyParts: new Map([['1', Buffer.from('hello body', 'utf-8')]]) }; + + it('decodes the preferred text part, truncates, and lists attachments', async () => { + const fetchOne = vi + .fn() + .mockImplementation((_seq: number, query: { bodyStructure?: unknown; bodyParts?: unknown }) => + Promise.resolve(query.bodyParts ? bodyMsg : meta), + ); + const msg = await makeImap(makeFlow({ fetchOne })).getMessage('INBOX', 7); + expect(msg.uid).toBe(7); + expect(msg.subject).toBe('Hi'); + expect(msg.from).toBe('A '); + expect(msg.body).toBe('hello body'); + expect(msg.attachments).toEqual([ + { part: '2', filename: 'a.pdf', contentType: 'application/pdf', size: 100 }, + ]); + }); + + it('truncates a body over the configured limit', async () => { + const big = { bodyParts: new Map([['1', Buffer.from('x'.repeat(9000), 'utf-8')]]) }; + const fetchOne = vi + .fn() + .mockImplementation((_seq: number, query: { bodyStructure?: unknown; bodyParts?: unknown }) => + Promise.resolve(query.bodyParts ? big : meta), + ); + const msg = await makeImap(makeFlow({ fetchOne })).getMessage('INBOX', 7); + expect(msg.body?.length).toBeLessThan(9000); + expect(msg.body).toContain('truncated'); + }); + + it('throws when the message is missing', async () => { + const fetchOne = vi.fn().mockResolvedValue(false); + await expect(makeImap(makeFlow({ fetchOne })).getMessage('INBOX', 9)).rejects.toThrow( + 'not found', + ); + }); +}); + +describe('MailImap.getReplyOrigin', () => { + it('parses a folded References header into a chain', async () => { + const meta = { + envelope: { + subject: 'thread', + from: [{ address: 'a@x.com' }], + to: [{ address: 'me@x.com' }], + cc: [{ address: 'b@x.com' }], + messageId: '', + }, + headers: Buffer.from('References: \r\n \r\nOther: x\r\n', 'utf-8'), + }; + const fetchOne = vi.fn().mockResolvedValue(meta); + const origin: ReplyOrigin = await makeImap(makeFlow({ fetchOne })).getReplyOrigin('INBOX', 1); + expect(origin.references).toEqual(['', '']); + expect(origin.from).toEqual([{ address: 'a@x.com' }]); + expect(origin.messageId).toBe(''); + }); +}); + +describe('MailImap.markRead / moveMessage', () => { + it(String.raw`adds \Seen when read=true`, async () => { + const messageFlagsAdd = vi.fn().mockResolvedValue(true); + await makeImap(makeFlow({ messageFlagsAdd })).markRead('INBOX', 5, true); + expect(messageFlagsAdd).toHaveBeenCalledWith(5, [String.raw`\Seen`], { uid: true }); + }); + + it(String.raw`removes \Seen when read=false`, async () => { + const messageFlagsRemove = vi.fn().mockResolvedValue(true); + await makeImap(makeFlow({ messageFlagsRemove })).markRead('INBOX', 5, false); + expect(messageFlagsRemove).toHaveBeenCalledWith(5, [String.raw`\Seen`], { uid: true }); + }); + + it('moves a message by uid', async () => { + const messageMove = vi.fn().mockResolvedValue({ destination: 'Archive' }); + await makeImap(makeFlow({ messageMove })).moveMessage('INBOX', 5, 'Archive'); + expect(messageMove).toHaveBeenCalledWith(5, 'Archive', { uid: true }); + }); + + it('throws when move returns false', async () => { + const messageMove = vi.fn().mockResolvedValue(false); + await expect( + makeImap(makeFlow({ messageMove })).moveMessage('INBOX', 5, 'Archive'), + ).rejects.toThrow('Failed to move'); + }); +}); diff --git a/packages/integrations/mail-mcp/src/__tests__/smtp.test.ts b/packages/integrations/mail-mcp/src/__tests__/smtp.test.ts new file mode 100644 index 000000000..1f519c125 --- /dev/null +++ b/packages/integrations/mail-mcp/src/__tests__/smtp.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SmtpTransporter } from '../smtp'; +import { MailSmtp } from '../smtp'; +import type { MailConfig } from '../types'; + +function makeConfig(): MailConfig { + return { + imap: { host: 'h', port: 993, secure: true, user: 'me@x.com', password: 'p' }, + smtp: { host: 'h', port: 465, secure: true, user: 'me@x.com', password: 'p' }, + smtpFrom: 'me@x.com', + maxBodyChars: 8000, + }; +} + +function makeTransporter(): SmtpTransporter { + return { + sendMail: vi.fn().mockResolvedValue({ messageId: '', response: '250 OK' }), + close: vi.fn(), + }; +} + +describe('MailSmtp.send', () => { + it('passes through addresses and reply headers, stamps from = smtpFrom', async () => { + const sendMail = vi.fn().mockResolvedValue({ messageId: '', response: '250 OK' }); + const transporter: SmtpTransporter = { sendMail, close: vi.fn() }; + const smtp = new MailSmtp(makeConfig(), () => transporter); + const result = await smtp.send({ + to: 'a@x.com', + subject: 'hi', + body: 'body', + cc: 'c@x.com', + inReplyTo: '', + references: ['', ''], + }); + expect(result).toEqual({ messageId: '', response: '250 OK' }); + expect(sendMail).toHaveBeenCalledTimes(1); + const opts = sendMail.mock.calls[0][0] as Record; + expect(opts.from).toBe('me@x.com'); + expect(opts.to).toBe('a@x.com'); + expect(opts.cc).toBe('c@x.com'); + expect(opts.subject).toBe('hi'); + expect(opts.text).toBe('body'); + expect(opts.inReplyTo).toBe(''); + expect(opts.references).toEqual(['', '']); + }); + + it('returns an empty response string when the server omits it', async () => { + const sendMail = vi.fn().mockResolvedValue({ messageId: '' }); + const smtp = new MailSmtp(makeConfig(), () => ({ sendMail, close: vi.fn() })); + const result = await smtp.send({ to: 'a@x.com', subject: 's', body: 'b' }); + expect(result.response).toBe(''); + }); +}); + +describe('MailSmtp.close', () => { + it('closes a created transporter exactly once', async () => { + const close = vi.fn(); + const transporter: SmtpTransporter = { + sendMail: vi.fn().mockResolvedValue({ messageId: '', response: '250' }), + close, + }; + const smtp = new MailSmtp(makeConfig(), () => transporter); + await smtp.send({ to: 'a@x.com', subject: 's', body: 'b' }); + await smtp.close(); + await smtp.close(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('is a no-op before any send', async () => { + const smtp = new MailSmtp(makeConfig(), () => makeTransporter()); + await expect(smtp.close()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/integrations/mail-mcp/src/__tests__/tools.test.ts b/packages/integrations/mail-mcp/src/__tests__/tools.test.ts new file mode 100644 index 000000000..a243cb467 --- /dev/null +++ b/packages/integrations/mail-mcp/src/__tests__/tools.test.ts @@ -0,0 +1,181 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { describe, expect, it, vi } from 'vitest'; +import type { Address, MailImapClient, ReplyOrigin } from '../imap'; +import type { MailSmtpClient } from '../smtp'; +import type { MailToolDeps } from '../tools'; +import { registerMailTools } from '../tools'; + +interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +interface CapturedTool { + cb: (args: Record) => Promise; +} + +class FakeServer { + readonly tools = new Map(); + registerTool(name: string, _config: unknown, cb: CapturedTool['cb']): void { + this.tools.set(name, { cb }); + } +} + +function makeDeps( + imap: Partial, + smtp: Partial, + accountEmail = 'me@x.com', +): FakeServer { + const server = new FakeServer(); + const fullImap: MailImapClient = { + listFolders: vi.fn(), + listMessages: vi.fn(), + searchMessages: vi.fn(), + getMessage: vi.fn(), + getReplyOrigin: vi.fn(), + markRead: vi.fn(), + moveMessage: vi.fn(), + close: vi.fn(), + ...imap, + }; + const fullSmtp: MailSmtpClient = { + send: vi.fn(), + close: vi.fn(), + ...smtp, + }; + const deps: MailToolDeps = { imap: fullImap, smtp: fullSmtp, accountEmail }; + // eslint-disable-next-line sukka/type/no-force-cast-via-top-type -- test fake of a 3rd-party class; only registerTool is exercised + registerMailTools(server as unknown as McpServer, deps); + return server; +} + +function parseResult(r: ToolResult): { data: unknown; isError: boolean; text: string } { + const text = r.content[0].text; + let data: unknown = text; + try { + data = JSON.parse(text); + } catch { + // error results carry a plain message, not JSON + } + return { data, isError: r.isError === true, text }; +} + +describe('reply_message tool', () => { + const origin: ReplyOrigin = { + messageId: '', + subject: 'Hi', + from: [{ address: 'a@x.com' }] as Address[], + to: [{ address: 'me@x.com' }, { address: 'c@x.com' }] as Address[], + cc: [{ address: 'd@x.com' }] as Address[], + references: [''], + }; + + it('replyAll drops the self address and extends the references chain', async () => { + const send = vi.fn().mockResolvedValue({ messageId: '', response: '250 OK' }); + const server = makeDeps({ getReplyOrigin: vi.fn().mockResolvedValue(origin) }, { send }); + const r = await server.tools.get('reply_message')!.cb({ + folder: 'INBOX', + uid: 1, + body: 'reply', + replyAll: true, + }); + expect(parseResult(r).isError).toBe(false); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'a@x.com, c@x.com, d@x.com', + subject: 'Re: Hi', + inReplyTo: '', + references: ['', ''], + }), + ); + }); + + it('reply-to-sender only addresses From', async () => { + const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); + const server = makeDeps({ getReplyOrigin: vi.fn().mockResolvedValue(origin) }, { send }); + await server.tools.get('reply_message')!.cb({ folder: 'INBOX', uid: 1, body: 'reply' }); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: 'a@x.com' })); + }); + + it('preserves a subject already prefixed with Re:', async () => { + const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); + const server = makeDeps( + { getReplyOrigin: vi.fn().mockResolvedValue({ ...origin, subject: 'Re: Hi' }) }, + { send }, + ); + await server.tools + .get('reply_message')! + .cb({ folder: 'INBOX', uid: 1, body: 'r', replyAll: true }); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Re: Hi' })); + }); + + it('appends the original messageId only once even if already referenced', async () => { + const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); + const server = makeDeps( + { + getReplyOrigin: vi + .fn() + .mockResolvedValue({ ...origin, references: ['', ''] }), + }, + { send }, + ); + await server.tools.get('reply_message')!.cb({ folder: 'INBOX', uid: 1, body: 'r' }); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ references: ['', ''] }), + ); + }); + + it('errors when the original has no replyable From', async () => { + const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); + const server = makeDeps( + { getReplyOrigin: vi.fn().mockResolvedValue({ ...origin, from: [] as Address[] }) }, + { send }, + ); + const r = await server.tools.get('reply_message')!.cb({ folder: 'INBOX', uid: 1, body: 'r' }); + expect(parseResult(r).isError).toBe(true); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe('send_message tool', () => { + it('passes args straight through to SMTP and returns the send result', async () => { + const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); + const server = makeDeps({}, { send }); + const r = await server.tools.get('send_message')!.cb({ + to: 'a@x.com', + subject: 'hello', + body: 'hi', + cc: 'c@x.com', + }); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ to: 'a@x.com', subject: 'hello', body: 'hi', cc: 'c@x.com' }), + ); + expect(parseResult(r).data).toEqual({ messageId: '', response: '250' }); + }); +}); + +describe('list_messages tool', () => { + it('clamps the limit into the allowed range before delegating', async () => { + const listMessages = vi.fn().mockResolvedValue([]); + const server = makeDeps({ listMessages }, {}); + await server.tools.get('list_messages')!.cb({ folder: 'INBOX', limit: 9999 }); + expect(listMessages).toHaveBeenCalledWith('INBOX', 100); + }); + + it('applies the default limit when omitted', async () => { + const listMessages = vi.fn().mockResolvedValue([]); + const server = makeDeps({ listMessages }, {}); + await server.tools.get('list_messages')!.cb({ folder: 'INBOX' }); + expect(listMessages).toHaveBeenCalledWith('INBOX', 20); + }); +}); + +describe('error handling', () => { + it('surfaces a thrown tool error as isError=true, not a thrown exception', async () => { + const listMessages = vi.fn().mockRejectedValue(new Error('boom')); + const server = makeDeps({ listMessages }, {}); + const r = await server.tools.get('list_messages')!.cb({ folder: 'INBOX' }); + expect(parseResult(r).isError).toBe(true); + expect(r.content[0].text).toContain('boom'); + }); +}); diff --git a/packages/integrations/mail-mcp/src/body.ts b/packages/integrations/mail-mcp/src/body.ts new file mode 100644 index 000000000..d6a2b6892 --- /dev/null +++ b/packages/integrations/mail-mcp/src/body.ts @@ -0,0 +1,90 @@ +import type { MessageStructureObject } from 'imapflow'; + +export interface TextPartRef { + readonly part: string; + readonly contentType: string; + readonly charset?: string; +} + +export interface AttachmentRef { + readonly part: string; + readonly filename?: string; + readonly contentType: string; + readonly size?: number; + readonly disposition?: string; +} + +function leafNodes(root?: MessageStructureObject): MessageStructureObject[] { + if (!root) return []; + const out: MessageStructureObject[] = []; + const walk = (node: MessageStructureObject): void => { + if (node.childNodes?.length) { + for (const child of node.childNodes) walk(child); + return; + } + out.push(node); + }; + walk(root); + return out; +} + +/** Leaf `text/*` parts that can serve as the readable body, preferring text/plain. */ +export function selectReadableParts(root?: MessageStructureObject): TextPartRef[] { + const out: TextPartRef[] = []; + for (const node of leafNodes(root)) { + const type = node.type.toLowerCase(); + if (type.startsWith('text/')) { + out.push({ part: node.part ?? '', contentType: type, charset: node.parameters?.charset }); + } + } + return out; +} + +export function pickPreferredPart(parts: TextPartRef[]): TextPartRef | undefined { + if (parts.length === 0) return undefined; + const plain = parts.find((p) => p.contentType === 'text/plain'); + return plain ?? parts[0]; +} + +/** Non-body leaves: anything explicitly `attachment`, or non-text leaves (covers inline images). */ +export function collectAttachments(root?: MessageStructureObject): AttachmentRef[] { + const out: AttachmentRef[] = []; + for (const node of leafNodes(root)) { + const type = node.type.toLowerCase(); + const disposition = node.disposition?.toLowerCase(); + if (disposition !== 'attachment' && type.startsWith('text/')) continue; + out.push({ + part: node.part ?? '', + filename: node.dispositionParameters?.filename ?? node.parameters?.name, + contentType: type, + size: node.size, + disposition, + }); + } + return out; +} + +/** Decode a raw body part Buffer using the declared charset; Node ships full-ICU TextDecoder (gbk/gb2312/big5). */ +export function decodeBodyPart(buffer: Buffer | undefined, charset?: string): string { + if (!buffer) return ''; + const label = normalizeCharset(charset); + try { + return new TextDecoder(label).decode(buffer); + } catch { + return new TextDecoder('utf-8').decode(buffer); + } +} + +function normalizeCharset(charset?: string): string { + if (!charset) return 'utf-8'; + const lower = charset.toLowerCase(); + // gb2312 is a subset of gbk; Node's TextDecoder resolves both to the same decoder. + if (lower === 'gb2312' || lower === 'gb18030') return 'gbk'; + return lower; +} + +export function truncate(text: string, max: number): string { + if (text.length <= max) return text; + const overflow = text.length - max; + return `${text.slice(0, max)}\n…[truncated ${overflow} chars]`; +} diff --git a/packages/integrations/mail-mcp/src/config.ts b/packages/integrations/mail-mcp/src/config.ts new file mode 100644 index 000000000..4f96743b5 --- /dev/null +++ b/packages/integrations/mail-mcp/src/config.ts @@ -0,0 +1,100 @@ +import process from 'node:process'; +import { clamp } from 'foxts/clamp'; +import type { MailConfig, MailPreset } from './types'; + +const DEFAULT_MAX_BODY_CHARS = 8000; +const MIN_BODY_CHARS = 100; +const MAX_BODY_CHARS = 100000; + +const PRESETS: Record< + MailPreset, + { imap: { host: string; port: number }; smtp: { host: string; port: number } } +> = { + '163': { imap: { host: 'imap.163.com', port: 993 }, smtp: { host: 'smtp.163.com', port: 465 } }, + qq: { imap: { host: 'imap.qq.com', port: 993 }, smtp: { host: 'smtp.qq.com', port: 465 } }, + exmail: { + imap: { host: 'imap.exmail.qq.com', port: 993 }, + smtp: { host: 'smtp.exmail.qq.com', port: 465 }, + }, +}; + +const RE_TRUTHY = /^(?:1|true|yes|on)$/i; + +export class ConfigError extends Error { + override name = 'ConfigError'; +} + +function parseBool(value: string | undefined, fallback: boolean): boolean { + if (value === undefined || value === '') return fallback; + return RE_TRUTHY.test(value.trim()); +} + +function parsePreset(value: string | undefined): MailPreset | null { + if (value === undefined || value === '') return null; + const lower = value.trim().toLowerCase(); + if (lower === '163' || lower === 'qq' || lower === 'exmail') return lower; + throw new ConfigError(`MAIL_PRESET must be one of: 163, qq, exmail (got: ${value})`); +} + +function defaultImapPort(secure: boolean): number { + return secure ? 993 : 143; +} + +function defaultSmtpPort(secure: boolean): number { + return secure ? 465 : 587; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): MailConfig { + const user = env.MAIL_USER?.trim(); + if (!user) throw new ConfigError('MAIL_USER is required'); + + const password = env.MAIL_PASSWORD; + if (!password) { + throw new ConfigError( + 'MAIL_PASSWORD is required (the 163/QQ authorization code, not the login password)', + ); + } + + const preset = parsePreset(env.MAIL_PRESET); + const presetHosts = preset ? PRESETS[preset] : null; + + const imapHost = env.IMAP?.trim() || presetHosts?.imap.host; + if (!imapHost) { + throw new ConfigError('IMAP host is required: set MAIL_PRESET=163|qq|exmail or provide IMAP'); + } + + const smtpHost = env.SMTP?.trim() || presetHosts?.smtp.host; + if (!smtpHost) { + throw new ConfigError('SMTP host is required: set MAIL_PRESET=163|qq|exmail or provide SMTP'); + } + + const imapSecure = parseBool(env.IMAP_SECURE, true); + const smtpSecure = parseBool(env.SMTP_SECURE, true); + // A preset pins host+port; a custom host override falls back to the secure-derived default port. + const imapPort = presetHosts && !env.IMAP ? presetHosts.imap.port : defaultImapPort(imapSecure); + const smtpPort = presetHosts && !env.SMTP ? presetHosts.smtp.port : defaultSmtpPort(smtpSecure); + + const smtpUser = env.SMTP_USER?.trim() || user; + // `||` not `??`: an empty SMTP_PASSWORD would otherwise log in with no credential. + const smtpPassword = env.SMTP_PASSWORD || password; + const smtpFrom = env.SMTP_FROM?.trim() || user; + + const parsedMax = Number(env.MAX_BODY_CHARS); + const maxBodyChars = + Number.isFinite(parsedMax) && parsedMax > 0 + ? clamp(Math.trunc(parsedMax), MIN_BODY_CHARS, MAX_BODY_CHARS) + : DEFAULT_MAX_BODY_CHARS; + + return { + imap: { host: imapHost, port: imapPort, secure: imapSecure, user, password }, + smtp: { + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + user: smtpUser, + password: smtpPassword, + }, + smtpFrom, + maxBodyChars, + }; +} diff --git a/packages/integrations/mail-mcp/src/imap.ts b/packages/integrations/mail-mcp/src/imap.ts new file mode 100644 index 000000000..3648fa01b --- /dev/null +++ b/packages/integrations/mail-mcp/src/imap.ts @@ -0,0 +1,358 @@ +import type { + FetchMessageObject, + FetchQueryObject, + ImapFlowOptions, + ListResponse, + MailboxObject, + MessageAddressObject, + SearchObject, +} from 'imapflow'; +import { ImapFlow } from 'imapflow'; +import { + collectAttachments, + decodeBodyPart, + pickPreferredPart, + selectReadableParts, + truncate, +} from './body'; +import type { MailConfig } from './types'; + +export interface FolderSummary { + readonly path: string; + readonly specialUse?: string; + readonly messages?: number; + readonly unseen?: number; +} + +export interface MessageSummary { + readonly uid: number; + readonly subject?: string; + readonly from?: string; + readonly to?: string; + readonly date?: string; + readonly flags?: string[]; + readonly size?: number; +} + +export interface FullMessage { + readonly uid: number; + readonly subject?: string; + readonly from?: string; + readonly to?: string; + readonly cc?: string; + readonly date?: string; + readonly messageId?: string; + readonly inReplyTo?: string; + readonly flags?: string[]; + readonly size?: number; + readonly body?: string; + readonly attachments: ReadonlyArray<{ + readonly part: string; + readonly filename?: string; + readonly contentType: string; + readonly size?: number; + }>; +} + +export interface MailboxLock { + release(): void; +} + +export interface ImapFlowPort { + readonly mailbox: MailboxObject | false; + connect(): Promise; + logout(): Promise; + close(): void; + list(options?: { statusQuery?: Partial> }): Promise; + getMailboxLock(path: string, options?: { readOnly?: boolean }): Promise; + search(query: SearchObject, options?: { uid?: boolean }): Promise; + fetchOne( + seq: number, + query: FetchQueryObject, + options?: { uid?: boolean }, + ): Promise; + fetchAll( + range: string | number[], + query: FetchQueryObject, + options?: { uid?: boolean }, + ): Promise; + messageFlagsAdd(range: number, flags: string[], options?: { uid?: boolean }): Promise; + messageFlagsRemove(range: number, flags: string[], options?: { uid?: boolean }): Promise; + messageMove(range: number, destination: string, options?: { uid?: boolean }): Promise; +} + +export interface ReplyOrigin { + readonly messageId?: string; + readonly subject?: string; + readonly from: Address[]; + readonly to: Address[]; + readonly cc: Address[]; + readonly references: string[]; +} + +export interface MailImapClient { + listFolders(): Promise; + listMessages(folder: string, limit: number): Promise; + searchMessages( + folder: string, + query: Record, + limit: number, + ): Promise; + getMessage(folder: string, uid: number): Promise; + getReplyOrigin(folder: string, uid: number): Promise; + markRead(folder: string, uid: number, read: boolean): Promise; + moveMessage(folder: string, uid: number, destination: string): Promise; + close(): Promise; +} + +export type ImapFlowFactory = (config: MailConfig) => ImapFlowPort; + +const SEEN_FLAG = String.raw`\Seen`; + +export class MailImap implements MailImapClient { + private flow: ImapFlowPort | undefined; + + constructor( + private readonly config: MailConfig, + private readonly flowFactory?: ImapFlowFactory, + ) {} + + async listFolders(): Promise { + const flow = await this.ensureConnected(); + const folders = await flow.list({ statusQuery: { messages: true, unseen: true } }); + return folders.map((f) => ({ + path: f.path, + specialUse: f.specialUse, + messages: f.status?.messages, + unseen: f.status?.unseen, + })); + } + + async listMessages(folder: string, limit: number): Promise { + const flow = await this.ensureConnected(); + const lock = await flow.getMailboxLock(folder, { readOnly: true }); + try { + const exists = flow.mailbox ? flow.mailbox.exists : 0; + if (exists === 0) return []; + const start = Math.max(1, exists - limit + 1); + const range = `${start}:${exists}`; + const messages = await flow.fetchAll(range, { envelope: true, flags: true, size: true }, {}); + return messages.reverse().map(toSummary); + } finally { + lock.release(); + } + } + + async searchMessages( + folder: string, + query: SearchObject, + limit: number, + ): Promise { + const flow = await this.ensureConnected(); + const lock = await flow.getMailboxLock(folder, { readOnly: true }); + try { + const result = await flow.search(query, { uid: true }); + const uids = Array.isArray(result) ? result : []; + if (uids.length === 0) return []; + const capped = uids.slice(-limit); + const messages = await flow.fetchAll( + capped, + { envelope: true, flags: true, size: true }, + { uid: true }, + ); + return messages.sort((a, b) => b.uid - a.uid).map(toSummary); + } finally { + lock.release(); + } + } + + async getMessage(folder: string, uid: number): Promise { + const flow = await this.ensureConnected(); + const lock = await flow.getMailboxLock(folder, { readOnly: true }); + try { + const meta = await flow.fetchOne( + uid, + { envelope: true, bodyStructure: true, flags: true, internalDate: true, size: true }, + { uid: true }, + ); + if (!meta) throw new Error(`Message uid=${uid} not found in ${folder}`); + const structure = meta.bodyStructure; + const readable = pickPreferredPart(selectReadableParts(structure)); + let body: string | undefined; + if (readable) { + const bodyMsg = await flow.fetchOne(uid, { bodyParts: [readable.part] }, { uid: true }); + const buf = bodyMsg ? bodyMsg.bodyParts?.get(readable.part) : undefined; + body = truncate(decodeBodyPart(buf, readable.charset), this.config.maxBodyChars); + } + const attachments = collectAttachments(structure).map((a) => ({ + part: a.part, + filename: a.filename, + contentType: a.contentType, + size: a.size, + })); + const env = meta.envelope; + return { + uid: meta.uid, + subject: env?.subject, + from: formatAddresses(env?.from), + to: formatAddresses(env?.to), + cc: formatAddresses(env?.cc), + date: env?.date ? new Date(env.date).toISOString() : undefined, + messageId: env?.messageId, + inReplyTo: env?.inReplyTo, + flags: meta.flags ? [...meta.flags] : undefined, + size: meta.size, + body, + attachments, + }; + } finally { + lock.release(); + } + } + + async getReplyOrigin(folder: string, uid: number): Promise { + const flow = await this.ensureConnected(); + const lock = await flow.getMailboxLock(folder, { readOnly: true }); + try { + const meta = await flow.fetchOne( + uid, + { envelope: true, headers: ['references'] }, + { uid: true }, + ); + if (!meta) throw new Error(`Message uid=${uid} not found in ${folder}`); + const env = meta.envelope; + return { + messageId: env?.messageId, + subject: env?.subject, + from: toAddresses(env?.from), + to: toAddresses(env?.to), + cc: toAddresses(env?.cc), + references: parseReferences(meta.headers), + }; + } finally { + lock.release(); + } + } + + async markRead(folder: string, uid: number, read: boolean): Promise { + const flow = await this.ensureConnected(); + const lock = await flow.getMailboxLock(folder); + try { + if (read) await flow.messageFlagsAdd(uid, [SEEN_FLAG], { uid: true }); + else await flow.messageFlagsRemove(uid, [SEEN_FLAG], { uid: true }); + } finally { + lock.release(); + } + } + + async moveMessage(folder: string, uid: number, destination: string): Promise { + const flow = await this.ensureConnected(); + const lock = await flow.getMailboxLock(folder); + try { + const result = await flow.messageMove(uid, destination, { uid: true }); + if (result === false) throw new Error(`Failed to move uid=${uid} to ${destination}`); + } finally { + lock.release(); + } + } + + async close(): Promise { + const flow = this.flow; + if (!flow) return; + this.flow = undefined; + try { + await flow.logout(); + } catch { + flow.close(); + } + } + + private async ensureConnected(): Promise { + if (this.flow) return this.flow; + const flow = this.flowFactory ? this.flowFactory(this.config) : createImapFlow(this.config); + await flow.connect(); + this.flow = flow; + return flow; + } +} + +function createImapFlow(config: MailConfig): ImapFlowPort { + const options: ImapFlowOptions = { + host: config.imap.host, + port: config.imap.port, + // ImapFlow logs to stdout by default; stdout is the MCP JSON-RPC channel, so disable. + logger: false, + secure: config.imap.secure, + auth: { user: config.imap.user, pass: config.imap.password }, + // 163 rejects connections without an RFC 2971 ID response; ImapFlow sends it when clientInfo is set. + clientInfo: { name: 'linkcode-mail-mcp', vendor: 'linkcode' }, + }; + return new ImapFlow(options); +} + +export interface Address { + readonly name?: string; + readonly address?: string; +} + +const RE_NEWLINE = /\r?\n/; +const RE_REFERENCES_LINE = /^references:/i; +const RE_REFERENCES_PREFIX = /^references:\s*/i; +const RE_FOLDED = /^\s/; +const RE_WS = /\s+/; + +/** Parsed `References:` header chain (RFC 5322 message-id tokens), honoring folded continuation lines. */ +function parseReferences(headers: Buffer | undefined): string[] { + if (!headers) return []; + const out: string[] = []; + let capturing = false; + for (const line of headers.toString('utf-8').split(RE_NEWLINE)) { + if (RE_REFERENCES_LINE.test(line)) { + capturing = true; + for (const token of line.replace(RE_REFERENCES_PREFIX, '').trim().split(RE_WS)) { + if (token) out.push(token); + } + } else if (capturing && RE_FOLDED.test(line)) { + for (const token of line.trim().split(RE_WS)) { + if (token) out.push(token); + } + } else if (capturing) { + break; + } + } + return out; +} + +function toSummary(msg: FetchMessageObject): MessageSummary { + const env = msg.envelope; + return { + uid: msg.uid, + subject: env?.subject, + from: formatAddresses(env?.from), + to: formatAddresses(env?.to), + date: env?.date ? new Date(env.date).toISOString() : undefined, + flags: msg.flags ? [...msg.flags] : undefined, + size: msg.size, + }; +} + +function toAddresses(addresses?: MessageAddressObject[]): Address[] { + if (!addresses) return []; + const out: Address[] = []; + for (const a of addresses) { + if (a.name !== undefined || a.address !== undefined) { + out.push({ name: a.name, address: a.address }); + } + } + return out; +} + +export function formatAddresses(addresses?: readonly Address[]): string | undefined { + if (!addresses || addresses.length === 0) return undefined; + const parts: string[] = []; + for (const a of addresses) { + const formatted = a.name ? `${a.name} <${a.address ?? ''}>` : (a.address ?? ''); + if (formatted) parts.push(formatted); + } + return parts.length ? parts.join(', ') : undefined; +} diff --git a/packages/integrations/mail-mcp/src/index.ts b/packages/integrations/mail-mcp/src/index.ts new file mode 100644 index 000000000..a1e673239 --- /dev/null +++ b/packages/integrations/mail-mcp/src/index.ts @@ -0,0 +1,54 @@ +import process from 'node:process'; +// 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'; +// eslint-disable-next-line import-x/no-unresolved -- same exports-map subpath as above +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { loadConfig } from './config'; +import { MailImap } from './imap'; +import { MailSmtp } from './smtp'; +import { registerMailTools } from './tools'; + +const VERSION = '0.0.0'; + +async function main(): Promise { + const config = loadConfig(); + const imap = new MailImap(config); + const smtp = new MailSmtp(config); + const server = new McpServer( + { name: 'linkcode-mail-mcp', version: VERSION }, + { + instructions: + 'Read and send email over IMAP/SMTP for 163, QQ, and exmail accounts. Use list_folders, list_messages, search_messages, get_message, send_message, reply_message, mark_read, move_message. Credentials are supplied via the host environment and never appear in tool output.', + }, + ); + registerMailTools(server, { imap, smtp, accountEmail: config.imap.user }); + const transport = new StdioServerTransport(); + await server.connect(transport); + + let shuttingDown = false; + const shutdown = (signal: string): void => { + if (shuttingDown) return; + shuttingDown = true; + void (async () => { + try { + await server.close(); + } catch { + // best-effort during shutdown + } + await Promise.allSettled([imap.close(), smtp.close()]); + process.exitCode = 0; + if (signal) process.exit(0); + })(); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +main().catch((error) => { + process.stderr.write( + `[linkcode-mail-mcp] fatal: ${extractErrorMessage(error) ?? 'unknown error'}\n`, + ); + process.exit(1); +}); diff --git a/packages/integrations/mail-mcp/src/smtp.ts b/packages/integrations/mail-mcp/src/smtp.ts new file mode 100644 index 000000000..0e42f35c1 --- /dev/null +++ b/packages/integrations/mail-mcp/src/smtp.ts @@ -0,0 +1,86 @@ +import { createTransport } from 'nodemailer'; +import type { MailConfig } from './types'; + +export interface SendOptions { + readonly to: string; + readonly subject: string; + readonly body: string; + readonly cc?: string; + readonly bcc?: string; + readonly html?: string; + readonly replyTo?: string; + readonly inReplyTo?: string; + readonly references?: string | string[]; +} + +export interface SendResult { + readonly messageId?: string; + readonly response: string; +} + +export interface SmtpTransporter { + sendMail(options: Record): Promise<{ messageId?: string; response?: string }>; + close(): void; +} + +export type SmtpTransporterFactory = (config: MailConfig) => SmtpTransporter; + +export interface MailSmtpClient { + send(opts: SendOptions): Promise; + close(): Promise; +} + +export class MailSmtp implements MailSmtpClient { + private transporter: SmtpTransporter | undefined; + + constructor( + private readonly config: MailConfig, + private readonly transporterFactory?: SmtpTransporterFactory, + ) {} + + async send(opts: SendOptions): Promise { + const transporter = this.ensureTransporter(); + const info = await transporter.sendMail({ + from: this.config.smtpFrom, + to: opts.to, + cc: opts.cc, + bcc: opts.bcc, + subject: opts.subject, + text: opts.body, + html: opts.html, + replyTo: opts.replyTo, + inReplyTo: opts.inReplyTo, + references: opts.references, + }); + return { messageId: info.messageId, response: info.response ?? '' }; + } + + close(): Promise { + const transporter = this.transporter; + if (!transporter) return Promise.resolve(); + this.transporter = undefined; + try { + transporter.close(); + } catch { + // best-effort; the MCP server is shutting down regardless + } + return Promise.resolve(); + } + + private ensureTransporter(): SmtpTransporter { + if (this.transporter) return this.transporter; + this.transporter = this.transporterFactory + ? this.transporterFactory(this.config) + : createSmtpTransporter(this.config); + return this.transporter; + } +} + +function createSmtpTransporter(config: MailConfig): SmtpTransporter { + return createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: { user: config.smtp.user, pass: config.smtp.password }, + }); +} diff --git a/packages/integrations/mail-mcp/src/tools.ts b/packages/integrations/mail-mcp/src/tools.ts new file mode 100644 index 000000000..fa2df1046 --- /dev/null +++ b/packages/integrations/mail-mcp/src/tools.ts @@ -0,0 +1,225 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { clamp } from 'foxts/clamp'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { z } from 'zod'; +import type { Address, FullMessage, MailImapClient, MessageSummary } from './imap'; +import { formatAddresses } from './imap'; +import type { MailSmtpClient, SendResult } from './smtp'; + +const DEFAULT_LIST_LIMIT = 20; +const MAX_LIST_LIMIT = 100; + +// The SDK's CallToolResult is inferred from a passthrough zod schema, so it carries a +// `[x: string]: unknown` index signature; mirror it so the helpers stay assignable. +interface TextContent { + [x: string]: unknown; + type: 'text'; + text: string; +} +interface ToolResult { + [x: string]: unknown; + content: TextContent[]; + isError?: boolean; +} + +function json(data: unknown): ToolResult { + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; +} + +function fail(error: unknown): ToolResult { + return { + content: [{ type: 'text', text: extractErrorMessage(error) ?? 'unknown error' }], + isError: true, + }; +} + +async function run(fn: () => Promise): Promise { + try { + return json(await fn()); + } catch (error) { + return fail(error); + } +} + +/** Drop duplicate recipients by address (case-insensitive), preserving first-seen order. */ +function dedupeAddresses(addresses: readonly Address[]): Address[] { + const seen = new Set(); + const out: Address[] = []; + for (const a of addresses) { + const key = (a.address ?? '').toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(a); + } + return out; +} + +export interface MailToolDeps { + readonly imap: MailImapClient; + readonly smtp: MailSmtpClient; + /** Authenticated account address, used to drop the user from reply-all recipients. */ + readonly accountEmail: string; +} + +export function registerMailTools(server: McpServer, deps: MailToolDeps): void { + const { imap, smtp, accountEmail } = deps; + + server.registerTool( + 'list_folders', + { + description: + 'List all IMAP folders (mailboxes) for the account, with message and unseen counts.', + }, + async () => run(() => imap.listFolders()), + ); + + server.registerTool( + 'list_messages', + { + description: + 'List the most recent messages in a folder. Returns summaries (uid, subject, from, to, date, flags, size).', + inputSchema: { + folder: z.string().min(1).describe('Folder path, e.g. INBOX or Sent'), + limit: z + .number() + .int() + .positive() + .max(MAX_LIST_LIMIT) + .optional() + .describe(`Default ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT}`), + }, + }, + async ({ folder, limit }) => + run(() => + imap.listMessages(folder, clamp(limit ?? DEFAULT_LIST_LIMIT, 1, MAX_LIST_LIMIT)), + ), + ); + + server.registerTool( + 'search_messages', + { + description: + 'Search messages in a folder by subject/from/to/body/seen/date. Returns matching message summaries.', + inputSchema: { + folder: z.string().min(1), + subject: z.string().optional(), + from: z.string().optional().describe('Sender name or address fragment'), + to: z.string().optional(), + body: z.string().optional().describe('Body text fragment'), + seen: z.boolean().optional().describe('Filter by read state; omit for either'), + since: z.string().optional().describe('ISO date; messages received after'), + before: z.string().optional().describe('ISO date; messages received before'), + limit: z.number().int().positive().max(MAX_LIST_LIMIT).optional(), + }, + }, + async (args) => { + const { folder, limit, seen, ...rest } = args; + const query: Record = { ...rest }; + if (seen !== undefined) query.seen = seen; + return run(() => + imap.searchMessages(folder, query, clamp(limit ?? DEFAULT_LIST_LIMIT, 1, MAX_LIST_LIMIT)), + ); + }, + ); + + server.registerTool( + 'get_message', + { + description: + 'Fetch a single message by uid: headers, decoded text body (truncated), and attachment metadata. Attachments are not downloaded.', + inputSchema: { + folder: z.string().min(1), + uid: z.number().int().positive().describe('Message UID from list_messages/search_messages'), + }, + }, + async ({ folder, uid }) => run(() => imap.getMessage(folder, uid)), + ); + + server.registerTool( + 'send_message', + { + description: 'Send a new email over SMTP. `to`/`cc`/`bcc` accept comma-separated addresses.', + inputSchema: { + to: z.string().min(1), + subject: z.string().min(1), + body: z.string().min(1).describe('Plain-text body'), + cc: z.string().optional(), + bcc: z.string().optional(), + html: z.string().optional().describe('Optional HTML body'), + replyTo: z.string().optional(), + }, + }, + async (args) => run(() => smtp.send(args)), + ); + + server.registerTool( + 'reply_message', + { + description: + 'Reply to a message by uid: fetches the original, sets In-Reply-To/References and `Re:` subject, sends via SMTP.', + inputSchema: { + folder: z.string().min(1), + uid: z.number().int().positive(), + body: z.string().min(1), + html: z.string().optional(), + replyAll: z + .boolean() + .optional() + .describe('Reply to original To+Cc instead of just From (default false)'), + }, + }, + async ({ folder, uid, body, html, replyAll }) => + run(async () => { + const origin = await imap.getReplyOrigin(folder, uid); + const recipients = replyAll ? [...origin.from, ...origin.to, ...origin.cc] : origin.from; + const self = accountEmail.toLowerCase(); + const filtered = recipients.filter((a) => (a.address ?? '').toLowerCase() !== self); + const to = formatAddresses(dedupeAddresses(filtered)); + if (!to) throw new Error('Original message has no replyable From address'); + const subject = origin.subject?.toLowerCase().startsWith('re:') + ? origin.subject + : `Re: ${origin.subject ?? ''}`; + const references = [...origin.references]; + if (origin.messageId && !references.includes(origin.messageId)) { + references.push(origin.messageId); + } + return smtp.send({ + to, + subject, + body, + html, + inReplyTo: origin.messageId, + references, + }); + }), + ); + + server.registerTool( + 'mark_read', + { + description: String.raw`Set or clear the \Seen flag on a message by uid.`, + inputSchema: { + folder: z.string().min(1), + uid: z.number().int().positive(), + read: z + .boolean() + .optional() + .describe('true (default) marks as read; false marks as unread'), + }, + }, + async ({ folder, uid, read }) => run(() => imap.markRead(folder, uid, read ?? true)), + ); + + server.registerTool( + 'move_message', + { + description: 'Move a message by uid from one folder to another.', + inputSchema: { + folder: z.string().min(1).describe('Source folder'), + uid: z.number().int().positive(), + destination: z.string().min(1).describe('Destination folder path'), + }, + }, + async ({ folder, uid, destination }) => run(() => imap.moveMessage(folder, uid, destination)), + ); +} diff --git a/packages/integrations/mail-mcp/src/types.ts b/packages/integrations/mail-mcp/src/types.ts new file mode 100644 index 000000000..eb1eb37d3 --- /dev/null +++ b/packages/integrations/mail-mcp/src/types.ts @@ -0,0 +1,24 @@ +export interface ImapEndpointConfig { + readonly host: string; + readonly port: number; + readonly secure: boolean; + readonly user: string; + readonly password: string; +} + +export interface SmtpEndpointConfig { + readonly host: string; + readonly port: number; + readonly secure: boolean; + readonly user: string; + readonly password: string; +} + +export interface MailConfig { + readonly imap: ImapEndpointConfig; + readonly smtp: SmtpEndpointConfig; + readonly smtpFrom: string; + readonly maxBodyChars: number; +} + +export type MailPreset = '163' | 'qq' | 'exmail'; diff --git a/packages/integrations/mail-mcp/tsconfig.json b/packages/integrations/mail-mcp/tsconfig.json new file mode 100644 index 000000000..855b324df --- /dev/null +++ b/packages/integrations/mail-mcp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src", "tsup.config.ts"] +} diff --git a/packages/integrations/mail-mcp/tsup.config.ts b/packages/integrations/mail-mcp/tsup.config.ts new file mode 100644 index 000000000..b7e958b40 --- /dev/null +++ b/packages/integrations/mail-mcp/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node24', + clean: true, + splitting: false, + sourcemap: true, + platform: 'node', + // Installed plugin copies run without node_modules: bundle every dependency into one file. + noExternal: [/.+/], + banner: { + // CJS deps (imapflow) keep their require() calls after bundling; give them a real require. + js: "#!/usr/bin/env node\nimport { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);", + }, +}); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 09c5e4028..fdf327a86 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -810,6 +810,7 @@ export const en = { refresh: 'Rescan', tabPlugins: 'Plugins', tabMarket: 'Market', + tabLinkcode: 'LinkCode', tabMcp: 'MCP', tabSkills: 'Skills', discoveryFailed: 'Could not read {harness} plugins: {reason}', @@ -875,6 +876,25 @@ export const en = { empty: 'No skills discovered yet.', noSearchResults: 'No matching skills.', }, + linkcode: { + hint: "LinkCode's own plugin marketplace: the local daemon refreshes the catalog, and a plugin's declared settings are filled in here after install; secrets stay on this machine and never return to clients.", + installedTitle: 'Installed', + installedEmpty: 'No LinkCode plugins installed yet — pick one from a marketplace below.', + noMarketplaces: 'No marketplaces configured yet.', + catalogEmpty: 'This marketplace has no plugins yet.', + refresh: 'Refresh catalog', + installed: 'Installed', + configure: 'Configure', + settingsTitle: '“{title}” settings', + form: { + save: 'Save', + cancel: 'Cancel', + required: 'Required', + invalidNumber: 'Enter a number', + secretPlaceholder: 'Leave blank to keep unchanged', + selectPlaceholder: 'Select…', + }, + }, mcp: { customTitle: 'Custom MCP servers', customHint: @@ -906,6 +926,11 @@ export const en = { restoreSecret: 'Keep key', save: 'Save', cancel: 'Cancel', + mailTemplate: 'Email template', + mailTemplateNone: 'None — start from scratch', + template163: '163 mailbox', + templateQq: 'QQ mailbox', + templateExmail: 'Tencent Exmail', }, }, }, diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index ff6a38b10..2e026e44f 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -795,6 +795,7 @@ export const zhCN = { refresh: '重新扫描', tabPlugins: '插件', tabMarket: '市场', + tabLinkcode: 'LinkCode', tabMcp: 'MCP', tabSkills: '技能', discoveryFailed: '无法读取 {harness} 的插件:{reason}', @@ -859,6 +860,25 @@ export const zhCN = { empty: '还没有发现任何技能。', noSearchResults: '没有匹配的技能。', }, + linkcode: { + hint: 'LinkCode 自有插件市场:目录由本机 daemon 刷新;插件声明的配置项在安装后于此填写,密钥只保存在本机,不会回传。', + installedTitle: '已安装', + installedEmpty: '还没有安装任何 LinkCode 插件;从下方市场目录挑一个。', + noMarketplaces: '还没有配置任何插件市场。', + catalogEmpty: '这个市场暂时没有可用的插件。', + refresh: '刷新目录', + installed: '已安装', + configure: '设置', + settingsTitle: '「{title}」设置', + form: { + save: '保存', + cancel: '取消', + required: '必填项', + invalidNumber: '请输入数字', + secretPlaceholder: '留空保持不变', + selectPlaceholder: '请选择…', + }, + }, mcp: { customTitle: '自定义 MCP 服务', customHint: @@ -889,6 +909,11 @@ export const zhCN = { restoreSecret: '保留此键', save: '保存', cancel: '取消', + mailTemplate: '邮箱模板', + mailTemplateNone: '不使用 · 从零开始', + template163: '163 邮箱', + templateQq: 'QQ 邮箱', + templateExmail: '腾讯企业邮箱', }, }, }, diff --git a/packages/presentation/ui/src/shell/plugins/index.ts b/packages/presentation/ui/src/shell/plugins/index.ts index c36968a81..73cda3b48 100644 --- a/packages/presentation/ui/src/shell/plugins/index.ts +++ b/packages/presentation/ui/src/shell/plugins/index.ts @@ -1,4 +1,5 @@ export * from './custom-server-list'; +export * from './linkcode-catalog'; export * from './plugin-card'; export * from './plugins-shell'; export * from './plugins-tab'; diff --git a/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx b/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx new file mode 100644 index 000000000..092620ec1 --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx @@ -0,0 +1,231 @@ +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from 'coss-ui/components/alert-dialog'; +import { Badge } from 'coss-ui/components/badge'; +import { Button } from 'coss-ui/components/button'; +import { Card } from 'coss-ui/components/card'; +import { Skeleton } from 'coss-ui/components/skeleton'; +import { createFixedArray } from 'foxact/create-fixed-array'; +import { DownloadIcon, RefreshCwIcon, Settings2Icon, Trash2Icon } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; +import { cn } from '../../lib/cn'; +import { SettingsSection } from '../settings-page'; +import type { LinkCodeCatalogCardView, LinkCodeInstalledPluginRow } from './types'; + +const SKELETON_ROWS = createFixedArray(2); + +export interface LinkCodeInstalledSectionProps { + /** Undefined while the first masked read is loading (skeletons). */ + rows: LinkCodeInstalledPluginRow[] | undefined; + busy: boolean; + onConfigure: (row: LinkCodeInstalledPluginRow) => void; + onUninstall: (row: LinkCodeInstalledPluginRow) => void; +} + +/** The installed LinkCode plugins; per-plugin settings and uninstall live here. */ +export function LinkCodeInstalledSection({ + rows, + busy, + onConfigure, + onUninstall, +}: LinkCodeInstalledSectionProps): React.ReactNode { + const t = useTranslations('settings.plugins.linkcode'); + return ( + + {rows === undefined ? ( +
+ {SKELETON_ROWS.map((index) => ( + + ))} +
+ ) : rows.length === 0 ? ( + +

{t('installedEmpty')}

+
+ ) : ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+ ); +} + +function InstalledRow({ + row, + busy, + onConfigure, + onUninstall, +}: { + row: LinkCodeInstalledPluginRow; + busy: boolean; + onConfigure: (row: LinkCodeInstalledPluginRow) => void; + onUninstall: (row: LinkCodeInstalledPluginRow) => void; +}): React.ReactNode { + const t = useTranslations('settings.plugins'); + const [confirmingUninstall, setConfirmingUninstall] = useState(false); + return ( + +
+ {row.title} + v{row.version} +
+
+ {row.hasSettings ? ( + + ) : null} + +
+ + + + {t('uninstallTitle', { title: row.title })} + {t('uninstallHint')} + + + {t('cancel')}} /> + + + + +
+ ); +} + +export interface LinkCodeCatalogSectionProps { + /** Marketplace display name; also the section title. */ + title: string; + /** Undefined while the first refresh is in flight (skeletons). */ + cards: LinkCodeCatalogCardView[] | undefined; + busy: boolean; + /** True while a manual refresh is revalidating. */ + refreshing: boolean; + onRefresh: () => void; + onInstall: (card: LinkCodeCatalogCardView) => void; +} + +/** One marketplace's catalog: name/version/description per release plus the install action. */ +export function LinkCodeCatalogSection({ + title, + cards, + busy, + refreshing, + onRefresh, + onInstall, +}: LinkCodeCatalogSectionProps): React.ReactNode { + const t = useTranslations('settings.plugins'); + return ( + + {title} + + + } + > + {cards === undefined ? ( +
+ {SKELETON_ROWS.map((index) => ( + + ))} +
+ ) : cards.length === 0 ? ( + +

{t('linkcode.catalogEmpty')}

+
+ ) : ( +
+ {cards.map((card) => ( + + ))} +
+ )} +
+ ); +} + +function CatalogCard({ + card, + busy, + onInstall, +}: { + card: LinkCodeCatalogCardView; + busy: boolean; + onInstall: (card: LinkCodeCatalogCardView) => void; +}): React.ReactNode { + const t = useTranslations('settings.plugins'); + return ( + +
+
+ {card.title} + v{card.version} + {card.installed ? {t('linkcode.installed')} : null} +
+ {card.description === undefined ? null : ( +

{card.description}

+ )} + {card.pluginId} +
+ {card.installed ? null : ( + + )} +
+ ); +} diff --git a/packages/presentation/ui/src/shell/plugins/plugins-shell.tsx b/packages/presentation/ui/src/shell/plugins/plugins-shell.tsx index cca11bfd7..14b190c4b 100644 --- a/packages/presentation/ui/src/shell/plugins/plugins-shell.tsx +++ b/packages/presentation/ui/src/shell/plugins/plugins-shell.tsx @@ -13,6 +13,8 @@ export interface PluginsShellProps { refreshing: boolean; pluginsTab: React.ReactNode; marketTab: React.ReactNode; + /** The LinkCode-owned marketplace (catalog + installed plugins), separate from the providers' own marketplaces. */ + linkcodeTab: React.ReactNode; mcpTab: React.ReactNode; skillsTab: React.ReactNode; } @@ -24,6 +26,7 @@ export function PluginsShell({ refreshing, pluginsTab, marketTab, + linkcodeTab, mcpTab, skillsTab, }: PluginsShellProps): React.ReactNode { @@ -37,6 +40,7 @@ export function PluginsShell({ {t('tabPlugins')} {t('tabMarket')} + {t('tabLinkcode')} {t('tabMcp')} {t('tabSkills')} @@ -60,6 +64,7 @@ export function PluginsShell({ {pluginsTab} {marketTab} + {linkcodeTab} {mcpTab} {skillsTab} diff --git a/packages/presentation/ui/src/shell/plugins/types.ts b/packages/presentation/ui/src/shell/plugins/types.ts index c35ea7f2a..b0697124c 100644 --- a/packages/presentation/ui/src/shell/plugins/types.ts +++ b/packages/presentation/ui/src/shell/plugins/types.ts @@ -80,3 +80,28 @@ export interface CustomMcpServerRow { enabled: boolean; secretKeys: string[]; } + +/** One entry of a LinkCode marketplace catalog (the daemon-refreshed index). */ +export interface LinkCodeCatalogCardView { + /** `${marketplaceId}:${pluginId}` — stable across refreshes. */ + key: string; + marketplaceId: string; + pluginId: string; + version: string; + title: string; + description: string | undefined; + installed: boolean; + /** Precomputed lowercase haystack for the client-side filter. */ + searchText: string; +} + +/** One installed LinkCode plugin (from the masked `plugin-config.listed` read). */ +export interface LinkCodeInstalledPluginRow { + /** The plugin id — stable across config writes. */ + key: string; + pluginId: string; + title: string; + version: string; + /** Whether the manifest declares settings; the configure action renders only when true. */ + hasSettings: boolean; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f65230b3..c2b77b622 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + tar: + specifier: ^7.5.22 + version: 7.5.22 zod: specifier: 'catalog:' version: 4.4.3 @@ -1156,6 +1159,40 @@ importers: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' + packages/integrations/mail-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.4.3) + foxts: + specifier: ^5.8.0 + version: 5.8.1 + imapflow: + specifier: ^1.7.2 + version: 1.7.2 + nodemailer: + specifier: ^9.0.5 + version: 9.0.5 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.1.1 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 + tsup: + specifier: 'catalog:' + version: 8.5.1(@typescript/typescript6@6.0.2)(jiti@2.7.0)(postcss@8.5.24)(tsx@4.23.1)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/presentation/i18n: devDependencies: typescript: @@ -5433,6 +5470,9 @@ packages: '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5838,6 +5878,9 @@ packages: engines: {node: '>=14.6'} deprecated: this version has critical issues, please update to the latest version + '@zone-eu/mailsplit@5.4.15': + resolution: {integrity: sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==} + abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -7092,6 +7135,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -8249,6 +8296,9 @@ packages: engines: {node: '>=16.x'} hasBin: true + imapflow@1.7.2: + resolution: {integrity: sha512-1pWZgWQ/M2Q7kPSW7Sp7QDn+ZPEqs/9IymYh34RY+3J7d3vfPayhSmRAl0tB7weblGU0SR/t7eYES3TW6vSiOQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -8311,10 +8361,6 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - ip-address@10.3.1: resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} engines: {node: '>= 12'} @@ -8578,6 +8624,15 @@ packages: engines: {node: '>=16'} hasBin: true + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.4.2: + resolution: {integrity: sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} @@ -9387,6 +9442,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -15765,6 +15824,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.1.1 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -16092,6 +16155,12 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@zone-eu/mailsplit@5.4.15': + dependencies: + libbase64: 1.3.0 + libmime: 5.4.2 + libqp: 2.1.1 + abbrev@4.0.0: {} abort-controller@3.0.0: @@ -17379,6 +17448,8 @@ snapshots: encodeurl@2.0.0: {} + encoding-japanese@2.2.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -18914,6 +18985,17 @@ snapshots: dependencies: queue: 6.0.2 + imapflow@1.7.2: + dependencies: + '@zone-eu/mailsplit': 5.4.15 + encoding-japanese: 2.2.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libmime: 5.4.2 + libqp: 2.1.1 + pino: 10.3.1 + socks: 2.8.9 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -18979,8 +19061,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.2.0: {} - ip-address@10.3.1: {} ipaddr.js@1.9.1: {} @@ -19223,6 +19303,17 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + libbase64@1.3.0: {} + + libmime@5.4.2: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + lighthouse-logger@1.4.2: dependencies: debug: 2.6.9 @@ -20280,6 +20371,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@9.0.5: {} + nopt@9.0.0: dependencies: abbrev: 4.0.0 @@ -21663,7 +21756,7 @@ snapshots: socks@2.8.9: dependencies: - ip-address: 10.2.0 + ip-address: 10.3.1 smart-buffer: 4.2.0 sonic-boom@4.2.1: diff --git a/tsconfig.json b/tsconfig.json index e8b7e410c..d8a318d9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ { "path": "packages/host/engine/tests" }, { "path": "packages/host/sim" }, { "path": "packages/integrations/im-render" }, + { "path": "packages/integrations/mail-mcp" }, { "path": "packages/presentation/i18n" }, { "path": "packages/presentation/ui" }, { "path": "packages/system-plane/ipc" } From 2509c6ce2864e6a30b0994ebd3c27cfe990a01f2 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Tue, 25 Aug 2026 02:04:14 +0800 Subject: [PATCH 02/19] test(daemon): add plugin marketplace e2e and dev marketplace fixture --- apps/daemon/e2e/plugin-marketplace.e2e.ts | 205 ++++++++++++++++++++++ scripts/dev-marketplace.mts | 167 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 apps/daemon/e2e/plugin-marketplace.e2e.ts create mode 100644 scripts/dev-marketplace.mts diff --git a/apps/daemon/e2e/plugin-marketplace.e2e.ts b/apps/daemon/e2e/plugin-marketplace.e2e.ts new file mode 100644 index 000000000..5c6b7a2ff --- /dev/null +++ b/apps/daemon/e2e/plugin-marketplace.e2e.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { LinkCodeClient } from '@linkcode/client-core'; +import { SocketIoTransport } from '@linkcode/transport'; +import { wait } from 'foxts/wait'; +import { waitFor } from 'foxts/wait-for'; + +const daemonDir = resolve(import.meta.dirname, '..'); +const repoRoot = resolve(daemonDir, '..', '..'); +const marketplaceScript = join(repoRoot, 'scripts', 'dev-marketplace.mts'); +const fixtureIndex = join(repoRoot, 'node_modules', '.cache', 'dev-marketplace', 'index.json'); + +const MARKETPLACE_ID = 'linkcode-official'; +const PLUGIN_ID = 'linkcode/mail'; +const PLUGIN_VERSION = '0.1.0'; +const AUTHCODE = 'e2e-secret-authcode'; + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert(address && typeof address !== 'string'); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return address.port; +} + +async function main(): Promise { + assert( + existsSync(join(daemonDir, 'dist/index.js')), + 'daemon dist is missing; run its build first', + ); + assert( + existsSync(fixtureIndex), + 'dev marketplace fixture is missing; run: node scripts/dev-marketplace.mts --build', + ); + + const home = mkdtempSync(join(tmpdir(), 'linkcode-marketplace-e2e-')); + const daemonPort = await freePort(); + const marketPort = await freePort(); + const logs: string[] = []; + + const marketplace = spawn(process.execPath, [marketplaceScript], { + cwd: repoRoot, + env: { ...process.env, HOME: home, DEV_MARKETPLACE_PORT: String(marketPort) }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + marketplace.stdout.on('data', (chunk: Buffer) => logs.push(chunk.toString())); + marketplace.stderr.on('data', (chunk: Buffer) => logs.push(chunk.toString())); + + let exit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + const child = spawn(process.execPath, ['--import', './dist/instrument.js', 'dist/index.js'], { + cwd: daemonDir, + env: { + ...process.env, + HOME: home, + LINKCODE_HOST: '127.0.0.1', + LINKCODE_PORT: String(daemonPort), + LINKCODE_MARKETPLACE_URL: `http://127.0.0.1:${marketPort}/index.json`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + child.stdout.on('data', (chunk: Buffer) => logs.push(chunk.toString())); + child.stderr.on('data', (chunk: Buffer) => logs.push(chunk.toString())); + child.once('exit', (code, signal) => { + exit = { code, signal }; + }); + + let client: LinkCodeClient | null = null; + try { + // The tsup bundle is stamped `release`, so state lands in `.linkcode`, not `.linkcode.development`. + const runtimePath = join(home, '.linkcode', 'runtime.json'); + const runtime = await waitFor( + () => { + if (exit) throw new Error(`daemon exited during boot: ${JSON.stringify(exit)}`); + if (!existsSync(runtimePath)) return false; + try { + return JSON.parse(readFileSync(runtimePath, 'utf8')) as { + pid: number; + listeners: Array<{ type: string; url: string }>; + }; + } catch { + return false; + } + }, + 100, + AbortSignal.timeout(30000), + ); + assert.equal(runtime.pid, child.pid); + const listener = runtime.listeners.find((entry) => entry.type === 'socket.io'); + assert(listener, 'runtime.json has no socket.io listener'); + + client = new LinkCodeClient(new SocketIoTransport({ url: listener.url }), { randomUUID }); + await client.connect(); + + // 1. The env override retargets the built-in official marketplace at the loopback fixture. + const marketplaces = await client.listPluginMarketplaces(); + const official = marketplaces.find((entry) => entry.id === MARKETPLACE_ID); + assert(official, 'official marketplace missing from list'); + assert.equal(official.source.url, `http://127.0.0.1:${marketPort}/index.json`); + + // 2. Refresh pulls the catalog; a second refresh rides the ETag to a 304. + const first = await client.refreshPluginMarketplace(MARKETPLACE_ID); + assert( + first.releases.some( + (entry) => + entry.pluginId === PLUGIN_ID && entry.release.manifest.version === PLUGIN_VERSION, + ), + 'catalog does not list linkcode/mail', + ); + const second = await client.refreshPluginMarketplace(MARKETPLACE_ID); + assert.equal(second.notModified, true, 'second refresh did not hit the ETag cache'); + + // 3. Install from the cached catalog; the package lands in the Store. + const installed = await client.installLinkCodePlugin({ + marketplaceId: MARKETPLACE_ID, + pluginId: PLUGIN_ID, + version: PLUGIN_VERSION, + }); + assert.equal(installed.pluginId, PLUGIN_ID); + const packageDir = join(home, '.linkcode', 'plugins', 'linkcode', 'mail', PLUGIN_VERSION); + assert(existsSync(join(packageDir, 'manifest.json')), 'installed manifest.json missing'); + assert(existsSync(join(packageDir, 'dist', 'index.js')), 'installed dist/index.js missing'); + + // 4. Settings: masked read shows the schema, set splits secret vs non-secret. + const before = await client.listLinkCodePluginConfigs(); + const view = before.find((entry) => entry.id === PLUGIN_ID); + assert(view, 'installed plugin missing from plugin-config.list'); + assert(view.settings.authcode?.secret, 'authcode must be a secret field'); + assert.equal(view.values.authcode, undefined, 'secret value leaked in masked read'); + + await client.setLinkCodePluginConfig({ + pluginId: PLUGIN_ID, + set: { account: 'user@163.com', authcode: AUTHCODE, preset: 'qq' }, + }); + const configFile = JSON.parse(readFileSync(join(home, '.linkcode', 'config.json'), 'utf8')) as { + pluginConfigs?: Record>; + }; + assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.account, 'user@163.com'); + assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.preset, 'qq'); + assert(!('authcode' in (configFile.pluginConfigs?.[PLUGIN_ID] ?? {})), 'secret in config.json'); + const secretsFile = JSON.parse( + readFileSync(join(home, '.linkcode', 'secrets.json'), 'utf8'), + ) as { + protection: 'os-keyring' | 'plaintext'; + }; + // A fake HOME has no login keychain, so the vault degrades to plaintext on disk (with a boot + // warning). Either way the authcode belongs in secrets.json — just never in config.json. + const secretsRaw = readFileSync(join(home, '.linkcode', 'secrets.json'), 'utf8'); + if (secretsFile.protection === 'os-keyring') { + assert(!secretsRaw.includes(AUTHCODE), 'authcode stored in plaintext under os-keyring'); + } else { + assert(secretsRaw.includes(AUTHCODE), 'authcode missing from the vault'); + } + + const after = await client.listLinkCodePluginConfigs(); + const afterView = after.find((entry) => entry.id === PLUGIN_ID); + assert.equal(afterView?.values.account, 'user@163.com'); + assert.equal(afterView?.values.authcode, undefined, 'secret value leaked after set'); + + // 5. Uninstall removes the package and prunes its config. + const removed = await client.uninstallLinkCodePlugin(PLUGIN_ID); + assert.equal(removed, PLUGIN_ID); + assert(!existsSync(packageDir), 'package dir survived uninstall'); + + assert(child.kill('SIGTERM'), 'daemon rejected SIGTERM'); + const shutdown = await waitFor(() => exit ?? false, 50, AbortSignal.timeout(10000)); + assert.deepEqual(shutdown, { code: 0, signal: null }); + + console.log('PASS marketplace refresh (ETag 304), install, settings vault split, uninstall'); + } catch (error) { + console.error(logs.join('').slice(-8000)); + throw error; + } finally { + client?.dispose(); + await stop(child); + await stop(marketplace); + rmSync(home, { recursive: true, force: true }); + } +} + +async function stop(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGTERM'); + await Promise.race([ + new Promise((resolve) => { + child.once('exit', () => resolve()); + }), + wait(5000).then(() => child.kill('SIGKILL')), + ]); +} + +void main(); diff --git a/scripts/dev-marketplace.mts b/scripts/dev-marketplace.mts new file mode 100644 index 000000000..f0264edb7 --- /dev/null +++ b/scripts/dev-marketplace.mts @@ -0,0 +1,167 @@ +/** + * Dev marketplace for LinkCode plugin debugging: packs @linkcode/mail-mcp into a tgz with a + * manifest, writes an index.json (schema: LinkCodeMarketplaceIndexSchema), and serves the + * directory over loopback HTTP with ETag support so the daemon's conditional refresh (304) path + * is exercised too. + * + * Usage: + * node scripts/dev-marketplace.mts # build fixture + serve on 127.0.0.1:18741 + * node scripts/dev-marketplace.mts --build # build fixture only (no server) + * + * Point the dev daemon at it with: + * LINKCODE_MARKETPLACE_URL=http://127.0.0.1:18741/index.json pnpm -F @linkcode/daemon dev + * + * The manifest's mcp-server args point at the INSTALLED copy under the dev channel's plugin + * store (~/.linkcode.development/plugins/...), so a session genuinely runs the artifact the + * installer published — not the repo's dist. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join } from 'node:path'; +import process from 'node:process'; + +const repoRoot = new URL('..', import.meta.url).pathname; +const outDir = join(repoRoot, 'node_modules', '.cache', 'dev-marketplace'); +const mailDist = join(repoRoot, 'packages', 'integrations', 'mail-mcp', 'dist'); + +const PLUGIN_ID = 'linkcode/mail'; +const VERSION = '0.1.0'; +const PORT = Number(process.env.DEV_MARKETPLACE_PORT ?? 18741); + +const buildOnly = process.argv.includes('--build'); + +const manifest = { + manifestVersion: 1, + id: PLUGIN_ID, + version: VERSION, + displayName: '邮箱(163 / QQ)', + description: '通过 IMAP 收信、SMTP 发信,支持 163、QQ 和腾讯企业邮箱(授权码登录)。', + keywords: ['mail', 'imap', 'smtp', '163', 'qq'], + components: [ + { + kind: 'mcp-server', + name: 'mail', + description: 'IMAP/SMTP mail tools (list/search/read/send/reply/mark/move)', + command: 'node', + entry: 'dist/index.js', + env: { + MAIL_USER: 'account', + MAIL_PASSWORD: 'authcode', + MAIL_PRESET: 'preset', + }, + }, + ], + settings: { + account: { + type: 'string', + label: '邮箱账号', + description: '完整邮箱地址,例如 you@163.com', + required: true, + }, + authcode: { + type: 'password', + label: '授权码', + description: '邮箱网页端生成的客户端授权码,不是登录密码', + secret: true, + required: true, + }, + preset: { + type: 'enum', + label: '服务商', + enum: ['163', 'qq', 'exmail'], + default: '163', + }, + }, + assets: [], +}; + +function buildFixture(): void { + if (!existsSync(join(mailDist, 'index.js'))) { + console.error( + 'packages/integrations/mail-mcp/dist is missing — run: pnpm -F @linkcode/mail-mcp build', + ); + process.exit(1); + } + rmSync(outDir, { recursive: true, force: true }); + const staging = join(outDir, 'staging', 'package'); + mkdirSync(join(staging, 'dist'), { recursive: true }); + writeFileSync(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + copyFileSync(join(mailDist, 'index.js'), join(staging, 'dist', 'index.js')); + + const tgzName = `mail-${VERSION}.tgz`; + const tgzPath = join(outDir, tgzName); + // The installer extracts with strip:1, so the archive must wrap everything in one top-level dir. + execFileSync('tar', ['-czf', tgzPath, '-C', join(outDir, 'staging'), 'package']); + rmSync(join(outDir, 'staging'), { recursive: true, force: true }); + + const bytes = readFileSync(tgzPath); + const integrity = `sha256-${createHash('sha256').update(bytes).digest('base64')}`; + const index = { + indexVersion: 1, + name: 'LinkCode Dev Marketplace', + updatedAt: new Date().toISOString(), + plugins: [ + { + id: PLUGIN_ID, + releases: [ + { + manifest, + artifact: { + urls: [tgzName], + integrity, + size: bytes.length, + format: 'tgz', + }, + publishedAt: new Date().toISOString(), + }, + ], + }, + ], + }; + writeFileSync(join(outDir, 'index.json'), `${JSON.stringify(index, null, 2)}\n`); + console.log(`fixture ready: ${outDir} (${tgzName} ${bytes.length} bytes, ${integrity})`); +} + +function serve(): void { + const server = createServer((req, res) => { + const path = req.url === '/' ? '/index.json' : (req.url ?? '/'); + const file = join(outDir, path); + if (!file.startsWith(outDir) || !existsSync(file)) { + res.writeHead(404).end('not found'); + return; + } + const bytes = readFileSync(file); + const etag = `"${createHash('sha1').update(bytes).digest('hex')}"`; + if (req.headers['if-none-match'] === etag) { + res.writeHead(304).end(); + return; + } + res.writeHead(200, { + 'content-type': extname(file) === '.tgz' ? 'application/gzip' : 'application/json', + 'content-length': String(bytes.length), + etag, + }); + res.end(bytes); + }); + server.listen(PORT, '127.0.0.1', () => { + console.log(`dev marketplace serving at http://127.0.0.1:${PORT}/index.json`); + }); +} + +buildFixture(); +if (buildOnly) { + console.log(`(build only; stat: ${statSync(join(outDir, 'index.json')).size} bytes index)`); +} else { + serve(); +} From 6373d0cdb7ab9c87f76728a92bd02cbbcf737fd1 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Tue, 25 Aug 2026 03:15:42 +0800 Subject: [PATCH 03/19] fix(daemon): harden plugin install staging, settings rollback, and marketplace refresh --- apps/daemon/e2e/plugin-marketplace.e2e.ts | 7 + apps/daemon/src/__tests__/marketplace.test.ts | 45 ++++- .../daemon/src/__tests__/plugin-store.test.ts | 167 ++++++++++++++++++ apps/daemon/src/marketplace/service.ts | 32 +++- apps/daemon/src/plugin-store/paths.ts | 6 +- apps/daemon/src/plugin-store/store.ts | 101 +++++++++-- .../schema/src/model/__tests__/plugin.test.ts | 12 ++ .../schema/src/model/linkcode-plugin.ts | 9 +- .../src/__tests__/plugin-market.test.ts | 46 +++++ .../src/plugin/market-request-handler.ts | 28 ++- packages/integrations/mail-mcp/package.json | 1 - .../mail-mcp/src/__tests__/config.test.ts | 48 ++++- .../mail-mcp/src/__tests__/imap.test.ts | 75 +++++++- packages/integrations/mail-mcp/src/config.ts | 35 +++- packages/integrations/mail-mcp/src/imap.ts | 63 ++++++- packages/integrations/mail-mcp/src/index.ts | 15 +- packages/integrations/mail-mcp/src/smtp.ts | 2 + packages/integrations/mail-mcp/tsup.config.ts | 6 + scripts/dev-marketplace.mts | 5 +- 19 files changed, 658 insertions(+), 45 deletions(-) create mode 100644 apps/daemon/src/__tests__/plugin-store.test.ts diff --git a/apps/daemon/e2e/plugin-marketplace.e2e.ts b/apps/daemon/e2e/plugin-marketplace.e2e.ts index 5c6b7a2ff..9d52c4d9f 100644 --- a/apps/daemon/e2e/plugin-marketplace.e2e.ts +++ b/apps/daemon/e2e/plugin-marketplace.e2e.ts @@ -122,6 +122,13 @@ async function main(): Promise { ); const second = await client.refreshPluginMarketplace(MARKETPLACE_ID); assert.equal(second.notModified, true, 'second refresh did not hit the ETag cache'); + assert( + second.releases.some( + (entry) => + entry.pluginId === PLUGIN_ID && entry.release.manifest.version === PLUGIN_VERSION, + ), + '304 refresh cleared the cached catalog', + ); // 3. Install from the cached catalog; the package lands in the Store. const installed = await client.installLinkCodePlugin({ diff --git a/apps/daemon/src/__tests__/marketplace.test.ts b/apps/daemon/src/__tests__/marketplace.test.ts index ecd82f42c..6aeb8ada7 100644 --- a/apps/daemon/src/__tests__/marketplace.test.ts +++ b/apps/daemon/src/__tests__/marketplace.test.ts @@ -1,8 +1,9 @@ -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { LinkCodeMarketplaceConfigList } from '@linkcode/schema'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { marketplaceIndexCachePath } from '../marketplace/paths'; import type { MarketplaceIndexResponse } from '../marketplace/service'; import { DaemonLinkCodeMarketplaceService } from '../marketplace/service'; @@ -128,6 +129,48 @@ describe('DaemonLinkCodeMarketplaceService.refresh', () => { ).toBeDefined(); }); + it('drops stale validators and retries unconditionally when a 304 has no readable cache', async () => { + let calls = 0; + const fetchIndex = vi.fn(() => { + calls += 1; + return Promise.resolve( + calls === 1 + ? fakeResponse(200, JSON.stringify(INDEX), { etag: '"index-v1"' }) + : calls === 2 + ? fakeResponse(304) + : fakeResponse(200, JSON.stringify(INDEX), { etag: '"index-v2"' }), + ); + }); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + await service.refresh('linkcode-official'); + writeFileSync(marketplaceIndexCachePath('linkcode-official'), '{broken', 'utf8'); + const result = await service.refresh('linkcode-official'); + + expect(result.releases).toHaveLength(1); + expect(fetchIndex).toHaveBeenNthCalledWith( + 3, + 'https://plugins.example/index.json', + expect.objectContaining({ headers: {} }), + ); + }); + + it('does not refresh or resolve releases from a disabled marketplace', async () => { + const disabled: LinkCodeMarketplaceConfigList = [{ ...MARKETPLACES[0], enabled: false }]; + const fetchIndex = vi.fn(); + const service = new DaemonLinkCodeMarketplaceService(disabled, fetchIndex); + + await expect(service.refresh('linkcode-official')).rejects.toThrow('Marketplace is disabled'); + expect(fetchIndex).not.toHaveBeenCalled(); + expect( + service.resolveRelease({ + marketplaceId: 'linkcode-official', + pluginId: 'arcbox/latex', + version: '1.2.0', + }), + ).toBeUndefined(); + }); + it('discards cached validators when the configured source URL changed', async () => { const fetchIndex = vi.fn(() => Promise.resolve(fakeResponse(200, JSON.stringify(INDEX), { etag: '"index-v1"' })), diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts new file mode 100644 index 000000000..02ae0ce4e --- /dev/null +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -0,0 +1,167 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { + InstalledLinkCodePlugin, + LinkCodePluginManifest, + LinkCodePluginRelease, +} from '@linkcode/schema'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makePluginTmpDir, pluginPackageDir, pluginRegistryPath } from '../plugin-store/paths'; +import { DaemonLinkCodePluginStore } from '../plugin-store/store'; +import { createInMemoryVault } from './fixtures/in-memory-vault'; + +const mocks = vi.hoisted(() => ({ + downloadVerified: vi.fn(), + tarExtract: vi.fn(), +})); + +vi.mock('@linkcode/assets', () => ({ downloadVerified: mocks.downloadVerified })); +vi.mock('tar', () => ({ extract: mocks.tarExtract })); + +let savedHome: string | undefined; + +beforeEach(() => { + savedHome = process.env.HOME; + process.env.HOME = mkdtempSync(join(tmpdir(), 'linkcode-plugin-store-')); + process.env.LINKCODE_CHANNEL = 'release'; + mocks.downloadVerified.mockReset().mockResolvedValue(undefined); + mocks.tarExtract.mockReset(); +}); + +afterEach(() => { + process.env.HOME = savedHome; + delete process.env.LINKCODE_CHANNEL; + vi.restoreAllMocks(); +}); + +function manifest(version: string, componentName = 'latex'): LinkCodePluginManifest { + return { + manifestVersion: 1, + id: 'arcbox/latex', + version, + keywords: [], + components: [{ kind: 'skill', name: componentName, entry: 'skills/latex/SKILL.md' }], + assets: [], + }; +} + +function record(version: string): InstalledLinkCodePlugin { + return { + id: 'arcbox/latex', + version, + marketplaceId: 'linkcode-official', + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + enabled: true, + path: pluginPackageDir('arcbox/latex', version), + }; +} + +function writePackage(installed: InstalledLinkCodePlugin, packageManifest: unknown): void { + mkdirSync(installed.path, { recursive: true }); + writeFileSync(join(installed.path, 'manifest.json'), JSON.stringify(packageManifest)); +} + +function writeRegistry(records: InstalledLinkCodePlugin[]): void { + const path = pluginRegistryPath(); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, JSON.stringify(records)); +} + +function settingsManifest(version: string): LinkCodePluginManifest { + return { + ...manifest(version), + settings: { + account: { type: 'string', label: 'Account' }, + authcode: { type: 'password', label: 'Authorization code', secret: true }, + }, + }; +} + +describe('DaemonLinkCodePluginStore', () => { + it('allocates a unique staging directory for concurrent installs of the same release', () => { + expect(makePluginTmpDir('arcbox/latex', '0.2.0')).not.toBe( + makePluginTmpDir('arcbox/latex', '0.2.0'), + ); + }); + + it('uses the most recently installed record for legacy duplicate plugin ids', () => { + const v1 = record('0.1.0'); + const v2 = record('0.2.0'); + writePackage(v1, { ...manifest('0.1.0'), futureManifestField: 'ignored' }); + writePackage(v2, manifest('0.2.0')); + writeRegistry([v1, v2]); + + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + expect(store.list()).toMatchObject([{ installed: { version: '0.2.0' } }]); + expect(store.get('arcbox/latex')?.installed.version).toBe('0.2.0'); + }); + + it('replaces an older package and returns the verified on-disk manifest', async () => { + const v0 = record('0.0.1'); + const v1 = record('0.1.0'); + writePackage(v0, manifest('0.0.1')); + writePackage(v1, manifest('0.1.0')); + writeRegistry([v0, v1]); + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(manifest('0.2.0', 'package-skill'))); + }); + const release = { + manifest: manifest('0.2.0', 'index-skill'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + const installed = await store.install(release, 'linkcode-official'); + + expect(installed.manifest.components[0]?.name).toBe('package-skill'); + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('package-skill'); + expect(existsSync(v0.path)).toBe(false); + expect(existsSync(v1.path)).toBe(false); + expect(JSON.parse(readFileSync(pluginRegistryPath(), 'utf8'))).toMatchObject([ + { id: 'arcbox/latex', version: '0.2.0' }, + ]); + }); + + it('rolls back config and secret changes when the vault rejects a settings update', () => { + const installed = record('0.1.0'); + writePackage(installed, settingsManifest('0.1.0')); + writeRegistry([installed]); + const baseVault = createInMemoryVault(); + const store = new DaemonLinkCodePluginStore(baseVault); + store.setSettings('arcbox/latex', { + set: { account: 'old@example.com', authcode: 'old-secret' }, + }); + const vaultFailure = new Error('vault unavailable'); + const flakyVault = { + ...baseVault, + namespace(name: Parameters[0]) { + const secrets = baseVault.namespace(name); + if (name !== 'plugin') return secrets; + return { + ...secrets, + set(key: string, value: string) { + if (key === 'arcbox/latex.authcode' && value === 'new-secret') throw vaultFailure; + secrets.set(key, value); + }, + }; + }, + }; + + expect(() => + new DaemonLinkCodePluginStore(flakyVault).setSettings('arcbox/latex', { + set: { account: 'new@example.com', authcode: 'new-secret' }, + }), + ).toThrow(vaultFailure); + + expect(store.getSettings('arcbox/latex')).toEqual({ + account: 'old@example.com', + authcode: 'old-secret', + }); + }); +}); diff --git a/apps/daemon/src/marketplace/service.ts b/apps/daemon/src/marketplace/service.ts index de046fa04..cda79d213 100644 --- a/apps/daemon/src/marketplace/service.ts +++ b/apps/daemon/src/marketplace/service.ts @@ -63,10 +63,18 @@ export class DaemonLinkCodeMarketplaceService implements LinkCodeMarketplaceServ } async refresh(marketplaceId: string): Promise { + return this.refreshIndex(marketplaceId, false); + } + + private async refreshIndex( + marketplaceId: string, + retriedWithoutValidators: boolean, + ): Promise { const config = nullthrow( this.marketplaces.find((entry) => entry.id === marketplaceId), `Unknown marketplace: ${marketplaceId}`, ); + if (!config.enabled) throw new Error(`Marketplace is disabled: ${marketplaceId}`); const url = config.source.url; // Validators are only replayed against the exact URL that produced them. const state = readRefreshState(marketplaceId); @@ -81,15 +89,28 @@ export class DaemonLinkCodeMarketplaceService implements LinkCodeMarketplaceServ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS), }); if (response.status === 304) { + const cachedIndex = readIndexCache(marketplaceId); + if (cachedIndex === undefined) { + if (retriedWithoutValidators) { + throw new Error('Marketplace returned HTTP 304 without a usable cached index'); + } + // A validator is only meaningful alongside the index it validates. If local state was + // deleted or corrupted, remove it and retry once without conditional request headers. + dropCachedIndexAndValidators(marketplaceId); + logger.warn( + { marketplaceId, operation: 'marketplace.refresh' }, + 'Received HTTP 304 without a usable cached index; retrying unconditionally', + ); + return this.refreshIndex(marketplaceId, true); + } if (validators !== undefined) { writeRefreshState({ ...validators, checkedAt: Date.now() }); } // A 304 means the remote index is unchanged, not that the catalog is empty. Reuse the // daemon's persisted index so clients can replace their snapshot safely even when they do not // retain the previous response in memory (for example after an uninstall or page remount). - const cachedIndex = readIndexCache(marketplaceId); return { - releases: cachedIndex === undefined ? [] : flattenReleases(cachedIndex), + releases: flattenReleases(cachedIndex), notModified: true, }; } @@ -113,7 +134,7 @@ export class DaemonLinkCodeMarketplaceService implements LinkCodeMarketplaceServ resolveRelease(identity: LinkCodeMarketplaceReleaseIdentity): LinkCodePluginRelease | undefined { const config = this.marketplaces.find((entry) => entry.id === identity.marketplaceId); const index = readIndexCache(identity.marketplaceId); - if (config === undefined || index === undefined) return undefined; + if (index === undefined || !config?.enabled) return undefined; const plugin = index.plugins.find((entry) => entry.id === identity.pluginId); const release = plugin?.releases.find( (candidate) => candidate.manifest.version === identity.version, @@ -129,6 +150,11 @@ export class DaemonLinkCodeMarketplaceService implements LinkCodeMarketplaceServ } } +function dropCachedIndexAndValidators(marketplaceId: string): void { + rmSync(marketplaceIndexCachePath(marketplaceId), { force: true }); + rmSync(marketplaceRefreshStatePath(marketplaceId), { force: true }); +} + function resolveMirrorUrl(url: string, indexUrl: string): string { if (ABSOLUTE_HTTP_URL_RE.test(url)) return url; return new URL(url, indexUrl).href; diff --git a/apps/daemon/src/plugin-store/paths.ts b/apps/daemon/src/plugin-store/paths.ts index 66460fb26..8878a7c70 100644 --- a/apps/daemon/src/plugin-store/paths.ts +++ b/apps/daemon/src/plugin-store/paths.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -28,12 +29,13 @@ export function pluginPackageDir(pluginId: string, version: string): string { return join(pluginsRoot(), ...safe, version); } -/** Staging dir beside the package dir, so publish is one same-volume `rename`. */ +/** Unique staging dir beside the package dir, so concurrent installs publish through one same-volume + * `rename` without sharing a partially extracted archive. */ export function makePluginTmpDir(pluginId: string, version: string): string { const dir = pluginPackageDir(pluginId, version); const parent = join(dir, '..'); mkdirSync(dir, { recursive: true }); - return join(parent, `.tmp-${process.pid}-${version}`); + return join(parent, `.tmp-${process.pid}-${version}-${randomUUID()}`); } /** Resolve product channel for callers that must not reach into the paths module's side effects. */ diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 0df7d0a0b..6221b74b0 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -25,7 +25,10 @@ import type { LinkCodePluginRelease, ManagedAssetArtifact, } from '@linkcode/schema'; -import { InstalledLinkCodePluginSchema, LinkCodePluginManifestSchema } from '@linkcode/schema'; +import { + InstalledLinkCodePluginSchema, + LinkCodePluginManifestReaderSchema, +} from '@linkcode/schema'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { extract as tarExtract } from 'tar'; import { loadPluginConfigValues, pluginSecretStore, savePluginConfigValues } from '../config'; @@ -42,7 +45,7 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { list(): InstalledLinkCodePluginEntry[] { const entries: InstalledLinkCodePluginEntry[] = []; - for (const record of readRegistry()) { + for (const record of currentRegistryRecords()) { const manifest = readManifest(record.path); if (manifest === undefined) continue; entries.push({ installed: record, manifest }); @@ -78,25 +81,54 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { } const settings = manifest.settings; const secrets = pluginSecretStore(this.vault); - const nonSecret = loadPluginConfigValues(pluginId); + const previousNonSecret = loadPluginConfigValues(pluginId); + const nextNonSecret = { ...previousNonSecret }; + const secretPatch = new Map(); if (patch.remove) { for (const fieldId of patch.remove) { const field = settings[fieldId]; if (field === undefined) continue; - if (field.secret) secrets.delete(`${pluginId}.${fieldId}`); - else delete nonSecret[fieldId]; + if (field.secret) secretPatch.set(`${pluginId}.${fieldId}`, undefined); + else delete nextNonSecret[fieldId]; } } if (patch.set) { for (const [fieldId, value] of Object.entries(patch.set)) { const field = settings[fieldId]; if (field === undefined) continue; - if (field.secret) secrets.set(`${pluginId}.${fieldId}`, String(value)); - else nonSecret[fieldId] = value; + if (field.secret) secretPatch.set(`${pluginId}.${fieldId}`, String(value)); + else nextNonSecret[fieldId] = value; + } + } + const previousSecrets = new Map( + [...secretPatch.keys()].map((key) => [key, secrets.get(key)] as const), + ); + + // There is no cross-file transaction between config.json and the vault. Commit config first; + // if the vault write then fails, restore config and every affected secret to their snapshots. + savePluginConfigValues(pluginId, nextNonSecret); + try { + applySecretPatch(secrets, secretPatch); + } catch (error) { + try { + applySecretPatch(secrets, previousSecrets); + } catch (rollbackError) { + logger.warn( + { error: rollbackError, pluginId, operation: 'plugin.settings.rollback-vault' }, + 'Failed to restore plugin secrets after a settings write failure', + ); } + try { + savePluginConfigValues(pluginId, previousNonSecret); + } catch (rollbackError) { + logger.warn( + { error: rollbackError, pluginId, operation: 'plugin.settings.rollback-config' }, + 'Failed to restore plugin config after a settings write failure', + ); + } + throw error; } - savePluginConfigValues(pluginId, nonSecret); return Promise.resolve(); } @@ -112,9 +144,11 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { if (httpsUrls.length === 0) { throw new Error('Plugin release has no HTTPS download URL'); } + const previousRecords = readRegistry().filter((entry) => entry.id === manifest.id); const targetDir = pluginPackageDir(manifest.id, manifest.version); const stagingDir = makePluginTmpDir(manifest.id, manifest.version); const tgzPath = join(stagingDir, 'package.tgz'); + let installedManifest: LinkCodePluginManifest; mkdirSync(stagingDir, { recursive: true }); try { const downloadArtifact: ManagedAssetArtifact = { @@ -126,11 +160,12 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { await downloadVerified(downloadArtifact, tgzPath, {}); await tarExtract({ file: tgzPath, cwd: stagingDir, strip: 1 }); const onDisk = readManifest(stagingDir); - if (onDisk?.id !== manifest.id || onDisk?.version !== manifest.version) { + if (onDisk?.id !== manifest.id || onDisk.version !== manifest.version) { throw new Error( `Extracted manifest does not match release ${manifest.id}@${manifest.version}`, ); } + installedManifest = onDisk; rmSync(targetDir, { recursive: true, force: true }); mkdirSync(dirname(targetDir), { recursive: true }); renameSync(stagingDir, targetDir); @@ -150,18 +185,32 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { path: targetDir, }; upsertRegistry(record); + // A plugin id has one active settings block and one wire identity, so keep exactly one installed + // version. Remove stale package directories only after the new package and registry record exist. + for (const previous of previousRecords) { + if (previous.path === targetDir) continue; + try { + rmSync(previous.path, { recursive: true, force: true }); + } catch (error) { + logger.warn( + { error, pluginId: manifest.id, path: previous.path, operation: 'plugin.install.gc' }, + 'Failed to remove stale plugin package', + ); + } + } logger.info( { pluginId: manifest.id, version: manifest.version, operation: 'plugin.install' }, 'Installed LinkCode plugin', ); - return { installed: record, manifest }; + return { installed: record, manifest: installedManifest }; } uninstall(pluginId: string): Promise { - const record = readRegistry().find((entry) => entry.id === pluginId); - if (record) { - rmSync(record.path, { recursive: true, force: true }); - writeRegistry(readRegistry().filter((entry) => entry.id !== pluginId)); + const records = readRegistry(); + const matches = records.filter((entry) => entry.id === pluginId); + if (matches.length > 0) { + for (const record of matches) rmSync(record.path, { recursive: true, force: true }); + writeRegistry(records.filter((entry) => entry.id !== pluginId)); } // Non-secret values are dropped by writing an empty block; secret values are pruned below. savePluginConfigValues(pluginId, {}); @@ -170,6 +219,16 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { } } +function applySecretPatch( + secrets: SecretStore, + patch: ReadonlyMap, +): void { + for (const [key, value] of patch) { + if (value === null || value === undefined) secrets.delete(key); + else secrets.set(key, value); + } +} + function readRegistry(): InstalledLinkCodePlugin[] { const path = pluginRegistryPath(); let raw: string; @@ -195,10 +254,16 @@ function readRegistry(): InstalledLinkCodePlugin[] { return records; } +function currentRegistryRecords(): InstalledLinkCodePlugin[] { + const latestById = new Map(); + for (const record of readRegistry()) latestById.set(record.id, record); + // Old builds could write duplicate versions. The newest registry entry wins for reads, while the + // next successful install or uninstall compacts the registry and removes every stale package dir. + return [...latestById.values()]; +} + function upsertRegistry(record: InstalledLinkCodePlugin): void { - const next = readRegistry().filter( - (entry) => entry.id !== record.id || entry.version !== record.version, - ); + const next = readRegistry().filter((entry) => entry.id !== record.id); next.push(record); writeRegistry(next); } @@ -237,7 +302,7 @@ function readManifest(packageDir: string): LinkCodePluginManifest | undefined { logger.warn({ err, packageDir, operation: 'plugin.manifest' }, 'Malformed plugin manifest'); return undefined; } - const result = LinkCodePluginManifestSchema.safeParse(parsed); + const result = LinkCodePluginManifestReaderSchema.safeParse(parsed); if (!result.success) { logger.warn({ packageDir, operation: 'plugin.manifest' }, 'Dropping invalid plugin manifest'); return undefined; diff --git a/packages/foundation/schema/src/model/__tests__/plugin.test.ts b/packages/foundation/schema/src/model/__tests__/plugin.test.ts index ba8ea677e..d0f3547f7 100644 --- a/packages/foundation/schema/src/model/__tests__/plugin.test.ts +++ b/packages/foundation/schema/src/model/__tests__/plugin.test.ts @@ -293,6 +293,18 @@ describe('LinkCode plugin package contracts', () => { expect(LinkCodePluginManifestSchema.safeParse(mailManifest).success).toBe(true); }); + it('rejects password settings unless they are routed to the secret vault', () => { + expect( + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + settings: { + ...mailManifest.settings, + authcode: { ...mailManifest.settings.authcode, secret: false }, + }, + }).success, + ).toBe(false); + }); + it.each(['/tmp/index.js', '../dist/index.js', String.raw`dist\index.js`])( 'rejects a non-package-relative mcp entry %s', (entry) => { diff --git a/packages/foundation/schema/src/model/linkcode-plugin.ts b/packages/foundation/schema/src/model/linkcode-plugin.ts index eccd5e78c..4bf4ab5e4 100644 --- a/packages/foundation/schema/src/model/linkcode-plugin.ts +++ b/packages/foundation/schema/src/model/linkcode-plugin.ts @@ -146,6 +146,13 @@ export const LinkCodePluginSettingFieldSchema = z path: ['enum'], }); } + if (field.type === 'password' && field.secret !== true) { + ctx.addIssue({ + code: 'custom', + message: 'A password setting must be secret', + path: ['secret'], + }); + } }); export type LinkCodePluginSettingField = z.infer; @@ -258,7 +265,7 @@ export const LinkCodePluginManifestSchema = z export type LinkCodePluginManifest = z.infer; /** Forward-compatible marketplace reader for additive fields within manifest version 1. */ -const LinkCodePluginManifestReaderSchema = z +export const LinkCodePluginManifestReaderSchema = z .object(linkCodePluginManifestFields) .superRefine(rejectDuplicateComponents) .superRefine(rejectUnresolvedEnvBindings); diff --git a/packages/host/engine/src/__tests__/plugin-market.test.ts b/packages/host/engine/src/__tests__/plugin-market.test.ts index d6a66e2aa..29a1ff071 100644 --- a/packages/host/engine/src/__tests__/plugin-market.test.ts +++ b/packages/host/engine/src/__tests__/plugin-market.test.ts @@ -193,6 +193,30 @@ describe('plugin-market.refresh', () => { await engine.stop(); }); + it('refuses a disabled marketplace without calling refresh', async () => { + const refresh = vi.fn(); + const { engine, sent, inject } = harness({ + linkCodeMarketplace: fakeMarketplace({ + list: () => [{ ...MARKETPLACE, enabled: false }], + refresh, + }).service, + }); + await engine.start(); + inject({ + kind: 'plugin-market.refresh', + clientReqId: 'r1', + marketplaceId: 'linkcode-official', + }); + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'forbidden', + message: 'Marketplace is disabled: linkcode-official', + }); + expect(refresh).not.toHaveBeenCalled(); + await engine.stop(); + }); + it('fails unsupported when the host has no marketplace plane', async () => { const { engine, sent, inject } = harness(); await engine.start(); @@ -279,6 +303,28 @@ describe('plugin-market.install', () => { await engine.stop(); }); + it('refuses installs from a disabled marketplace without resolving or installing', async () => { + const { store, install } = fakeStore(); + const { service, resolveRelease } = fakeMarketplace({ + list: () => [{ ...MARKETPLACE, enabled: false }], + }); + const { engine, sent, inject } = harness({ + linkCodePluginStore: store, + linkCodeMarketplace: service, + }); + await engine.start(); + inject({ kind: 'plugin-market.install', clientReqId: 'r1', release: IDENTITY }); + expect(sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'r1', + code: 'forbidden', + message: 'Marketplace is disabled: linkcode-official', + }); + expect(resolveRelease).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + await engine.stop(); + }); + it('fails the request when the store install rejects', async () => { const { store } = fakeStore(); store.install = () => Promise.reject(new Error('integrity mismatch detail')); diff --git a/packages/host/engine/src/plugin/market-request-handler.ts b/packages/host/engine/src/plugin/market-request-handler.ts index 873ba6c19..2a2217139 100644 --- a/packages/host/engine/src/plugin/market-request-handler.ts +++ b/packages/host/engine/src/plugin/market-request-handler.ts @@ -105,7 +105,8 @@ export class LinkCodePluginMarketRequestHandler { }), ); } - if (!marketplace.list().some((entry) => entry.id === marketplaceId)) { + const config = marketplace.list().find((entry) => entry.id === marketplaceId); + if (config === undefined) { return Effect.fail( new RequestError({ code: 'not_found', @@ -113,6 +114,14 @@ export class LinkCodePluginMarketRequestHandler { }), ); } + if (!config.enabled) { + return Effect.fail( + new RequestError({ + code: 'forbidden', + message: `Marketplace is disabled: ${marketplaceId}`, + }), + ); + } return Effect.tryPromise({ try: () => marketplace.refresh(marketplaceId), catch: (cause) => @@ -140,6 +149,23 @@ export class LinkCodePluginMarketRequestHandler { }), ); } + const config = marketplace.list().find((entry) => entry.id === payload.release.marketplaceId); + if (config === undefined) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown marketplace: ${payload.release.marketplaceId}`, + }), + ); + } + if (!config.enabled) { + return Effect.fail( + new RequestError({ + code: 'forbidden', + message: `Marketplace is disabled: ${payload.release.marketplaceId}`, + }), + ); + } const release = marketplace.resolveRelease(payload.release); if (release === undefined) { return Effect.fail( diff --git a/packages/integrations/mail-mcp/package.json b/packages/integrations/mail-mcp/package.json index 847c53ced..88ab5b592 100644 --- a/packages/integrations/mail-mcp/package.json +++ b/packages/integrations/mail-mcp/package.json @@ -10,7 +10,6 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "typecheck": "tsc --noEmit", "lint": "eslint --format=sukka ." }, "dependencies": { diff --git a/packages/integrations/mail-mcp/src/__tests__/config.test.ts b/packages/integrations/mail-mcp/src/__tests__/config.test.ts index ccb744718..ec6424428 100644 --- a/packages/integrations/mail-mcp/src/__tests__/config.test.ts +++ b/packages/integrations/mail-mcp/src/__tests__/config.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { ConfigError, loadConfig } from '../config'; +import { ConfigError, inferPresetFromEmail, loadConfig } from '../config'; + +const RE_IMAP_PORT = /IMAP_PORT/; describe('loadConfig presets', () => { it.each([ @@ -19,6 +21,22 @@ describe('loadConfig presets', () => { }, ); + it('corrects a mismatched configured preset from a QQ account suffix', () => { + const cfg = loadConfig({ + MAIL_USER: 'user@qq.com', + MAIL_PASSWORD: 'qq-authorisation-code', + MAIL_PRESET: '163', + }); + expect(cfg.imap.host).toBe('imap.qq.com'); + expect(cfg.smtp.host).toBe('smtp.qq.com'); + }); + + it('infers a preset when the account suffix is known and MAIL_PRESET is absent', () => { + const cfg = loadConfig({ MAIL_USER: 'user@163.com', MAIL_PASSWORD: '163-authorisation-code' }); + expect(cfg.imap.host).toBe('imap.163.com'); + expect(cfg.smtp.host).toBe('smtp.163.com'); + }); + it('defaults SMTP_USER/SMTP_PASSWORD/SMTP_FROM to the mail account', () => { const cfg = loadConfig({ MAIL_USER: 'u@163.com', MAIL_PASSWORD: 'code', MAIL_PRESET: '163' }); expect(cfg.smtp.user).toBe('u@163.com'); @@ -27,6 +45,16 @@ describe('loadConfig presets', () => { }); }); +describe('inferPresetFromEmail', () => { + it.each([ + ['person@qq.com', 'qq'], + ['PERSON@163.COM', '163'], + ['person@example.com', null], + ] as const)('maps %s to %s', (email, expected) => { + expect(inferPresetFromEmail(email)).toBe(expected); + }); +}); + describe('loadConfig overrides', () => { it('custom IMAP host with IMAP_SECURE=false derives port 143', () => { const cfg = loadConfig({ @@ -65,6 +93,24 @@ describe('loadConfig overrides', () => { expect(cfg.imap.port).toBe(993); expect(cfg.smtp.port).toBe(465); }); + + it('IMAP_PORT/SMTP_PORT override preset and default ports', () => { + const cfg = loadConfig({ + MAIL_USER: 'u', + MAIL_PASSWORD: 'p', + MAIL_PRESET: '163', + IMAP_PORT: '1993', + SMTP_PORT: '2465', + }); + expect(cfg.imap.port).toBe(1993); + expect(cfg.smtp.port).toBe(2465); + }); + + it('rejects a non-numeric port', () => { + expect(() => + loadConfig({ MAIL_USER: 'u', MAIL_PASSWORD: 'p', MAIL_PRESET: '163', IMAP_PORT: 'abc' }), + ).toThrow(RE_IMAP_PORT); + }); }); describe('loadConfig errors', () => { diff --git a/packages/integrations/mail-mcp/src/__tests__/imap.test.ts b/packages/integrations/mail-mcp/src/__tests__/imap.test.ts index b080e6db5..8faafd2f0 100644 --- a/packages/integrations/mail-mcp/src/__tests__/imap.test.ts +++ b/packages/integrations/mail-mcp/src/__tests__/imap.test.ts @@ -1,6 +1,7 @@ +import { trueFn } from 'foxts/noop'; import type { MailboxObject } from 'imapflow'; import { describe, expect, it, vi } from 'vitest'; -import type { ImapFlowPort, MailImapClient, ReplyOrigin } from '../imap'; +import type { ImapFlowFactory, ImapFlowPort, MailImapClient, ReplyOrigin } from '../imap'; import { MailImap } from '../imap'; import type { MailConfig } from '../types'; @@ -32,6 +33,7 @@ function makeFlow(overrides: Partial = {}, mailboxExists = 10): Im connect: vi.fn(), logout: vi.fn(), close: vi.fn(), + on: vi.fn(), list: vi.fn(), getMailboxLock: vi.fn().mockResolvedValue(lock), search: vi.fn(), @@ -48,6 +50,25 @@ function makeImap(flow: ImapFlowPort): MailImapClient { return new MailImap(makeConfig(), () => flow); } +function makeEventedFlow( + overrides: Partial = {}, +): ImapFlowPort & { emit(event: 'close' | 'error', error?: Error): void } { + const listeners = new Map void>>(); + const flow = makeFlow(overrides) as ImapFlowPort & { + on(event: 'close' | 'error', listener: (error?: Error) => void): void; + emit(event: 'close' | 'error', error?: Error): void; + }; + flow.on = (event, listener) => { + const existing = listeners.get(event) ?? []; + existing.push(listener); + listeners.set(event, existing); + }; + flow.emit = (event, error) => { + for (const listener of listeners.get(event) ?? []) listener(error); + }; + return flow; +} + describe('MailImap.listFolders', () => { it('maps folders with status counts', async () => { const list = vi.fn().mockResolvedValue([ @@ -64,6 +85,58 @@ describe('MailImap.listFolders', () => { { path: 'Sent', specialUse: String.raw`\Sent`, messages: 3, unseen: undefined }, ]); }); + + it('shares an in-flight connection across concurrent calls', async () => { + let resolveConnect: (() => void) | undefined; + const connect = vi.fn( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }), + ); + const flow = makeFlow({ connect, list: vi.fn().mockResolvedValue([]) }); + const factory = vi.fn(() => flow); + const imap = new MailImap(makeConfig(), factory); + + const first = imap.listFolders(); + const second = imap.listFolders(); + expect(factory).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledTimes(1); + + resolveConnect?.(); + await expect(Promise.all([first, second])).resolves.toEqual([[], []]); + }); + + it('drops a closed connection and reconnects on the next call', async () => { + const first = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); + const second = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); + const factory = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second); + const imap = new MailImap(makeConfig(), factory); + + await imap.listFolders(); + first.emit('close'); + await imap.listFolders(); + + expect(factory).toHaveBeenCalledTimes(2); + expect(second.connect).toHaveBeenCalledTimes(1); + }); + + it('handles an IMAP error, logs to stderr, and reconnects', async () => { + const first = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); + const second = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); + const factory = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(trueFn); + const imap = new MailImap(makeConfig(), factory); + + await imap.listFolders(); + first.emit('error', new Error('socket reset')); + await imap.listFolders(); + + expect(stderr).toHaveBeenCalledWith( + '[linkcode-mail-mcp] IMAP connection error: socket reset\n', + ); + expect(factory).toHaveBeenCalledTimes(2); + }); }); describe('MailImap.listMessages', () => { diff --git a/packages/integrations/mail-mcp/src/config.ts b/packages/integrations/mail-mcp/src/config.ts index 4f96743b5..001c4cd75 100644 --- a/packages/integrations/mail-mcp/src/config.ts +++ b/packages/integrations/mail-mcp/src/config.ts @@ -36,6 +36,20 @@ function parsePreset(value: string | undefined): MailPreset | null { throw new ConfigError(`MAIL_PRESET must be one of: 163, qq, exmail (got: ${value})`); } +/** Infer the two consumer-mail presets whose domains uniquely identify their provider. */ +export function inferPresetFromEmail(user: string): MailPreset | null { + const domain = user.trim().toLowerCase().split('@').at(-1); + if (domain === 'qq.com') return 'qq'; + if (domain === '163.com') return '163'; + return null; +} + +function resolvePreset(user: string, configuredPreset: MailPreset | null): MailPreset | null { + // The settings form has a backwards-compatible default of 163. Prefer a recognisable account + // suffix so a QQ account cannot accidentally be sent to 163's IMAP/SMTP endpoints. + return inferPresetFromEmail(user) ?? configuredPreset; +} + function defaultImapPort(secure: boolean): number { return secure ? 993 : 143; } @@ -44,6 +58,15 @@ function defaultSmtpPort(secure: boolean): number { return secure ? 465 : 587; } +function parsePort(value: string | undefined, name: string): number | null { + if (value === undefined || value === '') return null; + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new ConfigError(`${name} must be an integer between 1 and 65535 (got: ${value})`); + } + return port; +} + export function loadConfig(env: NodeJS.ProcessEnv = process.env): MailConfig { const user = env.MAIL_USER?.trim(); if (!user) throw new ConfigError('MAIL_USER is required'); @@ -55,7 +78,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): MailConfig { ); } - const preset = parsePreset(env.MAIL_PRESET); + const preset = resolvePreset(user, parsePreset(env.MAIL_PRESET)); const presetHosts = preset ? PRESETS[preset] : null; const imapHost = env.IMAP?.trim() || presetHosts?.imap.host; @@ -70,9 +93,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): MailConfig { const imapSecure = parseBool(env.IMAP_SECURE, true); const smtpSecure = parseBool(env.SMTP_SECURE, true); - // A preset pins host+port; a custom host override falls back to the secure-derived default port. - const imapPort = presetHosts && !env.IMAP ? presetHosts.imap.port : defaultImapPort(imapSecure); - const smtpPort = presetHosts && !env.SMTP ? presetHosts.smtp.port : defaultSmtpPort(smtpSecure); + // Precedence: explicit port env > preset's pinned port (host not overridden) > secure default. + const imapPort = + parsePort(env.IMAP_PORT, 'IMAP_PORT') ?? + (presetHosts && !env.IMAP ? presetHosts.imap.port : defaultImapPort(imapSecure)); + const smtpPort = + parsePort(env.SMTP_PORT, 'SMTP_PORT') ?? + (presetHosts && !env.SMTP ? presetHosts.smtp.port : defaultSmtpPort(smtpSecure)); const smtpUser = env.SMTP_USER?.trim() || user; // `||` not `??`: an empty SMTP_PASSWORD would otherwise log in with no credential. diff --git a/packages/integrations/mail-mcp/src/imap.ts b/packages/integrations/mail-mcp/src/imap.ts index 3648fa01b..c5c5702cc 100644 --- a/packages/integrations/mail-mcp/src/imap.ts +++ b/packages/integrations/mail-mcp/src/imap.ts @@ -60,9 +60,11 @@ export interface MailboxLock { export interface ImapFlowPort { readonly mailbox: MailboxObject | false; - connect(): Promise; + // Property-style so tests can reference the vi.fn() doubles without an unbound-method warning. + connect: () => Promise; logout(): Promise; close(): void; + on(event: 'error' | 'close', handler: (error?: unknown) => void): void; list(options?: { statusQuery?: Partial> }): Promise; getMailboxLock(path: string, options?: { readOnly?: boolean }): Promise; search(query: SearchObject, options?: { uid?: boolean }): Promise; @@ -107,10 +109,18 @@ export interface MailImapClient { export type ImapFlowFactory = (config: MailConfig) => ImapFlowPort; +/** The subset of ImapFlow's EventEmitter surface used to keep cached connections healthy. */ +interface EventedImapFlowPort { + on(event: 'close', listener: () => void): unknown; + on(event: 'error', listener: (error: Error) => void): unknown; +} + const SEEN_FLAG = String.raw`\Seen`; export class MailImap implements MailImapClient { private flow: ImapFlowPort | undefined; + private connecting: Promise | undefined; + private pendingFlow: ImapFlowPort | undefined; constructor( private readonly config: MailConfig, @@ -257,9 +267,10 @@ export class MailImap implements MailImapClient { } async close(): Promise { - const flow = this.flow; + const flow = this.flow ?? this.pendingFlow; if (!flow) return; this.flow = undefined; + this.pendingFlow = undefined; try { await flow.logout(); } catch { @@ -269,11 +280,53 @@ export class MailImap implements MailImapClient { private async ensureConnected(): Promise { if (this.flow) return this.flow; + if (this.connecting) return this.connecting; + const connecting = this.connect(); + this.connecting = connecting; + try { + return await connecting; + } finally { + if (this.connecting === connecting) this.connecting = undefined; + } + } + + private async connect(): Promise { const flow = this.flowFactory ? this.flowFactory(this.config) : createImapFlow(this.config); - await flow.connect(); - this.flow = flow; - return flow; + this.pendingFlow = flow; + this.attachLifecycleHandlers(flow); + try { + await flow.connect(); + // `close()` may have been called while the asynchronous connect was in progress. + if (this.pendingFlow !== flow) { + flow.close(); + throw new Error('IMAP connection closed while connecting'); + } + this.flow = flow; + return flow; + } finally { + if (this.pendingFlow === flow) this.pendingFlow = undefined; + } + } + + private attachLifecycleHandlers(flow: ImapFlowPort): void { + if (!isEventedImapFlow(flow)) return; + flow.on('close', () => this.invalidateFlow(flow)); + // An EventEmitter `error` event without a listener terminates Node. Log it on stderr (stdout + // is MCP JSON-RPC) and invalidate the cached flow so the next tool call establishes a socket. + flow.on('error', (error) => { + this.invalidateFlow(flow); + process.stderr.write(`[linkcode-mail-mcp] IMAP connection error: ${error.message}\n`); + }); } + + private invalidateFlow(flow: ImapFlowPort): void { + if (this.flow === flow) this.flow = undefined; + if (this.pendingFlow === flow) this.pendingFlow = undefined; + } +} + +function isEventedImapFlow(flow: ImapFlowPort): flow is ImapFlowPort & EventedImapFlowPort { + return 'on' in flow && typeof flow.on === 'function'; } function createImapFlow(config: MailConfig): ImapFlowPort { diff --git a/packages/integrations/mail-mcp/src/index.ts b/packages/integrations/mail-mcp/src/index.ts index a1e673239..91c3cd64e 100644 --- a/packages/integrations/mail-mcp/src/index.ts +++ b/packages/integrations/mail-mcp/src/index.ts @@ -9,7 +9,9 @@ import { MailImap } from './imap'; import { MailSmtp } from './smtp'; import { registerMailTools } from './tools'; -const VERSION = '0.0.0'; +// Build-time injected from package.json by tsup `define`; the fallback only covers unbundled runs. +declare const __MAIL_MCP_VERSION__: string | undefined; +const VERSION = typeof __MAIL_MCP_VERSION__ === 'string' ? __MAIL_MCP_VERSION__ : '0.0.0'; async function main(): Promise { const config = loadConfig(); @@ -27,7 +29,7 @@ async function main(): Promise { await server.connect(transport); let shuttingDown = false; - const shutdown = (signal: string): void => { + const shutdown = (exitProcess = false): void => { if (shuttingDown) return; shuttingDown = true; void (async () => { @@ -38,12 +40,15 @@ async function main(): Promise { } await Promise.allSettled([imap.close(), smtp.close()]); process.exitCode = 0; - if (signal) process.exit(0); + if (exitProcess) process.exit(0); })(); }; - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown(true)); + process.on('SIGTERM', () => shutdown(true)); + // The daemon owns stdin. If it dies or closes the MCP session, do not leave this plugin process + // running with open IMAP/SMTP sockets. + transport.onclose = () => shutdown(true); } main().catch((error) => { diff --git a/packages/integrations/mail-mcp/src/smtp.ts b/packages/integrations/mail-mcp/src/smtp.ts index 0e42f35c1..d7987ebb7 100644 --- a/packages/integrations/mail-mcp/src/smtp.ts +++ b/packages/integrations/mail-mcp/src/smtp.ts @@ -81,6 +81,8 @@ function createSmtpTransporter(config: MailConfig): SmtpTransporter { host: config.smtp.host, port: config.smtp.port, secure: config.smtp.secure, + // On the non-secure port, require STARTTLS instead of silently falling back to plaintext. + requireTLS: !config.smtp.secure, auth: { user: config.smtp.user, pass: config.smtp.password }, }); } diff --git a/packages/integrations/mail-mcp/tsup.config.ts b/packages/integrations/mail-mcp/tsup.config.ts index b7e958b40..f2ff81faf 100644 --- a/packages/integrations/mail-mcp/tsup.config.ts +++ b/packages/integrations/mail-mcp/tsup.config.ts @@ -1,5 +1,10 @@ +import { readFileSync } from 'node:fs'; import { defineConfig } from 'tsup'; +const { version } = JSON.parse(readFileSync(new URL('package.json', import.meta.url), 'utf8')) as { + version: string; +}; + export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], @@ -10,6 +15,7 @@ export default defineConfig({ platform: 'node', // Installed plugin copies run without node_modules: bundle every dependency into one file. noExternal: [/.+/], + define: { __MAIL_MCP_VERSION__: JSON.stringify(version) }, banner: { // CJS deps (imapflow) keep their require() calls after bundling; give them a real require. js: "#!/usr/bin/env node\nimport { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);", diff --git a/scripts/dev-marketplace.mts b/scripts/dev-marketplace.mts index f0264edb7..5931dcda4 100644 --- a/scripts/dev-marketplace.mts +++ b/scripts/dev-marketplace.mts @@ -11,8 +11,8 @@ * Point the dev daemon at it with: * LINKCODE_MARKETPLACE_URL=http://127.0.0.1:18741/index.json pnpm -F @linkcode/daemon dev * - * The manifest's mcp-server args point at the INSTALLED copy under the dev channel's plugin - * store (~/.linkcode.development/plugins/...), so a session genuinely runs the artifact the + * The manifest's mcp-server entry is package-relative, so the Engine resolves it against the + * installed copy under the dev channel's plugin store. A session genuinely runs the artifact the * installer published — not the repo's dist. */ @@ -79,6 +79,7 @@ const manifest = { preset: { type: 'enum', label: '服务商', + description: '会根据 @qq.com / @163.com 自动识别;腾讯企业邮箱请手动选择 exmail。', enum: ['163', 'qq', 'exmail'], default: '163', }, From 05b49deb3e9f50b83500f7e265a51665f7c8aa60 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Tue, 25 Aug 2026 17:08:24 +0800 Subject: [PATCH 04/19] fix: address plugin marketplace review feedback and split the mail plugin out of tree --- apps/daemon/e2e/plugin-marketplace.e2e.ts | 30 +- .../src/__tests__/fixtures/in-memory-vault.ts | 7 + apps/daemon/src/__tests__/marketplace.test.ts | 10 +- .../daemon/src/__tests__/plugin-store.test.ts | 82 +++- apps/daemon/src/config.ts | 2 +- apps/daemon/src/plugin-store/store.ts | 197 +++++---- apps/daemon/src/secrets/vault.ts | 9 + docs/ENVIRONMENT.md | 2 +- packages/client/sdk/src/client.ts | 2 +- packages/client/sdk/src/operations.ts | 4 +- .../src/mock/data/linkcode-marketplace.ts | 64 +-- .../workbench/src/mock/dev-mock-host.ts | 22 +- .../__tests__/linkcode-config-dialog.test.tsx | 3 +- .../plugins/__tests__/linkcode-config.test.ts | 15 + .../settings/plugins/__tests__/view.test.ts | 195 +++++++-- .../src/settings/plugins/linkcode-config.ts | 21 +- .../src/settings/plugins/linkcode-tab.tsx | 27 +- .../src/settings/plugins/mcp-settings.tsx | 55 --- .../workbench/src/settings/plugins/view.ts | 58 ++- packages/foundation/common/package.json | 1 + .../src/config/__tests__/contract.test.ts | 9 + .../foundation/common/src/config/contract.ts | 8 +- .../foundation/common/src/config/index.ts | 2 + .../foundation/common/src/config/semver.ts | 15 + .../foundation/schema/src/model/custom-mcp.ts | 11 +- .../schema/src/model/linkcode-marketplace.ts | 7 +- .../foundation/schema/src/wire/message.ts | 2 +- .../schema/tests/contract/wire/config.test.ts | 21 +- .../src/__tests__/start-options-mcp.test.ts | 123 ++++++ .../host/engine/src/plugin/config-service.ts | 23 +- .../host/engine/src/plugin/linkcode-store.ts | 14 +- .../src/session/start-options-resolver.ts | 2 + packages/integrations/mail-mcp/package.json | 29 -- .../mail-mcp/src/__tests__/body.test.ts | 156 ------- .../mail-mcp/src/__tests__/config.test.ts | 150 ------- .../mail-mcp/src/__tests__/imap.test.ts | 296 ------------- .../mail-mcp/src/__tests__/smtp.test.ts | 73 ---- .../mail-mcp/src/__tests__/tools.test.ts | 181 -------- packages/integrations/mail-mcp/src/body.ts | 90 ---- packages/integrations/mail-mcp/src/config.ts | 127 ------ packages/integrations/mail-mcp/src/imap.ts | 411 ------------------ packages/integrations/mail-mcp/src/index.ts | 59 --- packages/integrations/mail-mcp/src/smtp.ts | 88 ---- packages/integrations/mail-mcp/src/tools.ts | 225 ---------- packages/integrations/mail-mcp/src/types.ts | 24 - packages/integrations/mail-mcp/tsconfig.json | 4 - packages/integrations/mail-mcp/tsup.config.ts | 23 - packages/presentation/i18n/src/locales/en.ts | 9 +- .../presentation/i18n/src/locales/zh-cn.ts | 9 +- .../ui/src/shell/plugins/linkcode-catalog.tsx | 12 +- .../ui/src/shell/plugins/types.ts | 8 +- pnpm-lock.yaml | 96 ---- scripts/dev-marketplace.mts | 138 ++++-- tsconfig.json | 1 - 54 files changed, 875 insertions(+), 2377 deletions(-) delete mode 100644 packages/integrations/mail-mcp/package.json delete mode 100644 packages/integrations/mail-mcp/src/__tests__/body.test.ts delete mode 100644 packages/integrations/mail-mcp/src/__tests__/config.test.ts delete mode 100644 packages/integrations/mail-mcp/src/__tests__/imap.test.ts delete mode 100644 packages/integrations/mail-mcp/src/__tests__/smtp.test.ts delete mode 100644 packages/integrations/mail-mcp/src/__tests__/tools.test.ts delete mode 100644 packages/integrations/mail-mcp/src/body.ts delete mode 100644 packages/integrations/mail-mcp/src/config.ts delete mode 100644 packages/integrations/mail-mcp/src/imap.ts delete mode 100644 packages/integrations/mail-mcp/src/index.ts delete mode 100644 packages/integrations/mail-mcp/src/smtp.ts delete mode 100644 packages/integrations/mail-mcp/src/tools.ts delete mode 100644 packages/integrations/mail-mcp/src/types.ts delete mode 100644 packages/integrations/mail-mcp/tsconfig.json delete mode 100644 packages/integrations/mail-mcp/tsup.config.ts diff --git a/apps/daemon/e2e/plugin-marketplace.e2e.ts b/apps/daemon/e2e/plugin-marketplace.e2e.ts index 9d52c4d9f..b681a3e81 100644 --- a/apps/daemon/e2e/plugin-marketplace.e2e.ts +++ b/apps/daemon/e2e/plugin-marketplace.e2e.ts @@ -17,9 +17,9 @@ const marketplaceScript = join(repoRoot, 'scripts', 'dev-marketplace.mts'); const fixtureIndex = join(repoRoot, 'node_modules', '.cache', 'dev-marketplace', 'index.json'); const MARKETPLACE_ID = 'linkcode-official'; -const PLUGIN_ID = 'linkcode/mail'; +const PLUGIN_ID = 'linkcode/echo'; const PLUGIN_VERSION = '0.1.0'; -const AUTHCODE = 'e2e-secret-authcode'; +const SECRET_TOKEN = 'e2e-secret-token'; async function freePort(): Promise { const server = createServer(); @@ -118,7 +118,7 @@ async function main(): Promise { (entry) => entry.pluginId === PLUGIN_ID && entry.release.manifest.version === PLUGIN_VERSION, ), - 'catalog does not list linkcode/mail', + 'catalog does not list linkcode/echo', ); const second = await client.refreshPluginMarketplace(MARKETPLACE_ID); assert.equal(second.notModified, true, 'second refresh did not hit the ETag cache'); @@ -137,7 +137,7 @@ async function main(): Promise { version: PLUGIN_VERSION, }); assert.equal(installed.pluginId, PLUGIN_ID); - const packageDir = join(home, '.linkcode', 'plugins', 'linkcode', 'mail', PLUGIN_VERSION); + const packageDir = join(home, '.linkcode', 'plugins', 'linkcode', 'echo', PLUGIN_VERSION); assert(existsSync(join(packageDir, 'manifest.json')), 'installed manifest.json missing'); assert(existsSync(join(packageDir, 'dist', 'index.js')), 'installed dist/index.js missing'); @@ -145,37 +145,37 @@ async function main(): Promise { const before = await client.listLinkCodePluginConfigs(); const view = before.find((entry) => entry.id === PLUGIN_ID); assert(view, 'installed plugin missing from plugin-config.list'); - assert(view.settings.authcode?.secret, 'authcode must be a secret field'); - assert.equal(view.values.authcode, undefined, 'secret value leaked in masked read'); + assert(view.settings.token?.secret, 'token must be a secret field'); + assert.equal(view.values.token, undefined, 'secret value leaked in masked read'); await client.setLinkCodePluginConfig({ pluginId: PLUGIN_ID, - set: { account: 'user@163.com', authcode: AUTHCODE, preset: 'qq' }, + set: { greeting: '你好', token: SECRET_TOKEN, mode: 'shout' }, }); const configFile = JSON.parse(readFileSync(join(home, '.linkcode', 'config.json'), 'utf8')) as { pluginConfigs?: Record>; }; - assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.account, 'user@163.com'); - assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.preset, 'qq'); - assert(!('authcode' in (configFile.pluginConfigs?.[PLUGIN_ID] ?? {})), 'secret in config.json'); + assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.greeting, '你好'); + assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.mode, 'shout'); + assert(!('token' in (configFile.pluginConfigs?.[PLUGIN_ID] ?? {})), 'secret in config.json'); const secretsFile = JSON.parse( readFileSync(join(home, '.linkcode', 'secrets.json'), 'utf8'), ) as { protection: 'os-keyring' | 'plaintext'; }; // A fake HOME has no login keychain, so the vault degrades to plaintext on disk (with a boot - // warning). Either way the authcode belongs in secrets.json — just never in config.json. + // warning). Either way the token belongs in secrets.json — just never in config.json. const secretsRaw = readFileSync(join(home, '.linkcode', 'secrets.json'), 'utf8'); if (secretsFile.protection === 'os-keyring') { - assert(!secretsRaw.includes(AUTHCODE), 'authcode stored in plaintext under os-keyring'); + assert(!secretsRaw.includes(SECRET_TOKEN), 'token stored in plaintext under os-keyring'); } else { - assert(secretsRaw.includes(AUTHCODE), 'authcode missing from the vault'); + assert(secretsRaw.includes(SECRET_TOKEN), 'token missing from the vault'); } const after = await client.listLinkCodePluginConfigs(); const afterView = after.find((entry) => entry.id === PLUGIN_ID); - assert.equal(afterView?.values.account, 'user@163.com'); - assert.equal(afterView?.values.authcode, undefined, 'secret value leaked after set'); + assert.equal(afterView?.values.greeting, '你好'); + assert.equal(afterView?.values.token, undefined, 'secret value leaked after set'); // 5. Uninstall removes the package and prunes its config. const removed = await client.uninstallLinkCodePlugin(PLUGIN_ID); diff --git a/apps/daemon/src/__tests__/fixtures/in-memory-vault.ts b/apps/daemon/src/__tests__/fixtures/in-memory-vault.ts index 661478673..1e948f365 100644 --- a/apps/daemon/src/__tests__/fixtures/in-memory-vault.ts +++ b/apps/daemon/src/__tests__/fixtures/in-memory-vault.ts @@ -20,6 +20,13 @@ export function createInMemoryVault(protection: SecretProtection = 'os-keyring') return { protection, get: (key) => refs.get(prefix + key) ?? null, + keys: () => { + const keys: string[] = []; + for (const ref of refs.keys()) { + if (ref.startsWith(prefix)) keys.push(ref.slice(prefix.length)); + } + return keys; + }, set(key, secret) { refs.set(prefix + key, secret); }, diff --git a/apps/daemon/src/__tests__/marketplace.test.ts b/apps/daemon/src/__tests__/marketplace.test.ts index 6aeb8ada7..b3c4b41e9 100644 --- a/apps/daemon/src/__tests__/marketplace.test.ts +++ b/apps/daemon/src/__tests__/marketplace.test.ts @@ -156,12 +156,18 @@ describe('DaemonLinkCodeMarketplaceService.refresh', () => { }); it('does not refresh or resolve releases from a disabled marketplace', async () => { + // Populate the cache through an enabled config first, so the resolveRelease assertion below + // actually exercises the disabled gate instead of short-circuiting on an empty cache. + const fetchIndex = vi.fn(() => Promise.resolve(fakeResponse(200, JSON.stringify(INDEX)))); + await new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex).refresh( + 'linkcode-official', + ); + const disabled: LinkCodeMarketplaceConfigList = [{ ...MARKETPLACES[0], enabled: false }]; - const fetchIndex = vi.fn(); const service = new DaemonLinkCodeMarketplaceService(disabled, fetchIndex); await expect(service.refresh('linkcode-official')).rejects.toThrow('Marketplace is disabled'); - expect(fetchIndex).not.toHaveBeenCalled(); + expect(fetchIndex).toHaveBeenCalledTimes(1); expect( service.resolveRelease({ marketplaceId: 'linkcode-official', diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts index 02ae0ce4e..38607292e 100644 --- a/apps/daemon/src/__tests__/plugin-store.test.ts +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -6,6 +6,7 @@ import type { LinkCodePluginManifest, LinkCodePluginRelease, } from '@linkcode/schema'; +import { wait } from 'foxts/wait'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makePluginTmpDir, pluginPackageDir, pluginRegistryPath } from '../plugin-store/paths'; import { DaemonLinkCodePluginStore } from '../plugin-store/store'; @@ -128,6 +129,85 @@ describe('DaemonLinkCodePluginStore', () => { ]); }); + it('serializes concurrent installs of the same plugin so neither deletes the other’s package', async () => { + const events: string[] = []; + mocks.tarExtract.mockImplementation(async ({ cwd }: { cwd: string }) => { + events.push('extract:start'); + await wait(10); + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(manifest('0.2.0'))); + events.push('extract:end'); + }); + const release = { + manifest: manifest('0.2.0'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + await Promise.all([ + store.install(release, 'linkcode-official'), + store.install(release, 'linkcode-official'), + ]); + + expect(events).toEqual(['extract:start', 'extract:end', 'extract:start', 'extract:end']); + expect(existsSync(pluginPackageDir('arcbox/latex', '0.2.0'))).toBe(true); + expect(JSON.parse(readFileSync(pluginRegistryPath(), 'utf8'))).toMatchObject([ + { id: 'arcbox/latex', version: '0.2.0' }, + ]); + }); + + it('uninstall prunes only its own secrets, even beside a dotted sibling id', async () => { + const installed = record('0.1.0'); + writePackage(installed, settingsManifest('0.1.0')); + const neighbour: InstalledLinkCodePlugin = { + ...record('0.3.0'), + // Dots are legal inside id segments, so `arcbox/latex.pro` is a real neighbour whose keys + // would match a naive `arcbox/latex.` prefix; a corrupt manifest must not turn them into + // prunable orphans either. + id: 'arcbox/latex.pro', + path: pluginPackageDir('arcbox/latex.pro', '0.3.0'), + }; + writePackage(neighbour, '{broken'); + writeRegistry([installed, neighbour]); + const vault = createInMemoryVault(); + const store = new DaemonLinkCodePluginStore(vault); + await store.setSettings('arcbox/latex', { + set: { account: 'a@example.com', authcode: 'secret-a' }, + }); + vault.namespace('plugin').set('arcbox/latex.pro/authcode', 'secret-b'); + + await store.uninstall('arcbox/latex'); + + const secrets = vault.namespace('plugin'); + expect(secrets.get('arcbox/latex/authcode')).toBeNull(); + expect(secrets.get('arcbox/latex.pro/authcode')).toBe('secret-b'); + }); + + it('folds manifest defaults into settings that have no stored value', async () => { + const installed = record('0.1.0'); + const withDefaults = settingsManifest('0.1.0'); + withDefaults.settings = { + ...withDefaults.settings, + preset: { type: 'enum', enum: ['163', 'qq'], default: '163' }, + limit: { type: 'number', default: 8000 }, + fallbacktoken: { type: 'password', secret: true, default: 'manifest-leak' }, + }; + writePackage(installed, withDefaults); + writeRegistry([installed]); + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + await store.setSettings('arcbox/latex', { set: { account: 'a@example.com' } }); + + // A secret field's default is a plaintext credential in the manifest — never folded in. + expect(store.getSettings('arcbox/latex')).toEqual({ + account: 'a@example.com', + preset: '163', + limit: 8000, + }); + }); + it('rolls back config and secret changes when the vault rejects a settings update', () => { const installed = record('0.1.0'); writePackage(installed, settingsManifest('0.1.0')); @@ -146,7 +226,7 @@ describe('DaemonLinkCodePluginStore', () => { return { ...secrets, set(key: string, value: string) { - if (key === 'arcbox/latex.authcode' && value === 'new-secret') throw vaultFailure; + if (key === 'arcbox/latex/authcode' && value === 'new-secret') throw vaultFailure; secrets.set(key, value); }, }; diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 811d62960..55035e115 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -324,7 +324,7 @@ export function savePluginConfigValues( writeConfigFields(file, { pluginConfigs: configs }); } -/** The daemon's `plugin` vault namespace, for secret setting values keyed `.`. */ +/** The daemon's `plugin` vault namespace, for secret setting values keyed `/`. */ export function pluginSecretStore(vault: SecretVault): SecretStore { return pluginSecrets(vault); } diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 6221b74b0..012384fb4 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -27,9 +27,11 @@ import type { } from '@linkcode/schema'; import { InstalledLinkCodePluginSchema, + isAllowedMarketplaceUrl, LinkCodePluginManifestReaderSchema, } from '@linkcode/schema'; import { extractErrorMessage } from 'foxts/extract-error-message'; +import { noop } from 'foxts/noop'; import { extract as tarExtract } from 'tar'; import { loadPluginConfigValues, pluginSecretStore, savePluginConfigValues } from '../config'; import { logger } from '../logger'; @@ -65,10 +67,14 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { const merged: Record = {}; for (const [fieldId, field] of Object.entries(manifest.settings)) { if (field.secret) { - const stored = secrets.get(`${pluginId}.${fieldId}`); + // No default folding for secrets: a secret's default would be a plaintext credential in + // the manifest, injected into env while the masked read hides where it came from. + const stored = secrets.get(`${pluginId}/${fieldId}`); if (stored !== null) merged[fieldId] = stored; } else if (fieldId in nonSecret) { merged[fieldId] = nonSecret[fieldId]; + } else if (field.default !== undefined) { + merged[fieldId] = field.default; } } return merged; @@ -89,7 +95,7 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { for (const fieldId of patch.remove) { const field = settings[fieldId]; if (field === undefined) continue; - if (field.secret) secretPatch.set(`${pluginId}.${fieldId}`, undefined); + if (field.secret) secretPatch.set(`${pluginId}/${fieldId}`, undefined); else delete nextNonSecret[fieldId]; } } @@ -97,7 +103,7 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { for (const [fieldId, value] of Object.entries(patch.set)) { const field = settings[fieldId]; if (field === undefined) continue; - if (field.secret) secretPatch.set(`${pluginId}.${fieldId}`, String(value)); + if (field.secret) secretPatch.set(`${pluginId}/${fieldId}`, String(value)); else nextNonSecret[fieldId] = value; } } @@ -132,77 +138,24 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { return Promise.resolve(); } - async install( + /** Serializes installs of one plugin id; a concurrent second install would otherwise publish to + * the same package dir and delete the first install's just-renamed package. */ + private readonly installChains = new Map>(); + + install( release: LinkCodePluginRelease, marketplaceId: string, ): Promise { - const { manifest, artifact } = release; - if (artifact.format !== 'tgz') { - throw new Error(`Unsupported plugin archive format: ${artifact.format}`); - } - const httpsUrls = artifact.urls.filter((url): url is string => typeof url === 'string'); - if (httpsUrls.length === 0) { - throw new Error('Plugin release has no HTTPS download URL'); - } - const previousRecords = readRegistry().filter((entry) => entry.id === manifest.id); - const targetDir = pluginPackageDir(manifest.id, manifest.version); - const stagingDir = makePluginTmpDir(manifest.id, manifest.version); - const tgzPath = join(stagingDir, 'package.tgz'); - let installedManifest: LinkCodePluginManifest; - mkdirSync(stagingDir, { recursive: true }); - try { - const downloadArtifact: ManagedAssetArtifact = { - urls: httpsUrls, - integrity: artifact.integrity, - size: artifact.size, - format: 'tgz', - }; - await downloadVerified(downloadArtifact, tgzPath, {}); - await tarExtract({ file: tgzPath, cwd: stagingDir, strip: 1 }); - const onDisk = readManifest(stagingDir); - if (onDisk?.id !== manifest.id || onDisk.version !== manifest.version) { - throw new Error( - `Extracted manifest does not match release ${manifest.id}@${manifest.version}`, - ); - } - installedManifest = onDisk; - rmSync(targetDir, { recursive: true, force: true }); - mkdirSync(dirname(targetDir), { recursive: true }); - renameSync(stagingDir, targetDir); - } catch (error) { - rmSync(stagingDir, { recursive: true, force: true }); - throw new Error( - `Failed to install plugin ${manifest.id}: ${extractErrorMessage(error) ?? 'unknown'}`, - { cause: error }, - ); - } - const record: InstalledLinkCodePlugin = { - id: manifest.id, - version: manifest.version, - marketplaceId, - integrity: artifact.integrity, - enabled: true, - path: targetDir, + const pluginId = release.manifest.id; + const run = (this.installChains.get(pluginId) ?? Promise.resolve()) + .catch(noop) + .then(() => installExclusive(release, marketplaceId)); + this.installChains.set(pluginId, run); + const settle = (): void => { + if (this.installChains.get(pluginId) === run) this.installChains.delete(pluginId); }; - upsertRegistry(record); - // A plugin id has one active settings block and one wire identity, so keep exactly one installed - // version. Remove stale package directories only after the new package and registry record exist. - for (const previous of previousRecords) { - if (previous.path === targetDir) continue; - try { - rmSync(previous.path, { recursive: true, force: true }); - } catch (error) { - logger.warn( - { error, pluginId: manifest.id, path: previous.path, operation: 'plugin.install.gc' }, - 'Failed to remove stale plugin package', - ); - } - } - logger.info( - { pluginId: manifest.id, version: manifest.version, operation: 'plugin.install' }, - 'Installed LinkCode plugin', - ); - return { installed: record, manifest: installedManifest }; + void run.catch(noop).finally(settle); + return run; } uninstall(pluginId: string): Promise { @@ -219,6 +172,81 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { } } +async function installExclusive( + release: LinkCodePluginRelease, + marketplaceId: string, +): Promise { + const { manifest, artifact } = release; + if (artifact.format !== 'tgz') { + throw new Error(`Unsupported plugin archive format: ${artifact.format}`); + } + // Mirrors arrive absolutized against the index URL; keep only schemes the marketplace source + // itself may use (https, plus loopback http for dev marketplaces). + const downloadUrls = artifact.urls.filter((url) => isAllowedMarketplaceUrl(url)); + if (downloadUrls.length === 0) { + throw new Error('Plugin release has no HTTPS (or loopback HTTP) download URL'); + } + const previousRecords = readRegistry().filter((entry) => entry.id === manifest.id); + const targetDir = pluginPackageDir(manifest.id, manifest.version); + const stagingDir = makePluginTmpDir(manifest.id, manifest.version); + const tgzPath = join(stagingDir, 'package.tgz'); + let installedManifest: LinkCodePluginManifest; + mkdirSync(stagingDir, { recursive: true }); + try { + const downloadArtifact: ManagedAssetArtifact = { + urls: downloadUrls, + integrity: artifact.integrity, + size: artifact.size, + format: 'tgz', + }; + await downloadVerified(downloadArtifact, tgzPath, {}); + await tarExtract({ file: tgzPath, cwd: stagingDir, strip: 1 }); + const onDisk = readManifest(stagingDir); + if (onDisk?.id !== manifest.id || onDisk.version !== manifest.version) { + throw new Error( + `Extracted manifest does not match release ${manifest.id}@${manifest.version}`, + ); + } + installedManifest = onDisk; + rmSync(targetDir, { recursive: true, force: true }); + mkdirSync(dirname(targetDir), { recursive: true }); + renameSync(stagingDir, targetDir); + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw new Error( + `Failed to install plugin ${manifest.id}: ${extractErrorMessage(error) ?? 'unknown'}`, + { cause: error }, + ); + } + const record: InstalledLinkCodePlugin = { + id: manifest.id, + version: manifest.version, + marketplaceId, + integrity: artifact.integrity, + enabled: true, + path: targetDir, + }; + upsertRegistry(record); + // A plugin id has one active settings block and one wire identity, so keep exactly one installed + // version. Remove stale package directories only after the new package and registry record exist. + for (const previous of previousRecords) { + if (previous.path === targetDir) continue; + try { + rmSync(previous.path, { recursive: true, force: true }); + } catch (error) { + logger.warn( + { error, pluginId: manifest.id, path: previous.path, operation: 'plugin.install.gc' }, + 'Failed to remove stale plugin package', + ); + } + } + logger.info( + { pluginId: manifest.id, version: manifest.version, operation: 'plugin.install' }, + 'Installed LinkCode plugin', + ); + return { installed: record, manifest: installedManifest }; +} + function applySecretPatch( secrets: SecretStore, patch: ReadonlyMap, @@ -311,25 +339,18 @@ function readManifest(packageDir: string): LinkCodePluginManifest | undefined { } function prunePluginSecrets(secrets: SecretStore, pluginId: string): void { - // replaceAll on the `plugin` namespace keeps every OTHER plugin's secrets and drops this one's, - // in a single write — the same prune-on-delete property the vault hands other namespaces. + // Prune by key prefix, never by re-deriving keys from each surviving plugin's manifest: a + // manifest that is unreadable (corrupt, or schema-drifted after an upgrade) must not turn an + // unrelated plugin's secrets into "orphans" that replaceAll then deletes. + // The separator must be `/`, not `.`: dots are legal inside both id segments (a `linkcode/mail.pro` + // plugin would have its keys match a `linkcode/mail.` prefix), while `/` can never appear in a + // setting id and a plugin id always has exactly two segments, so the prefix is unambiguous. + const prefix = `${pluginId}/`; const surviving = new Map(); - for (const entry of readRegistry()) { - if (entry.id === pluginId) continue; - for (const fieldId of secretFieldIds(entry)) { - const value = secrets.get(`${entry.id}.${fieldId}`); - if (value !== null) surviving.set(`${entry.id}.${fieldId}`, value); - } + for (const key of secrets.keys()) { + if (key.startsWith(prefix)) continue; + const value = secrets.get(key); + if (value !== null) surviving.set(key, value); } secrets.replaceAll(surviving); } - -function secretFieldIds(record: InstalledLinkCodePlugin): string[] { - const manifest = readManifest(record.path); - if (manifest?.settings === undefined) return []; - const ids: string[] = []; - for (const [fieldId, field] of Object.entries(manifest.settings)) { - if (field.secret) ids.push(fieldId); - } - return ids; -} diff --git a/apps/daemon/src/secrets/vault.ts b/apps/daemon/src/secrets/vault.ts index 607b03d95..82e9a1351 100644 --- a/apps/daemon/src/secrets/vault.ts +++ b/apps/daemon/src/secrets/vault.ts @@ -37,6 +37,8 @@ export interface SecretStore { get: (key: string) => string | null; set: (key: string, secret: string) => void; delete: (key: string) => void; + /** Every key currently stored in this namespace (without the namespace prefix). */ + keys: () => string[]; /** * Replace this namespace's entire content in **one** write: keys absent from `entries` are dropped. * This is what a `save*` should call — it makes deletion implicit and costs a single re-encrypt no @@ -139,6 +141,13 @@ export function createSecretVault(file: string, loadKey: () => MasterKey | null) return { protection, get: (key) => secrets.get(prefix + key) ?? null, + keys() { + const keys: string[] = []; + for (const ref of secrets.keys()) { + if (ref.startsWith(prefix)) keys.push(ref.slice(prefix.length)); + } + return keys; + }, set(key, secret) { const ref = prefix + key; const previous = secrets.get(ref); diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 0eb450c1c..a1b18e378 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -20,7 +20,7 @@ Read by the daemon, desktop, webview, or mobile at run time. | `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe *within a channel*: forks the state dir to the `-` sibling (`~/.linkcode.development-alpha`), plus DB, `runtime.json`, and HQ device identity. `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Workspaces and the asset store do not fork by profile. Desktop reads it too, where `--profile=` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the channel's default universe. | | `LINKCODE_PORT` | `apps/daemon/src/config.ts` | Overrides every configured listener's port. Must parse as an integer in `1..65535`, otherwise the config value stands. | | `LINKCODE_HOST` | `apps/daemon/src/config.ts` | Overrides every listener's bind host. | -| `LINKCODE_MARKETPLACE_URL` | `apps/daemon/src/config.ts` | Retargets the official LinkCode plugin marketplace index (default `https://plugins.linkcode.ai/index.json`). Must be an absolute HTTPS URL, otherwise the configured/default source stands. | +| `LINKCODE_MARKETPLACE_URL` | `apps/daemon/src/config.ts` | Retargets the official LinkCode plugin marketplace index (default `https://plugins.linkcode.ai/index.json`). Must be an absolute HTTPS URL — plain HTTP is accepted on loopback hosts only, which is what makes `scripts/dev-marketplace.mts` usable; anything else falls back to the configured/default source. | | `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/src/pty/sidecar.ts` | Absolute path to the `linkcode-pty` binary; always wins. Dev falls back to `target/release/linkcode-pty`; a bundled `dist/` daemon has no fallback and disables terminals. The packaged desktop supervisor sets it to `/sidecar/`. | | `LINKCODE_SIM_SIDECAR_PATH` | `apps/daemon/src/sim/backend.ts` | Absolute path to the `linkcode-sim` iOS Simulator sidecar; always wins. macOS only — other platforms resolve to none regardless. Dev falls back to `target/release/linkcode-sim`; a bundled `dist/` daemon has no fallback and disables simulators. The packaged desktop supervisor sets it from ``. | | `LINKCODE_AIGATEWAY_PATH` | `apps/daemon/src/ai-gateway.ts` | Path to the `aigateway` translation sidecar, overriding the managed-asset install. | diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index f5dff5db1..1446cc704 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -361,7 +361,7 @@ export class LinkCodeSdkClient { return toResult(this.raw.listPluginMarketplaces()); } - /** Refresh one marketplace index; `notModified` replies carry no releases. */ + /** Refresh one marketplace index; `notModified` replies carry the cached catalog. */ refreshPluginMarketplace(marketplaceId: string): RequestResult { return toResult(this.raw.refreshPluginMarketplace(marketplaceId)); } diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index c8343ce14..dd92bdf42 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -355,8 +355,8 @@ export function getPluginMarketplaces( return resolveClient(options).listPluginMarketplaces(); } -/** Refresh one marketplace index. A `notModified` reply carries no releases — the caller keeps - * the catalog it already has. */ +/** Refresh one marketplace index. A `notModified` reply still carries the cached catalog — the + * daemon re-flattens its persisted index, so callers can replace their snapshot outright. */ export function refreshPluginMarketplace( options: Options<{ marketplaceId: string }>, ): RequestResult { diff --git a/packages/client/workbench/src/mock/data/linkcode-marketplace.ts b/packages/client/workbench/src/mock/data/linkcode-marketplace.ts index f457ef0da..fbfe4450b 100644 --- a/packages/client/workbench/src/mock/data/linkcode-marketplace.ts +++ b/packages/client/workbench/src/mock/data/linkcode-marketplace.ts @@ -15,70 +15,72 @@ export interface MockLinkCodeCatalogEntry { release: LinkCodePluginRelease; } -/** Catalog the mock serves for `plugin-market.refresh`: a settings-bearing MCP plugin (the mail - * plugin's real env surface as manifest settings) and a skill-only plugin with nothing to configure. */ +/** Catalog the mock serves for `plugin-market.refresh`: a settings-bearing MCP plugin (the echo + * debug plugin's env surface, exercising every settings field type) and a skill-only plugin with + * nothing to configure. */ export const SEED_LINKCODE_RELEASES: MockLinkCodeCatalogEntry[] = [ { - pluginId: 'linkcode/mail', + pluginId: 'linkcode/echo', release: { manifest: { manifestVersion: 1, - id: 'linkcode/mail', - version: '1.0.0', - displayName: 'Mail (163 / QQ)', - description: 'Receive and send 163/QQ mail over IMAP + SMTP via an MCP server.', - keywords: ['mail', '163', 'qq', 'imap', 'smtp'], + id: 'linkcode/echo', + version: '0.1.0', + displayName: 'Echo', + description: 'Echoes text back over a stdio MCP server; a marketplace debug fixture.', + keywords: ['echo', 'debug', 'marketplace'], components: [ { kind: 'mcp-server', - name: 'mail', - description: 'Mail tools: list/search/read/send messages', + name: 'echo', + description: 'Echo tool: returns the input text, optionally uppercased', command: 'node', entry: 'dist/index.js', env: { - MAIL_USER: 'account', - MAIL_PASSWORD: 'password', - MAIL_PRESET: 'preset', - MAX_BODY_CHARS: 'maxBodyChars', + ECHO_GREETING: 'greeting', + ECHO_TOKEN: 'token', + ECHO_MODE: 'mode', + ECHO_MAX_CHARS: 'maxChars', + ECHO_PREVIEW: 'preview', }, }, ], settings: { - account: { + greeting: { type: 'string', - label: 'Account', - description: 'Full email address, e.g. you@163.com', + label: 'Greeting', + description: 'Prefix prepended to every echoed text', required: true, }, - password: { + token: { type: 'password', - label: 'Authorization code', - description: 'The IMAP/SMTP authorization code from the mailbox settings page', + label: 'Token', + description: 'Only exercised to prove secret fields land in the vault', secret: true, required: true, }, - preset: { + mode: { type: 'enum', - label: 'Provider preset', - enum: ['163', 'qq', 'exmail'], - default: '163', + label: 'Mode', + enum: ['plain', 'shout'], + default: 'plain', }, - maxBodyChars: { + maxChars: { type: 'number', - label: 'Max body characters', - default: 8000, + label: 'Max echo characters', + default: 1000, }, - readonly: { + preview: { type: 'boolean', - label: 'Read-only', - description: 'Expose read tools only; never send or modify mail', + label: 'Preview', + description: 'Log every echo to stdout as well', default: false, }, }, assets: [], }, artifact: { - urls: ['plugins/mail-1.0.0.tgz'], + urls: ['plugins/echo-0.1.0.tgz'], integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', format: 'tgz', }, diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 71a8c498a..9b1945037 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -205,16 +205,16 @@ export class DevMockHost { } >([ [ - 'linkcode/mail', + 'linkcode/echo', { marketplaceId: 'linkcode-official', - version: '1.0.0', + version: '0.1.0', values: { - account: 'you@163.com', - password: 'mock-authorization-code', - preset: '163', - maxBodyChars: 8000, - readonly: false, + greeting: 'Hello', + token: 'mock-secret-token', + mode: 'plain', + maxChars: 1000, + preview: false, }, }, ], @@ -1793,8 +1793,9 @@ export class DevMockHost { this.send({ kind: 'request.failed', replyTo, message, ...reporting }); } - /** The masked `plugin-config.listed` projection: only installed plugins whose manifest declares - * settings, secret values omitted — mirrors the daemon's PluginConfigService. */ + /** The masked `plugin-config.listed` projection: every installed plugin appears (a plugin with + * no declared settings gets `settings: {}`), secret values omitted — mirrors the daemon's + * PluginConfigService. */ private linkCodeConfigViews(): Array<{ id: string; version: string; @@ -1804,8 +1805,7 @@ export class DevMockHost { const views = []; for (const [pluginId, installed] of this.linkCodeInstalled) { const seed = SEED_LINKCODE_RELEASES.find((candidate) => candidate.pluginId === pluginId); - const settings = seed?.release.manifest.settings; - if (settings === undefined) continue; + const settings = seed?.release.manifest.settings ?? {}; views.push({ id: pluginId, version: installed.version, diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx index 6cd27f433..6b6b12f11 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx @@ -82,10 +82,11 @@ describe('LinkCodePluginConfigDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'form.save' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + // preset stays at its manifest default and was never stored, so it is deliberately absent + // from the patch — writing it would freeze today's default against future manifest upgrades. expect(onSubmit).toHaveBeenCalledWith({ set: { account: 'new@163.com', - preset: '163', maxBodyChars: 4000, readonly: true, }, diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts index d2cae2f13..ccd9649a3 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts @@ -119,6 +119,21 @@ describe('buildPluginConfigPatch', () => { expect(withoutStored.remove).toBeUndefined(); }); + it('stores a value equal to the manifest default as a removal, so upgrades can change it', () => { + const patch = buildPluginConfigPatch( + SETTINGS, + { preset: 'qq', maxBodyChars: 4000 }, + { + ...pluginConfigDefaults(SETTINGS, { preset: 'qq', maxBodyChars: 4000 }), + preset: '163', + maxBodyChars: '8000', + }, + ); + + expect(patch.set).toBeUndefined(); + expect(patch.remove).toEqual(['preset', 'maxBodyChars']); + }); + it('omits both sides of an empty patch', () => { const patch = buildPluginConfigPatch({ nickname: { type: 'string' } }, {}, { nickname: '' }); expect(patch).toEqual({}); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts index 8b562563d..2e86d4ec8 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -1,10 +1,10 @@ -import type { PluginList } from '@linkcode/client-core'; +import type { PluginList, PluginMarketReleaseEntry } from '@linkcode/client-core'; import type { Plugin } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import { filterLinkCodeCatalogCards, filterPluginCards, - linkcodeCatalogCard, + linkcodeCatalogCards, linkcodeInstalledRow, pluginCardView, pluginMcpServerRows, @@ -276,34 +276,38 @@ describe('pluginMcpServerRows', () => { }); }); -describe('linkcodeCatalogCard', () => { - it('projects a marketplace release entry to a catalog card with install state', () => { - const card = linkcodeCatalogCard( - 'linkcode-official', - { - pluginId: 'linkcode/mail', - release: { - manifest: { - manifestVersion: 1, - id: 'linkcode/mail', - version: '1.0.0', - displayName: 'Mail (163 / QQ)', - description: 'Receive and send mail.', - keywords: ['mail'], - components: [ - { kind: 'mcp-server', name: 'mail', command: 'npx', env: { MAIL_USER: 'account' } }, - ], - settings: { account: { type: 'string', required: true } }, - assets: [], - }, - artifact: { - urls: ['plugins/mail-1.0.0.tgz'], - integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', - format: 'tgz', - }, +describe('linkcodeCatalogCards', () => { + function catalogEntry(version: string): PluginMarketReleaseEntry { + return { + pluginId: 'linkcode/mail', + release: { + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version, + displayName: 'Mail (163 / QQ)', + description: 'Receive and send mail.', + keywords: ['mail'], + components: [ + { kind: 'mcp-server', name: 'mail', command: 'npx', env: { MAIL_USER: 'account' } }, + ], + settings: { account: { type: 'string', required: true } }, + assets: [], + }, + artifact: { + urls: [`plugins/mail-${version}.tgz`], + integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + format: 'tgz', }, }, - true, + }; + } + + it('projects a marketplace release entry to a catalog card with install state', () => { + const [card] = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('1.0.0')], + new Map([['linkcode/mail', '1.0.0']]), ); expect(card).toMatchObject({ @@ -313,8 +317,129 @@ describe('linkcodeCatalogCard', () => { version: '1.0.0', title: 'Mail (163 / QQ)', installed: true, + updateAvailable: false, + installedNewer: false, + }); + expect(card?.searchText).toContain('linkcode/mail'); + }); + + it('distinguishes not-installed from an upgrade to an older installed version', () => { + const [notInstalled] = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('1.0.0')], + new Map(), + ); + expect(notInstalled).toMatchObject({ + installed: false, + updateAvailable: false, + installedNewer: false, + }); + + const [upgrade] = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('1.0.0')], + new Map([['linkcode/mail', '0.9.0']]), + ); + expect(upgrade).toMatchObject({ + installed: false, + updateAvailable: true, + installedNewer: false, + }); + }); + + it('flags a newer-than-catalog install as neither installed nor an update', () => { + const [card] = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('1.0.0')], + new Map([['linkcode/mail', '1.1.0']]), + ); + + expect(card).toMatchObject({ installed: false, updateAvailable: false, installedNewer: true }); + }); + + it('leaves a prerelease install switchable back to the stable release', () => { + // The catalog prefers 1.9.0, so the installed beta reads as installedNewer — but the card must + // stay actionable (`installed: false`) or there is no way off the prerelease. + const [card] = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('1.9.0'), catalogEntry('2.0.0-beta.1')], + new Map([['linkcode/mail', '2.0.0-beta.1']]), + ); + + expect(card).toMatchObject({ + version: '1.9.0', + installed: false, + updateAvailable: false, + installedNewer: true, }); - expect(card.searchText).toContain('linkcode/mail'); + }); + + it('folds every published version of one plugin into a single card at the latest version', () => { + // 0.10.0 outranks 0.9.0 numerically — a lexicographic compare would pick 0.9.0. + const cards = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('0.9.0'), catalogEntry('0.10.0')], + new Map(), + ); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + pluginId: 'linkcode/mail', + version: '0.10.0', + installed: false, + updateAvailable: false, + }); + }); + + it('lets a stable release outrank a higher prerelease', () => { + const cards = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('1.9.0'), catalogEntry('2.0.0-beta.1')], + new Map(), + ); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ version: '1.9.0' }); + }); + + it('still cards a plugin whose only releases are prereleases, at the newest one', () => { + const cards = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('2.0.0-beta.1'), catalogEntry('2.0.0-beta.2')], + new Map(), + ); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ version: '2.0.0-beta.2' }); + }); + + it('treats a hyphen in build metadata as stable, not as a prerelease', () => { + const cards = linkcodeCatalogCards( + 'linkcode-official', + [catalogEntry('1.9.0'), catalogEntry('2.0.0+build-7')], + new Map(), + ); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ version: '2.0.0+build-7' }); + }); + + it('compares the installed version against the latest release, not just any release', () => { + const releases = [catalogEntry('0.9.0'), catalogEntry('0.10.0')]; + + const [behind] = linkcodeCatalogCards( + 'linkcode-official', + releases, + new Map([['linkcode/mail', '0.9.0']]), + ); + expect(behind).toMatchObject({ version: '0.10.0', installed: false, updateAvailable: true }); + + const [upToDate] = linkcodeCatalogCards( + 'linkcode-official', + releases, + new Map([['linkcode/mail', '0.10.0']]), + ); + expect(upToDate).toMatchObject({ installed: true, updateAvailable: false }); }); }); @@ -343,9 +468,9 @@ describe('linkcodeInstalledRow', () => { describe('filterLinkCodeCatalogCards', () => { it('filters by the precomputed haystack, blank query keeps all', () => { - const cards = [ - linkcodeCatalogCard( - 'linkcode-official', + const cards = linkcodeCatalogCards( + 'linkcode-official', + [ { pluginId: 'linkcode/mail', release: { @@ -365,9 +490,9 @@ describe('filterLinkCodeCatalogCards', () => { }, }, }, - false, - ), - ]; + ], + new Map(), + ); expect(filterLinkCodeCatalogCards(cards, '')).toHaveLength(1); expect(filterLinkCodeCatalogCards(cards, 'mail')).toHaveLength(1); expect(filterLinkCodeCatalogCards(cards, 'zzz')).toHaveLength(0); diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config.ts b/packages/client/workbench/src/settings/plugins/linkcode-config.ts index 0c7d73f7c..639658915 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-config.ts +++ b/packages/client/workbench/src/settings/plugins/linkcode-config.ts @@ -67,6 +67,13 @@ export function validatePluginConfigField( * - `password` fields write only when the user typed something; blank keeps the stored secret. * - `string` / `enum` / `number` write their typed value, or remove the key when cleared — * but only if the key had a stored value (removing an absent key would be noise). + * - A non-secret value equal to the manifest `default` is stored as a removal, not a write: + * freezing today's default into config.json would silently win over a future manifest upgrade + * that changes the default. + * + * The `fieldId in values` guards only suppress noise, and only for fields with no `default`: the + * masked read folds defaults in, so for a defaulted field the key is present whether or not + * anything is stored. Removing an already-absent key is a harmless no-op either way. */ export function buildPluginConfigPatch( settings: LinkCodePluginSettings, @@ -79,7 +86,12 @@ export function buildPluginConfigPatch( if (!(fieldId in form)) continue; const raw = form[fieldId]; if (field.type === 'boolean') { - set[fieldId] = raw === true; + const typed = raw === true; + if (field.default !== undefined && typed === field.default) { + if (fieldId in values) remove.push(fieldId); + } else { + set[fieldId] = typed; + } continue; } const value = typeof raw === 'string' ? raw : String(raw); @@ -87,7 +99,12 @@ export function buildPluginConfigPatch( if (!field.secret && fieldId in values) remove.push(fieldId); continue; } - set[fieldId] = field.type === 'number' ? Number(value) : value; + const typed = field.type === 'number' ? Number(value) : value; + if (!field.secret && field.default !== undefined && typed === field.default) { + if (fieldId in values) remove.push(fieldId); + continue; + } + set[fieldId] = typed; } return { ...(!isObjectEmpty(set) && { set }), diff --git a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx index 2a49dea31..e07b74a4b 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx @@ -17,7 +17,7 @@ import { } from './hooks'; import type { LinkCodePluginConfigPatch } from './linkcode-config-dialog'; import { LinkCodePluginConfigDialog } from './linkcode-config-dialog'; -import { filterLinkCodeCatalogCards, linkcodeCatalogCard, linkcodeInstalledRow } from './view'; +import { filterLinkCodeCatalogCards, linkcodeCatalogCards, linkcodeInstalledRow } from './view'; export interface LinkCodeMarketTabProps { searchQuery: string; @@ -38,11 +38,14 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac const save = useSetLinkCodePluginConfig(); const [configuring, setConfiguring] = useState(null); - const installedIds = new Set((configs ?? []).map((view) => view.id)); + const installedVersions = new Map((configs ?? []).map((view) => [view.id, view.version])); const rows = configs?.map(linkcodeInstalledRow); const configById = new Map((configs ?? []).map((view) => [view.id, view])); const editing = configuring === null ? undefined : configById.get(configuring); const busy = install.isMutating || uninstall.isMutating; + // The daemon rejects refresh/install on a disabled marketplace, so rendering one would leave + // its section on loading skeletons forever. + const enabledMarketplaces = marketplaces?.filter((marketplace) => marketplace.enabled); const onInstall = async (card: LinkCodeCatalogCardView): Promise => { await install.trigger({ @@ -85,16 +88,18 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac void onUninstall(row).catch(noop); }} /> - {marketplaces === undefined ? null : marketplaces.length === 0 ? ( + {enabledMarketplaces === undefined ? null : enabledMarketplaces.length === 0 ? ( -

{t('noMarketplaces')}

+

+ {marketplaces?.length === 0 ? t('noMarketplaces') : t('allMarketplacesDisabled')} +

) : ( - marketplaces.map((marketplace) => ( + enabledMarketplaces.map((marketplace) => ( { @@ -122,13 +127,13 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac function MarketplaceCatalog({ marketplace, - installedIds, + installedVersions, searchQuery, busy, onInstall, }: { marketplace: LinkCodeMarketplaceConfig; - installedIds: ReadonlySet; + installedVersions: ReadonlyMap; searchQuery: string; busy: boolean; onInstall: (card: LinkCodeCatalogCardView) => void; @@ -139,13 +144,11 @@ function MarketplaceCatalog({ data === undefined ? undefined : filterLinkCodeCatalogCards( - data.releases.map((entry) => - linkcodeCatalogCard(marketplace.id, entry, installedIds.has(entry.pluginId)), - ), + linkcodeCatalogCards(marketplace.id, data.releases, installedVersions), searchQuery, ); - // Keep this defensive merge for older daemons that still return an empty 304 payload. + // Keep this defensive merge for pre-wire-80 daemons that still return an empty 304 payload. const onRefresh = (): void => { void mutate( async (current): Promise => { diff --git a/packages/client/workbench/src/settings/plugins/mcp-settings.tsx b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx index bc1e0dc81..b955aca70 100644 --- a/packages/client/workbench/src/settings/plugins/mcp-settings.tsx +++ b/packages/client/workbench/src/settings/plugins/mcp-settings.tsx @@ -14,13 +14,6 @@ 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 { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from 'coss-ui/components/select'; import { Textarea } from 'coss-ui/components/textarea'; import { noop } from 'foxts/noop'; import { Trash2Icon, UndoIcon } from 'lucide-react'; @@ -66,28 +59,6 @@ type McpForm = z.infer; type DialogState = { mode: 'closed' } | { mode: 'add' } | { mode: 'edit'; id: string }; -/** Email server templates that prefill the stdio form for 163 / QQ / exmail. */ -const MAIL_TEMPLATES = [ - { value: '163', preset: '163', nameKey: 'template163' }, - { value: 'qq', preset: 'qq', nameKey: 'templateQq' }, - { value: 'exmail', preset: 'exmail', nameKey: 'templateExmail' }, -] as const; - -function mailTemplateForm(preset: string, name: string): McpForm { - return { - name, - transport: 'stdio', - command: 'npx', - args: '-y\n@linkcode/mail-mcp', - url: '', - secrets: [ - { key: 'MAIL_USER', value: '', remove: false }, - { key: 'MAIL_PASSWORD', value: '', remove: false }, - { key: 'MAIL_PRESET', value: preset, remove: false }, - ], - }; -} - export interface McpTabProps { pluginRows: PluginMcpServerRow[]; } @@ -188,12 +159,10 @@ function CustomServerDialog({ onSubmit: (draft: CustomMcpServerDraft) => void; }): React.ReactNode { const t = useTranslations('settings.plugins.mcp'); - const [template, setTemplate] = useState(''); const { control, register, handleSubmit, - reset, formState: { errors }, } = useForm({ resolver: zodResolver(McpFormSchema), @@ -208,12 +177,6 @@ function CustomServerDialog({ ? previous.server.envKeys.length : previous.server.headerKeys.length; - const applyTemplate = (value: string): void => { - setTemplate(value); - const tpl = MAIL_TEMPLATES.find((m) => m.value === value); - reset(tpl ? mailTemplateForm(tpl.preset, t(tpl.nameKey)) : formDefaults(undefined)); - }; - const submit = handleSubmit((form) => { const secretRows: Array<{ key: string; value: string; remove: boolean }> = []; for (const row of form.secrets) { @@ -250,24 +213,6 @@ function CustomServerDialog({ errors={rhfErrorsToFormErrors(errors)} onSubmit={submit} > - {previous === undefined && ( - - {t('form.mailTemplate')} - - - )} {t('form.name')} diff --git a/packages/client/workbench/src/settings/plugins/view.ts b/packages/client/workbench/src/settings/plugins/view.ts index b5b1c7e6f..e8753d099 100644 --- a/packages/client/workbench/src/settings/plugins/view.ts +++ b/packages/client/workbench/src/settings/plugins/view.ts @@ -3,6 +3,9 @@ import type { PluginList, PluginMarketReleaseEntry, } from '@linkcode/client-core'; +// The narrow `config/semver` subpath, never the `config` barrel: the barrel re-exports crypto +// (@noble/*, with a top-level side effect), ConfigCore, and telemetry into this renderer bundle. +import { compareSemverStrings, isPrereleaseSemver } from '@linkcode/common/config/semver'; import type { Plugin, PluginComponentKind, StandaloneSkill } from '@linkcode/schema'; import type { LinkCodeCatalogCardView, @@ -178,14 +181,22 @@ function linkcodePluginTitle(pluginId: string): string { return pluginId.split('/').at(-1) ?? pluginId; } -/** A marketplace release entry to its catalog card. */ -export function linkcodeCatalogCard( +/** A marketplace release entry to its catalog card. `installedVersion` is the version on disk for + * this plugin id, if any: an exact match renders installed, an older one renders as an upgrade + * (the daemon's install replaces the older package and keeps its settings). Never call this + * directly from the catalog — go through {@link linkcodeCatalogCards} so one plugin renders one + * card (its latest release), not one card per published version. */ +function linkcodeCatalogCard( marketplaceId: string, entry: PluginMarketReleaseEntry, - installed: boolean, + installedVersion: string | undefined, ): LinkCodeCatalogCardView { const manifest = entry.release.manifest; const title = manifest.displayName ?? linkcodePluginTitle(entry.pluginId); + const age = + installedVersion === undefined + ? null + : comparePluginVersions(installedVersion, manifest.version); return { key: `${marketplaceId}:${entry.pluginId}`, marketplaceId, @@ -193,13 +204,52 @@ export function linkcodeCatalogCard( version: manifest.version, title, description: manifest.description, - installed, + installed: age === 0, + updateAvailable: age !== null && age < 0, + installedNewer: age !== null && age > 0, searchText: [entry.pluginId, title, manifest.description ?? '', ...manifest.keywords] .join('\n') .toLowerCase(), }; } +/** One card per plugin id, for its newest release: a marketplace that keeps old releases listed + * must not fill the catalog with one card per version, and an "update" badge must never point at a + * version older than the installed one. Stability outranks version order, so a published + * `2.0.0-beta.1` cannot hide the `1.9.0` everyone should actually install; a plugin whose only + * releases are prereleases still gets its card. */ +export function linkcodeCatalogCards( + marketplaceId: string, + releases: readonly PluginMarketReleaseEntry[], + installedVersions: ReadonlyMap, +): LinkCodeCatalogCardView[] { + const newestByPlugin = new Map(); + for (const entry of releases) { + const current = newestByPlugin.get(entry.pluginId); + if (current === undefined || outranks(entry, current)) { + newestByPlugin.set(entry.pluginId, entry); + } + } + return [...newestByPlugin.values()].map((entry) => + linkcodeCatalogCard(marketplaceId, entry, installedVersions.get(entry.pluginId)), + ); +} + +/** Whether `candidate` should replace `current` as the plugin's catalog card. */ +function outranks(candidate: PluginMarketReleaseEntry, current: PluginMarketReleaseEntry): boolean { + const candidateVersion = candidate.release.manifest.version; + const currentVersion = current.release.manifest.version; + const candidatePrerelease = isPrereleaseSemver(candidateVersion); + if (candidatePrerelease !== isPrereleaseSemver(currentVersion)) return !candidatePrerelease; + return comparePluginVersions(candidateVersion, currentVersion) > 0; +} + +/** semver compare over the schema-validated plugin version shape; an unparseable value can only + * arrive from a corrupt index, where string order is the honest fallback. */ +function comparePluginVersions(a: string, b: string): number { + return compareSemverStrings(a, b) ?? (a === b ? 0 : a < b ? -1 : 1); +} + /** A masked plugin config read to its installed-list row. */ export function linkcodeInstalledRow(view: LinkCodePluginConfigView): LinkCodeInstalledPluginRow { return { diff --git a/packages/foundation/common/package.json b/packages/foundation/common/package.json index 2593004be..304c2499d 100644 --- a/packages/foundation/common/package.json +++ b/packages/foundation/common/package.json @@ -6,6 +6,7 @@ "exports": { "./config": "./src/config/index.ts", "./config-signing-poc": "./src/config-signing-poc/index.ts", + "./config/semver": "./src/config/semver.ts", "./node": "./src/node/index.ts", "./sentry": "./src/sentry/index.ts", "./telemetry-config": "./src/telemetry-config/index.ts", diff --git a/packages/foundation/common/src/config/__tests__/contract.test.ts b/packages/foundation/common/src/config/__tests__/contract.test.ts index 5685f5a42..7564dfd5f 100644 --- a/packages/foundation/common/src/config/__tests__/contract.test.ts +++ b/packages/foundation/common/src/config/__tests__/contract.test.ts @@ -12,9 +12,11 @@ import { canonicalSignedPayload, canonicalSignedPayloadBytes, compareMonotonicVersions, + compareSemverStrings, conditionMatches, decideAntiReplay, emergencyTargetMatches, + isPrereleaseSemver, matchesVersionRange, murmur3X86_32, rolloutBucket, @@ -288,6 +290,13 @@ describe('configuration contract v1 golden fixture', () => { expect(matchesVersionRange('2.4.0-beta.1', '>=2.4.0')).toBe(false); expect(matchesVersionRange('2.4.0+build.7', '=2.4.0+other')).toBe(true); expect(matchesVersionRange('2.4.0', '^2.3.0')).toBe(false); + expect(compareSemverStrings('0.10.0', '0.9.0')).toBeGreaterThan(0); + expect(compareSemverStrings('2.4.0-beta.1', '2.4.0')).toBeLessThan(0); + expect(compareSemverStrings('2.4.0+build.7', '2.4.0+other')).toBe(0); + expect(compareSemverStrings('2.4.0', 'not-semver')).toBeNull(); + expect(isPrereleaseSemver('2.4.0-beta.1')).toBe(true); + expect(isPrereleaseSemver('2.4.0+build-7')).toBe(false); + expect(isPrereleaseSemver('not-semver')).toBe(false); expect( conditionMatches({ locale: 'k' }, { appVersion: '2.4.0', locale: 'K', os: 'windows' }), ).toBe(false); diff --git a/packages/foundation/common/src/config/contract.ts b/packages/foundation/common/src/config/contract.ts index ded2ea6c5..24b424417 100644 --- a/packages/foundation/common/src/config/contract.ts +++ b/packages/foundation/common/src/config/contract.ts @@ -87,4 +87,10 @@ export { rolloutBucket, rolloutMatches, } from './rules'; -export { isValidSemver, isValidVersionRange, matchesVersionRange } from './semver'; +export { + compareSemverStrings, + isPrereleaseSemver, + isValidSemver, + isValidVersionRange, + matchesVersionRange, +} from './semver'; diff --git a/packages/foundation/common/src/config/index.ts b/packages/foundation/common/src/config/index.ts index 86558a544..3d7faf86e 100644 --- a/packages/foundation/common/src/config/index.ts +++ b/packages/foundation/common/src/config/index.ts @@ -31,6 +31,7 @@ export { canonicalSignedPayload, canonicalSignedPayloadBytes, compareMonotonicVersions, + compareSemverStrings, conditionMatches, configPointerPath, configSnapshotPath, @@ -38,6 +39,7 @@ export { emergencyPath, emergencyTargetMatches, isConfigKey, + isPrereleaseSemver, isValidSemver, isValidVersionRange, localeMatches, diff --git a/packages/foundation/common/src/config/semver.ts b/packages/foundation/common/src/config/semver.ts index a23d14463..a11bdcd2d 100644 --- a/packages/foundation/common/src/config/semver.ts +++ b/packages/foundation/common/src/config/semver.ts @@ -117,6 +117,21 @@ export function isValidSemver(value: string): boolean { return parseSemver(value) !== null; } +/** Whether `value` carries a prerelease segment. Not `includes('-')`: build metadata may contain a + * hyphen (`1.0.0+build-7` is a stable release). False for anything that fails to parse. */ +export function isPrereleaseSemver(value: string): boolean { + return (parseSemver(value)?.prerelease.length ?? 0) > 0; +} + +/** Compares two semver strings: negative when `left` is older, 0 on equality (build metadata + * ignored), positive when `left` is newer; null when either side fails to parse. */ +export function compareSemverStrings(left: string, right: string): number | null { + const parsedLeft = parseSemver(left); + const parsedRight = parseSemver(right); + if (parsedLeft === null || parsedRight === null) return null; + return compareSemver(parsedLeft, parsedRight); +} + export function isValidVersionRange(value: string): boolean { return parseVersionRange(value) !== null; } diff --git a/packages/foundation/schema/src/model/custom-mcp.ts b/packages/foundation/schema/src/model/custom-mcp.ts index 556302222..8c4ba3daf 100644 --- a/packages/foundation/schema/src/model/custom-mcp.ts +++ b/packages/foundation/schema/src/model/custom-mcp.ts @@ -96,13 +96,10 @@ export const CustomMcpServerPatchOpSchema = z.discriminatedUnion('op', [ ]); export type CustomMcpServerPatchOp = z.infer; -/** Session-start advisory about custom MCP injection, carried on `session.started`. */ -export const McpWarningReasonSchema = z.enum([ - 'agent-unsupported', - 'name-conflict', - 'provider-unsupported', - 'provider-preflight-failed', -]); +/** Session-start advisory about custom MCP injection, carried on `session.started`. Deliberately + * a bare string, never an enum: a strict enum makes an older client reject the whole + * `session.started` frame when a newer daemon adds a reason, instead of just displaying it. */ +export const McpWarningReasonSchema = z.string().min(1); export type McpWarningReason = z.infer; export const McpWarningSchema = z.object({ diff --git a/packages/foundation/schema/src/model/linkcode-marketplace.ts b/packages/foundation/schema/src/model/linkcode-marketplace.ts index 639dc4289..af8bd0554 100644 --- a/packages/foundation/schema/src/model/linkcode-marketplace.ts +++ b/packages/foundation/schema/src/model/linkcode-marketplace.ts @@ -12,7 +12,7 @@ const HTTP_URL_RE = /^http:\/\//i; const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); /** http is accepted for loopback hosts only — local debug marketplaces (RFC 8252's exception). */ -function isAllowedMarketplaceUrl(url: string): boolean { +export function isAllowedMarketplaceUrl(url: string): boolean { if (HTTPS_URL_RE.test(url)) return true; if (!HTTP_URL_RE.test(url)) return false; try { @@ -24,7 +24,10 @@ function isAllowedMarketplaceUrl(url: string): boolean { const LinkCodeMarketplaceHttpsUrlSchema = z .url() - .refine((url) => isAllowedMarketplaceUrl(url), 'Expected an absolute HTTPS URL'); + .refine( + (url) => isAllowedMarketplaceUrl(url), + 'Expected an absolute HTTPS URL (plain HTTP on loopback hosts only)', + ); /** HTTPS index configured by the user; the remote document never chooses its local identity. */ export const LinkCodeMarketplaceRemoteSourceSchema = z.object({ diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 3f9b6e6cf..454c86262 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 79 as const; +export const WIRE_PROTOCOL_VERSION = 80 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/tests/contract/wire/config.test.ts b/packages/foundation/schema/tests/contract/wire/config.test.ts index 0d49cfc78..55ca25628 100644 --- a/packages/foundation/schema/tests/contract/wire/config.test.ts +++ b/packages/foundation/schema/tests/contract/wire/config.test.ts @@ -89,7 +89,9 @@ describe('config wire schema — custom MCP servers', () => { expect(parsed.ok).toBe(true); }); - it('rejects an mcp warning outside the closed reason set', () => { + it('passes an unknown mcp warning reason through untouched (forward-compat)', () => { + // A newer daemon may add reasons; an older reader must not drop the whole session.started + // frame over one unrecognized value, so reason stays a bare non-empty string on the wire. const parsed = parseWireMessage( envelope({ kind: 'session.started', @@ -98,6 +100,23 @@ describe('config wire schema — custom MCP servers', () => { mcpWarnings: [{ serverName: 'github', reason: 'broker-unavailable' }], }), ); + expect(parsed.ok).toBe(true); + if (parsed.ok && parsed.message.payload.kind === 'session.started') { + expect(parsed.message.payload.mcpWarnings).toEqual([ + { serverName: 'github', reason: 'broker-unavailable' }, + ]); + } + }); + + it('still rejects a malformed mcp warning', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'session.started', + replyTo: 'request-1', + sessionId: 'session-1', + mcpWarnings: [{ serverName: 'github', reason: '' }], + }), + ); expect(parsed.ok).toBe(false); }); }); diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 819597486..4bbef92c4 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -425,4 +425,127 @@ describe('LinkCode plugin MCP injection at session start', () => { ]); expect(warnings).toEqual([]); }); + + it('spawns without a missing-config advisory when a setting has no stored value', async () => { + const store = new InMemoryLinkCodePluginStore(); + const packageRoot = '/store/plugins/linkcode/mail/0.1.0'; + store.seed( + { + installed: { + id: 'linkcode/mail', + version: '0.1.0', + marketplaceId: 'linkcode-official', + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + enabled: true, + path: packageRoot, + }, + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '0.1.0', + keywords: ['mail'], + components: [ + { + kind: 'mcp-server', + name: 'mail', + command: 'node', + entry: 'dist/index.js', + env: { MAIL_USER: 'account', MAIL_PASSWORD: 'authcode' }, + }, + ], + settings: { + account: { type: 'string' }, + authcode: { type: 'password', secret: true }, + }, + assets: [], + }, + }, + {}, + ); + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + undefined, + undefined, + store, + ); + + const { options: resolved, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), + ); + + // The missing-config advisory is deferred until clients tolerate unknown warning reasons; + // until then a missing setting is just an absent env var, not a warning. + expect(resolved.mcpServers).toEqual([ + { + type: 'stdio', + name: 'mail', + command: 'node', + args: [`${packageRoot}/dist/index.js`], + }, + ]); + expect(warnings).toEqual([]); + }); + + it('injects manifest defaults for settings with no stored value', async () => { + const store = new InMemoryLinkCodePluginStore(); + const packageRoot = '/store/plugins/linkcode/mail/0.1.0'; + store.seed( + { + installed: { + id: 'linkcode/mail', + version: '0.1.0', + marketplaceId: 'linkcode-official', + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + enabled: true, + path: packageRoot, + }, + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '0.1.0', + keywords: ['mail'], + components: [ + { + kind: 'mcp-server', + name: 'mail', + command: 'node', + entry: 'dist/index.js', + env: { MAIL_USER: 'account', MAIL_PRESET: 'preset' }, + }, + ], + settings: { + account: { type: 'string' }, + preset: { type: 'enum', enum: ['163', 'qq'], default: '163' }, + }, + assets: [], + }, + }, + { account: 'user@163.com' }, + ); + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + undefined, + undefined, + store, + ); + + const { options: resolved, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'claude-code', cwd: '/repo' }, SESSION), + ); + + expect(resolved.mcpServers).toEqual([ + { + type: 'stdio', + name: 'mail', + command: 'node', + args: [`${packageRoot}/dist/index.js`], + env: { MAIL_USER: 'user@163.com', MAIL_PRESET: '163' }, + }, + ]); + expect(warnings).toEqual([]); + }); }); diff --git a/packages/host/engine/src/plugin/config-service.ts b/packages/host/engine/src/plugin/config-service.ts index e14037211..2b2f45007 100644 --- a/packages/host/engine/src/plugin/config-service.ts +++ b/packages/host/engine/src/plugin/config-service.ts @@ -9,7 +9,9 @@ import type { /** A plugin's settings as the wire exposes them: the manifest's field schemas plus the non-secret * values. Secret fields appear in `settings` (so the client renders a masked input) but never in - * `values` — the same masked-edit contract custom-MCP uses. */ + * `values` — the same masked-edit contract custom-MCP uses. Every installed plugin appears here, + * even one declaring no settings (`settings: {}`), so this list doubles as the installed + * inventory: "has settings" must never decide "is installed". */ export interface PluginConfigView { readonly id: string; readonly version: string; @@ -28,7 +30,7 @@ export class PluginConfigService { list(): PluginConfigView[] { return this.store .list() - .flatMap((entry) => viewFor(entry, this.store.getSettings(entry.installed.id))); + .map((entry) => viewFor(entry, this.store.getSettings(entry.installed.id))); } /** Per-key patch; the store splits secret vs non-secret per the manifest. */ @@ -68,16 +70,13 @@ export class PluginConfigService { function viewFor( entry: InstalledLinkCodePluginEntry, merged: Record, -): PluginConfigView[] { - if (entry.manifest.settings === undefined) return []; - return [ - { - id: entry.installed.id, - version: entry.installed.version, - settings: entry.manifest.settings, - values: maskValues(entry, merged), - }, - ]; +): PluginConfigView { + return { + id: entry.installed.id, + version: entry.installed.version, + settings: entry.manifest.settings ?? {}, + values: maskValues(entry, merged), + }; } function maskValues( diff --git a/packages/host/engine/src/plugin/linkcode-store.ts b/packages/host/engine/src/plugin/linkcode-store.ts index 318ae7f6f..5bec8229e 100644 --- a/packages/host/engine/src/plugin/linkcode-store.ts +++ b/packages/host/engine/src/plugin/linkcode-store.ts @@ -31,7 +31,8 @@ export interface PluginConfigPatch { export interface LinkCodePluginStore { list(): InstalledLinkCodePluginEntry[]; get(pluginId: string): InstalledLinkCodePluginEntry | undefined; - /** Merged setting values (non-secret from config, secret from the vault) for a plugin. */ + /** Merged effective setting values (non-secret from config, secret from the vault), with each + * manifest-declared `default` folded in when the field has no stored value. */ getSettings(pluginId: string): Record; /** Per-key patch; the store splits secret vs non-secret per the manifest. */ setSettings(pluginId: string, patch: PluginConfigPatch): Promise; @@ -66,9 +67,14 @@ export class InMemoryLinkCodePluginStore implements LinkCodePluginStore { } getSettings(pluginId: string): Record { - const map = this.values.get(pluginId); - if (!map) return {}; - return Object.fromEntries(map); + const merged = Object.fromEntries(this.values.get(pluginId) ?? []); + const settings = this.entries.get(pluginId)?.manifest.settings; + for (const [fieldId, field] of Object.entries(settings ?? {})) { + // Mirrors the daemon store: defaults fold in for missing values, but never for secrets. + if (field.secret === true) continue; + if (!(fieldId in merged) && field.default !== undefined) merged[fieldId] = field.default; + } + return merged; } setSettings(pluginId: string, patch: PluginConfigPatch): Promise { diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 08e894f61..8b4820ad7 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -214,6 +214,8 @@ export class SessionStartOptionsResolver { if (settingId in settings) env[envVar] = String(settings[settingId]); } } + // No missing-config advisory yet: shipped clients validate `reason` against the old enum + // and would drop the whole session.started frame, so emission waits for a tolerant floor. const server: McpServer = { type: 'stdio', name: component.name, diff --git a/packages/integrations/mail-mcp/package.json b/packages/integrations/mail-mcp/package.json deleted file mode 100644 index 88ab5b592..000000000 --- a/packages/integrations/mail-mcp/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@linkcode/mail-mcp", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./dist/index.js", - "bin": { - "mail-mcp": "./dist/index.js" - }, - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "lint": "eslint --format=sukka ." - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "foxts": "^5.8.0", - "imapflow": "^1.7.2", - "nodemailer": "^9.0.5", - "zod": "catalog:" - }, - "devDependencies": { - "@types/node": "catalog:", - "@types/nodemailer": "^8.0.1", - "tsup": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/packages/integrations/mail-mcp/src/__tests__/body.test.ts b/packages/integrations/mail-mcp/src/__tests__/body.test.ts deleted file mode 100644 index 35c4fead7..000000000 --- a/packages/integrations/mail-mcp/src/__tests__/body.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { MessageStructureObject } from 'imapflow'; -import { describe, expect, it } from 'vitest'; -import { - collectAttachments, - decodeBodyPart, - pickPreferredPart, - selectReadableParts, - truncate, -} from '../body'; - -function part(opts: Partial & { type: string }): MessageStructureObject { - return { ...opts }; -} - -describe('selectReadableParts', () => { - it('returns nothing for an empty structure', () => { - expect(selectReadableParts()).toEqual([]); - }); - - it('collects text/plain and text/html leaves from a multipart/alternative', () => { - const root = part({ - type: 'multipart/alternative', - childNodes: [ - part({ type: 'text/plain', part: '1', parameters: { charset: 'utf-8' } }), - part({ type: 'text/html', part: '2' }), - ], - }); - const readable = selectReadableParts(root); - expect(readable.map((p) => p.part)).toEqual(['1', '2']); - expect(readable[0].contentType).toBe('text/plain'); - }); - - it('ignores non-text leaves', () => { - const root = part({ - type: 'multipart/mixed', - childNodes: [ - part({ type: 'text/plain', part: '1' }), - part({ - type: 'application/pdf', - part: '2', - disposition: 'attachment', - dispositionParameters: { filename: 'a.pdf' }, - }), - ], - }); - expect(selectReadableParts(root).map((p) => p.part)).toEqual(['1']); - }); -}); - -describe('pickPreferredPart', () => { - it('prefers text/plain over html', () => { - const parts = selectReadableParts( - part({ - type: 'multipart/alternative', - childNodes: [ - part({ type: 'text/html', part: '1' }), - part({ type: 'text/plain', part: '2' }), - ], - }), - ); - expect(pickPreferredPart(parts)?.contentType).toBe('text/plain'); - }); - - it('returns undefined when empty', () => { - expect(pickPreferredPart([])).toBeUndefined(); - }); -}); - -describe('collectAttachments', () => { - it('captures attachments and inline non-text parts, skipping the body', () => { - const root = part({ - type: 'multipart/mixed', - childNodes: [ - part({ type: 'text/plain', part: '1' }), - part({ - type: 'application/pdf', - part: '2', - disposition: 'attachment', - dispositionParameters: { filename: 'a.pdf' }, - size: 1024, - }), - part({ - type: 'image/png', - part: '3', - disposition: 'inline', - dispositionParameters: { filename: 'img.png' }, - }), - ], - }); - const attachments = collectAttachments(root); - expect(attachments).toHaveLength(2); - expect(attachments[0]).toMatchObject({ - part: '2', - filename: 'a.pdf', - contentType: 'application/pdf', - size: 1024, - }); - expect(attachments[1]).toMatchObject({ - part: '3', - filename: 'img.png', - contentType: 'image/png', - }); - }); - - it('treats a text/* part with disposition=attachment as an attachment', () => { - const root = part({ - type: 'multipart/mixed', - childNodes: [ - part({ type: 'text/plain', part: '1' }), - part({ - type: 'text/csv', - part: '2', - disposition: 'attachment', - dispositionParameters: { filename: 'data.csv' }, - }), - ], - }); - const attachments = collectAttachments(root); - expect(attachments).toHaveLength(1); - expect(attachments[0].filename).toBe('data.csv'); - }); -}); - -describe('decodeBodyPart', () => { - it('decodes utf-8 bytes', () => { - const buf = Buffer.from('héllo', 'utf-8'); - expect(decodeBodyPart(buf, 'utf-8')).toBe('héllo'); - }); - - it('decodes gbk bytes when charset is gb2312', () => { - // "中" in GBK is 0xD6 0xD0. - const buf = Buffer.from([0xd6, 0xd0]); - expect(decodeBodyPart(buf, 'gb2312')).toBe('中'); - }); - - it('falls back to utf-8 on an unknown charset', () => { - const buf = Buffer.from('ok', 'utf-8'); - expect(decodeBodyPart(buf, 'not-a-real-charset')).toBe('ok'); - }); - - it('returns empty for undefined buffer', () => { - expect(decodeBodyPart(undefined)).toBe(''); - }); -}); - -describe('truncate', () => { - it('returns the text unchanged when within the limit', () => { - expect(truncate('abc', 10)).toBe('abc'); - }); - - it('slices and notes the overflow', () => { - const out = truncate('abcdefghij', 4); - expect(out.startsWith('abcd')).toBe(true); - expect(out).toContain('6 chars'); - }); -}); diff --git a/packages/integrations/mail-mcp/src/__tests__/config.test.ts b/packages/integrations/mail-mcp/src/__tests__/config.test.ts deleted file mode 100644 index ec6424428..000000000 --- a/packages/integrations/mail-mcp/src/__tests__/config.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ConfigError, inferPresetFromEmail, loadConfig } from '../config'; - -const RE_IMAP_PORT = /IMAP_PORT/; - -describe('loadConfig presets', () => { - it.each([ - ['163', 'imap.163.com', 993, 'smtp.163.com', 465], - ['qq', 'imap.qq.com', 993, 'smtp.qq.com', 465], - ['exmail', 'imap.exmail.qq.com', 993, 'smtp.exmail.qq.com', 465], - ] as const)( - 'preset=%s fills host/port and secure=true', - (preset, imapHost, imapPort, smtpHost, smtpPort) => { - const cfg = loadConfig({ MAIL_USER: 'u@x.com', MAIL_PASSWORD: 'code', MAIL_PRESET: preset }); - expect(cfg.imap.host).toBe(imapHost); - expect(cfg.imap.port).toBe(imapPort); - expect(cfg.imap.secure).toBe(true); - expect(cfg.smtp.host).toBe(smtpHost); - expect(cfg.smtp.port).toBe(smtpPort); - expect(cfg.smtp.secure).toBe(true); - }, - ); - - it('corrects a mismatched configured preset from a QQ account suffix', () => { - const cfg = loadConfig({ - MAIL_USER: 'user@qq.com', - MAIL_PASSWORD: 'qq-authorisation-code', - MAIL_PRESET: '163', - }); - expect(cfg.imap.host).toBe('imap.qq.com'); - expect(cfg.smtp.host).toBe('smtp.qq.com'); - }); - - it('infers a preset when the account suffix is known and MAIL_PRESET is absent', () => { - const cfg = loadConfig({ MAIL_USER: 'user@163.com', MAIL_PASSWORD: '163-authorisation-code' }); - expect(cfg.imap.host).toBe('imap.163.com'); - expect(cfg.smtp.host).toBe('smtp.163.com'); - }); - - it('defaults SMTP_USER/SMTP_PASSWORD/SMTP_FROM to the mail account', () => { - const cfg = loadConfig({ MAIL_USER: 'u@163.com', MAIL_PASSWORD: 'code', MAIL_PRESET: '163' }); - expect(cfg.smtp.user).toBe('u@163.com'); - expect(cfg.smtp.password).toBe('code'); - expect(cfg.smtpFrom).toBe('u@163.com'); - }); -}); - -describe('inferPresetFromEmail', () => { - it.each([ - ['person@qq.com', 'qq'], - ['PERSON@163.COM', '163'], - ['person@example.com', null], - ] as const)('maps %s to %s', (email, expected) => { - expect(inferPresetFromEmail(email)).toBe(expected); - }); -}); - -describe('loadConfig overrides', () => { - it('custom IMAP host with IMAP_SECURE=false derives port 143', () => { - const cfg = loadConfig({ - MAIL_USER: 'u', - MAIL_PASSWORD: 'p', - MAIL_PRESET: '163', - IMAP: 'imap.x.com', - IMAP_SECURE: 'false', - }); - expect(cfg.imap.host).toBe('imap.x.com'); - expect(cfg.imap.secure).toBe(false); - expect(cfg.imap.port).toBe(143); - }); - - it('SMTP_USER/SMTP_PASSWORD/SMTP_FROM override', () => { - const cfg = loadConfig({ - MAIL_USER: 'u', - MAIL_PASSWORD: 'p', - MAIL_PRESET: '163', - SMTP_USER: 'smtpu', - SMTP_PASSWORD: 'smtpp', - SMTP_FROM: 'from@x.com', - }); - expect(cfg.smtp.user).toBe('smtpu'); - expect(cfg.smtp.password).toBe('smtpp'); - expect(cfg.smtpFrom).toBe('from@x.com'); - }); - - it('no preset falls back to default ports from secure', () => { - const cfg = loadConfig({ - MAIL_USER: 'u', - MAIL_PASSWORD: 'p', - IMAP: 'imap.x.com', - SMTP: 'smtp.x.com', - }); - expect(cfg.imap.port).toBe(993); - expect(cfg.smtp.port).toBe(465); - }); - - it('IMAP_PORT/SMTP_PORT override preset and default ports', () => { - const cfg = loadConfig({ - MAIL_USER: 'u', - MAIL_PASSWORD: 'p', - MAIL_PRESET: '163', - IMAP_PORT: '1993', - SMTP_PORT: '2465', - }); - expect(cfg.imap.port).toBe(1993); - expect(cfg.smtp.port).toBe(2465); - }); - - it('rejects a non-numeric port', () => { - expect(() => - loadConfig({ MAIL_USER: 'u', MAIL_PASSWORD: 'p', MAIL_PRESET: '163', IMAP_PORT: 'abc' }), - ).toThrow(RE_IMAP_PORT); - }); -}); - -describe('loadConfig errors', () => { - it('throws on missing MAIL_USER', () => { - expect(() => loadConfig({ MAIL_PASSWORD: 'p', MAIL_PRESET: '163' })).toThrow(ConfigError); - }); - it('throws on missing MAIL_PASSWORD', () => { - expect(() => loadConfig({ MAIL_USER: 'u', MAIL_PRESET: '163' })).toThrow(ConfigError); - }); - it('throws on missing host without preset', () => { - expect(() => loadConfig({ MAIL_USER: 'u', MAIL_PASSWORD: 'p' })).toThrow(ConfigError); - }); - it('throws on unknown preset', () => { - expect(() => loadConfig({ MAIL_USER: 'u', MAIL_PASSWORD: 'p', MAIL_PRESET: 'gmail' })).toThrow( - ConfigError, - ); - }); -}); - -describe('loadConfig maxBodyChars', () => { - const base = { MAIL_USER: 'u', MAIL_PASSWORD: 'p', MAIL_PRESET: '163' }; - it('defaults to 8000', () => { - expect(loadConfig(base).maxBodyChars).toBe(8000); - }); - it('clamps below the minimum up to 100', () => { - expect(loadConfig({ ...base, MAX_BODY_CHARS: '50' }).maxBodyChars).toBe(100); - }); - it('clamps above the maximum down to 100000', () => { - expect(loadConfig({ ...base, MAX_BODY_CHARS: '999999' }).maxBodyChars).toBe(100000); - }); - it('falls back to default on garbage', () => { - expect(loadConfig({ ...base, MAX_BODY_CHARS: 'garbage' }).maxBodyChars).toBe(8000); - }); - it('accepts a valid value', () => { - expect(loadConfig({ ...base, MAX_BODY_CHARS: '5000' }).maxBodyChars).toBe(5000); - }); -}); diff --git a/packages/integrations/mail-mcp/src/__tests__/imap.test.ts b/packages/integrations/mail-mcp/src/__tests__/imap.test.ts deleted file mode 100644 index 8faafd2f0..000000000 --- a/packages/integrations/mail-mcp/src/__tests__/imap.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { trueFn } from 'foxts/noop'; -import type { MailboxObject } from 'imapflow'; -import { describe, expect, it, vi } from 'vitest'; -import type { ImapFlowFactory, ImapFlowPort, MailImapClient, ReplyOrigin } from '../imap'; -import { MailImap } from '../imap'; -import type { MailConfig } from '../types'; - -function makeConfig(user = 'me@x.com'): MailConfig { - return { - imap: { host: 'h', port: 993, secure: true, user, password: 'p' }, - smtp: { host: 'h', port: 465, secure: true, user, password: 'p' }, - smtpFrom: user, - maxBodyChars: 8000, - }; -} - -function makeMailbox(exists: number): MailboxObject { - return { - path: 'INBOX', - delimiter: '/', - flags: new Set(), - uidValidity: 1n, - uidNext: 1, - exists, - }; -} - -const lock = { release: vi.fn() }; - -function makeFlow(overrides: Partial = {}, mailboxExists = 10): ImapFlowPort { - return { - mailbox: makeMailbox(mailboxExists), - connect: vi.fn(), - logout: vi.fn(), - close: vi.fn(), - on: vi.fn(), - list: vi.fn(), - getMailboxLock: vi.fn().mockResolvedValue(lock), - search: vi.fn(), - fetchOne: vi.fn(), - fetchAll: vi.fn(), - messageFlagsAdd: vi.fn(), - messageFlagsRemove: vi.fn(), - messageMove: vi.fn(), - ...overrides, - }; -} - -function makeImap(flow: ImapFlowPort): MailImapClient { - return new MailImap(makeConfig(), () => flow); -} - -function makeEventedFlow( - overrides: Partial = {}, -): ImapFlowPort & { emit(event: 'close' | 'error', error?: Error): void } { - const listeners = new Map void>>(); - const flow = makeFlow(overrides) as ImapFlowPort & { - on(event: 'close' | 'error', listener: (error?: Error) => void): void; - emit(event: 'close' | 'error', error?: Error): void; - }; - flow.on = (event, listener) => { - const existing = listeners.get(event) ?? []; - existing.push(listener); - listeners.set(event, existing); - }; - flow.emit = (event, error) => { - for (const listener of listeners.get(event) ?? []) listener(error); - }; - return flow; -} - -describe('MailImap.listFolders', () => { - it('maps folders with status counts', async () => { - const list = vi.fn().mockResolvedValue([ - { - path: 'INBOX', - specialUse: String.raw`\Inbox`, - status: { path: 'INBOX', messages: 5, unseen: 2 }, - }, - { path: 'Sent', specialUse: String.raw`\Sent`, status: { path: 'Sent', messages: 3 } }, - ]); - const folders = await makeImap(makeFlow({ list })).listFolders(); - expect(folders).toEqual([ - { path: 'INBOX', specialUse: String.raw`\Inbox`, messages: 5, unseen: 2 }, - { path: 'Sent', specialUse: String.raw`\Sent`, messages: 3, unseen: undefined }, - ]); - }); - - it('shares an in-flight connection across concurrent calls', async () => { - let resolveConnect: (() => void) | undefined; - const connect = vi.fn( - () => - new Promise((resolve) => { - resolveConnect = resolve; - }), - ); - const flow = makeFlow({ connect, list: vi.fn().mockResolvedValue([]) }); - const factory = vi.fn(() => flow); - const imap = new MailImap(makeConfig(), factory); - - const first = imap.listFolders(); - const second = imap.listFolders(); - expect(factory).toHaveBeenCalledTimes(1); - expect(connect).toHaveBeenCalledTimes(1); - - resolveConnect?.(); - await expect(Promise.all([first, second])).resolves.toEqual([[], []]); - }); - - it('drops a closed connection and reconnects on the next call', async () => { - const first = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); - const second = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); - const factory = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second); - const imap = new MailImap(makeConfig(), factory); - - await imap.listFolders(); - first.emit('close'); - await imap.listFolders(); - - expect(factory).toHaveBeenCalledTimes(2); - expect(second.connect).toHaveBeenCalledTimes(1); - }); - - it('handles an IMAP error, logs to stderr, and reconnects', async () => { - const first = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); - const second = makeEventedFlow({ list: vi.fn().mockResolvedValue([]) }); - const factory = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second); - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(trueFn); - const imap = new MailImap(makeConfig(), factory); - - await imap.listFolders(); - first.emit('error', new Error('socket reset')); - await imap.listFolders(); - - expect(stderr).toHaveBeenCalledWith( - '[linkcode-mail-mcp] IMAP connection error: socket reset\n', - ); - expect(factory).toHaveBeenCalledTimes(2); - }); -}); - -describe('MailImap.listMessages', () => { - it('fetches the last N by sequence range and returns newest-first', async () => { - const fetchAll = vi.fn().mockImplementation((range: string) => { - expect(range).toBe('1:10'); - return Promise.resolve([ - { uid: 1, seq: 1, envelope: { subject: 'old' } }, - { uid: 9, seq: 9, envelope: { subject: 'new' } }, - ]); - }); - const msgs = await makeImap(makeFlow({ fetchAll })).listMessages('INBOX', 10); - expect(msgs.map((m) => m.uid)).toEqual([9, 1]); - }); - - it('returns empty when the folder has no messages', async () => { - expect(await makeImap(makeFlow({}, 0)).listMessages('INBOX', 20)).toEqual([]); - }); -}); - -describe('MailImap.searchMessages', () => { - it('caps matched UIDs to the limit and sorts newest-first', async () => { - const search = vi.fn().mockResolvedValue([1, 2, 3, 4, 5]); - const fetchAll = vi.fn().mockImplementation((uids: number[]) => { - expect(uids).toEqual([4, 5]); - return Promise.resolve([ - { uid: 5, seq: 5, envelope: {} }, - { uid: 4, seq: 4, envelope: {} }, - ]); - }); - const msgs = await makeImap(makeFlow({ search, fetchAll })).searchMessages( - 'INBOX', - { subject: 'x' }, - 2, - ); - expect(msgs.map((m) => m.uid)).toEqual([5, 4]); - }); - - it('returns empty when search yields nothing', async () => { - const search = vi.fn().mockResolvedValue(false); - expect(await makeImap(makeFlow({ search })).searchMessages('INBOX', { from: 'x' }, 20)).toEqual( - [], - ); - }); -}); - -describe('MailImap.getMessage', () => { - const meta = { - uid: 7, - seq: 7, - envelope: { - subject: 'Hi', - from: [{ name: 'A', address: 'a@x.com' }], - to: [{ address: 'me@x.com' }], - messageId: '', - date: new Date('2026-01-01T00:00:00Z'), - }, - bodyStructure: { - type: 'multipart/mixed', - childNodes: [ - { type: 'text/plain', part: '1', parameters: { charset: 'utf-8' } }, - { - type: 'application/pdf', - part: '2', - disposition: 'attachment', - dispositionParameters: { filename: 'a.pdf' }, - size: 100, - }, - ], - }, - flags: new Set([String.raw`\Seen`]), - size: 42, - }; - const bodyMsg = { bodyParts: new Map([['1', Buffer.from('hello body', 'utf-8')]]) }; - - it('decodes the preferred text part, truncates, and lists attachments', async () => { - const fetchOne = vi - .fn() - .mockImplementation((_seq: number, query: { bodyStructure?: unknown; bodyParts?: unknown }) => - Promise.resolve(query.bodyParts ? bodyMsg : meta), - ); - const msg = await makeImap(makeFlow({ fetchOne })).getMessage('INBOX', 7); - expect(msg.uid).toBe(7); - expect(msg.subject).toBe('Hi'); - expect(msg.from).toBe('A '); - expect(msg.body).toBe('hello body'); - expect(msg.attachments).toEqual([ - { part: '2', filename: 'a.pdf', contentType: 'application/pdf', size: 100 }, - ]); - }); - - it('truncates a body over the configured limit', async () => { - const big = { bodyParts: new Map([['1', Buffer.from('x'.repeat(9000), 'utf-8')]]) }; - const fetchOne = vi - .fn() - .mockImplementation((_seq: number, query: { bodyStructure?: unknown; bodyParts?: unknown }) => - Promise.resolve(query.bodyParts ? big : meta), - ); - const msg = await makeImap(makeFlow({ fetchOne })).getMessage('INBOX', 7); - expect(msg.body?.length).toBeLessThan(9000); - expect(msg.body).toContain('truncated'); - }); - - it('throws when the message is missing', async () => { - const fetchOne = vi.fn().mockResolvedValue(false); - await expect(makeImap(makeFlow({ fetchOne })).getMessage('INBOX', 9)).rejects.toThrow( - 'not found', - ); - }); -}); - -describe('MailImap.getReplyOrigin', () => { - it('parses a folded References header into a chain', async () => { - const meta = { - envelope: { - subject: 'thread', - from: [{ address: 'a@x.com' }], - to: [{ address: 'me@x.com' }], - cc: [{ address: 'b@x.com' }], - messageId: '', - }, - headers: Buffer.from('References: \r\n \r\nOther: x\r\n', 'utf-8'), - }; - const fetchOne = vi.fn().mockResolvedValue(meta); - const origin: ReplyOrigin = await makeImap(makeFlow({ fetchOne })).getReplyOrigin('INBOX', 1); - expect(origin.references).toEqual(['', '']); - expect(origin.from).toEqual([{ address: 'a@x.com' }]); - expect(origin.messageId).toBe(''); - }); -}); - -describe('MailImap.markRead / moveMessage', () => { - it(String.raw`adds \Seen when read=true`, async () => { - const messageFlagsAdd = vi.fn().mockResolvedValue(true); - await makeImap(makeFlow({ messageFlagsAdd })).markRead('INBOX', 5, true); - expect(messageFlagsAdd).toHaveBeenCalledWith(5, [String.raw`\Seen`], { uid: true }); - }); - - it(String.raw`removes \Seen when read=false`, async () => { - const messageFlagsRemove = vi.fn().mockResolvedValue(true); - await makeImap(makeFlow({ messageFlagsRemove })).markRead('INBOX', 5, false); - expect(messageFlagsRemove).toHaveBeenCalledWith(5, [String.raw`\Seen`], { uid: true }); - }); - - it('moves a message by uid', async () => { - const messageMove = vi.fn().mockResolvedValue({ destination: 'Archive' }); - await makeImap(makeFlow({ messageMove })).moveMessage('INBOX', 5, 'Archive'); - expect(messageMove).toHaveBeenCalledWith(5, 'Archive', { uid: true }); - }); - - it('throws when move returns false', async () => { - const messageMove = vi.fn().mockResolvedValue(false); - await expect( - makeImap(makeFlow({ messageMove })).moveMessage('INBOX', 5, 'Archive'), - ).rejects.toThrow('Failed to move'); - }); -}); diff --git a/packages/integrations/mail-mcp/src/__tests__/smtp.test.ts b/packages/integrations/mail-mcp/src/__tests__/smtp.test.ts deleted file mode 100644 index 1f519c125..000000000 --- a/packages/integrations/mail-mcp/src/__tests__/smtp.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { SmtpTransporter } from '../smtp'; -import { MailSmtp } from '../smtp'; -import type { MailConfig } from '../types'; - -function makeConfig(): MailConfig { - return { - imap: { host: 'h', port: 993, secure: true, user: 'me@x.com', password: 'p' }, - smtp: { host: 'h', port: 465, secure: true, user: 'me@x.com', password: 'p' }, - smtpFrom: 'me@x.com', - maxBodyChars: 8000, - }; -} - -function makeTransporter(): SmtpTransporter { - return { - sendMail: vi.fn().mockResolvedValue({ messageId: '', response: '250 OK' }), - close: vi.fn(), - }; -} - -describe('MailSmtp.send', () => { - it('passes through addresses and reply headers, stamps from = smtpFrom', async () => { - const sendMail = vi.fn().mockResolvedValue({ messageId: '', response: '250 OK' }); - const transporter: SmtpTransporter = { sendMail, close: vi.fn() }; - const smtp = new MailSmtp(makeConfig(), () => transporter); - const result = await smtp.send({ - to: 'a@x.com', - subject: 'hi', - body: 'body', - cc: 'c@x.com', - inReplyTo: '', - references: ['', ''], - }); - expect(result).toEqual({ messageId: '', response: '250 OK' }); - expect(sendMail).toHaveBeenCalledTimes(1); - const opts = sendMail.mock.calls[0][0] as Record; - expect(opts.from).toBe('me@x.com'); - expect(opts.to).toBe('a@x.com'); - expect(opts.cc).toBe('c@x.com'); - expect(opts.subject).toBe('hi'); - expect(opts.text).toBe('body'); - expect(opts.inReplyTo).toBe(''); - expect(opts.references).toEqual(['', '']); - }); - - it('returns an empty response string when the server omits it', async () => { - const sendMail = vi.fn().mockResolvedValue({ messageId: '' }); - const smtp = new MailSmtp(makeConfig(), () => ({ sendMail, close: vi.fn() })); - const result = await smtp.send({ to: 'a@x.com', subject: 's', body: 'b' }); - expect(result.response).toBe(''); - }); -}); - -describe('MailSmtp.close', () => { - it('closes a created transporter exactly once', async () => { - const close = vi.fn(); - const transporter: SmtpTransporter = { - sendMail: vi.fn().mockResolvedValue({ messageId: '', response: '250' }), - close, - }; - const smtp = new MailSmtp(makeConfig(), () => transporter); - await smtp.send({ to: 'a@x.com', subject: 's', body: 'b' }); - await smtp.close(); - await smtp.close(); - expect(close).toHaveBeenCalledTimes(1); - }); - - it('is a no-op before any send', async () => { - const smtp = new MailSmtp(makeConfig(), () => makeTransporter()); - await expect(smtp.close()).resolves.toBeUndefined(); - }); -}); diff --git a/packages/integrations/mail-mcp/src/__tests__/tools.test.ts b/packages/integrations/mail-mcp/src/__tests__/tools.test.ts deleted file mode 100644 index a243cb467..000000000 --- a/packages/integrations/mail-mcp/src/__tests__/tools.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { describe, expect, it, vi } from 'vitest'; -import type { Address, MailImapClient, ReplyOrigin } from '../imap'; -import type { MailSmtpClient } from '../smtp'; -import type { MailToolDeps } from '../tools'; -import { registerMailTools } from '../tools'; - -interface ToolResult { - content: Array<{ type: 'text'; text: string }>; - isError?: boolean; -} - -interface CapturedTool { - cb: (args: Record) => Promise; -} - -class FakeServer { - readonly tools = new Map(); - registerTool(name: string, _config: unknown, cb: CapturedTool['cb']): void { - this.tools.set(name, { cb }); - } -} - -function makeDeps( - imap: Partial, - smtp: Partial, - accountEmail = 'me@x.com', -): FakeServer { - const server = new FakeServer(); - const fullImap: MailImapClient = { - listFolders: vi.fn(), - listMessages: vi.fn(), - searchMessages: vi.fn(), - getMessage: vi.fn(), - getReplyOrigin: vi.fn(), - markRead: vi.fn(), - moveMessage: vi.fn(), - close: vi.fn(), - ...imap, - }; - const fullSmtp: MailSmtpClient = { - send: vi.fn(), - close: vi.fn(), - ...smtp, - }; - const deps: MailToolDeps = { imap: fullImap, smtp: fullSmtp, accountEmail }; - // eslint-disable-next-line sukka/type/no-force-cast-via-top-type -- test fake of a 3rd-party class; only registerTool is exercised - registerMailTools(server as unknown as McpServer, deps); - return server; -} - -function parseResult(r: ToolResult): { data: unknown; isError: boolean; text: string } { - const text = r.content[0].text; - let data: unknown = text; - try { - data = JSON.parse(text); - } catch { - // error results carry a plain message, not JSON - } - return { data, isError: r.isError === true, text }; -} - -describe('reply_message tool', () => { - const origin: ReplyOrigin = { - messageId: '', - subject: 'Hi', - from: [{ address: 'a@x.com' }] as Address[], - to: [{ address: 'me@x.com' }, { address: 'c@x.com' }] as Address[], - cc: [{ address: 'd@x.com' }] as Address[], - references: [''], - }; - - it('replyAll drops the self address and extends the references chain', async () => { - const send = vi.fn().mockResolvedValue({ messageId: '', response: '250 OK' }); - const server = makeDeps({ getReplyOrigin: vi.fn().mockResolvedValue(origin) }, { send }); - const r = await server.tools.get('reply_message')!.cb({ - folder: 'INBOX', - uid: 1, - body: 'reply', - replyAll: true, - }); - expect(parseResult(r).isError).toBe(false); - expect(send).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'a@x.com, c@x.com, d@x.com', - subject: 'Re: Hi', - inReplyTo: '', - references: ['', ''], - }), - ); - }); - - it('reply-to-sender only addresses From', async () => { - const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); - const server = makeDeps({ getReplyOrigin: vi.fn().mockResolvedValue(origin) }, { send }); - await server.tools.get('reply_message')!.cb({ folder: 'INBOX', uid: 1, body: 'reply' }); - expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: 'a@x.com' })); - }); - - it('preserves a subject already prefixed with Re:', async () => { - const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); - const server = makeDeps( - { getReplyOrigin: vi.fn().mockResolvedValue({ ...origin, subject: 'Re: Hi' }) }, - { send }, - ); - await server.tools - .get('reply_message')! - .cb({ folder: 'INBOX', uid: 1, body: 'r', replyAll: true }); - expect(send).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Re: Hi' })); - }); - - it('appends the original messageId only once even if already referenced', async () => { - const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); - const server = makeDeps( - { - getReplyOrigin: vi - .fn() - .mockResolvedValue({ ...origin, references: ['', ''] }), - }, - { send }, - ); - await server.tools.get('reply_message')!.cb({ folder: 'INBOX', uid: 1, body: 'r' }); - expect(send).toHaveBeenCalledWith( - expect.objectContaining({ references: ['', ''] }), - ); - }); - - it('errors when the original has no replyable From', async () => { - const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); - const server = makeDeps( - { getReplyOrigin: vi.fn().mockResolvedValue({ ...origin, from: [] as Address[] }) }, - { send }, - ); - const r = await server.tools.get('reply_message')!.cb({ folder: 'INBOX', uid: 1, body: 'r' }); - expect(parseResult(r).isError).toBe(true); - expect(send).not.toHaveBeenCalled(); - }); -}); - -describe('send_message tool', () => { - it('passes args straight through to SMTP and returns the send result', async () => { - const send = vi.fn().mockResolvedValue({ messageId: '', response: '250' }); - const server = makeDeps({}, { send }); - const r = await server.tools.get('send_message')!.cb({ - to: 'a@x.com', - subject: 'hello', - body: 'hi', - cc: 'c@x.com', - }); - expect(send).toHaveBeenCalledWith( - expect.objectContaining({ to: 'a@x.com', subject: 'hello', body: 'hi', cc: 'c@x.com' }), - ); - expect(parseResult(r).data).toEqual({ messageId: '', response: '250' }); - }); -}); - -describe('list_messages tool', () => { - it('clamps the limit into the allowed range before delegating', async () => { - const listMessages = vi.fn().mockResolvedValue([]); - const server = makeDeps({ listMessages }, {}); - await server.tools.get('list_messages')!.cb({ folder: 'INBOX', limit: 9999 }); - expect(listMessages).toHaveBeenCalledWith('INBOX', 100); - }); - - it('applies the default limit when omitted', async () => { - const listMessages = vi.fn().mockResolvedValue([]); - const server = makeDeps({ listMessages }, {}); - await server.tools.get('list_messages')!.cb({ folder: 'INBOX' }); - expect(listMessages).toHaveBeenCalledWith('INBOX', 20); - }); -}); - -describe('error handling', () => { - it('surfaces a thrown tool error as isError=true, not a thrown exception', async () => { - const listMessages = vi.fn().mockRejectedValue(new Error('boom')); - const server = makeDeps({ listMessages }, {}); - const r = await server.tools.get('list_messages')!.cb({ folder: 'INBOX' }); - expect(parseResult(r).isError).toBe(true); - expect(r.content[0].text).toContain('boom'); - }); -}); diff --git a/packages/integrations/mail-mcp/src/body.ts b/packages/integrations/mail-mcp/src/body.ts deleted file mode 100644 index d6a2b6892..000000000 --- a/packages/integrations/mail-mcp/src/body.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { MessageStructureObject } from 'imapflow'; - -export interface TextPartRef { - readonly part: string; - readonly contentType: string; - readonly charset?: string; -} - -export interface AttachmentRef { - readonly part: string; - readonly filename?: string; - readonly contentType: string; - readonly size?: number; - readonly disposition?: string; -} - -function leafNodes(root?: MessageStructureObject): MessageStructureObject[] { - if (!root) return []; - const out: MessageStructureObject[] = []; - const walk = (node: MessageStructureObject): void => { - if (node.childNodes?.length) { - for (const child of node.childNodes) walk(child); - return; - } - out.push(node); - }; - walk(root); - return out; -} - -/** Leaf `text/*` parts that can serve as the readable body, preferring text/plain. */ -export function selectReadableParts(root?: MessageStructureObject): TextPartRef[] { - const out: TextPartRef[] = []; - for (const node of leafNodes(root)) { - const type = node.type.toLowerCase(); - if (type.startsWith('text/')) { - out.push({ part: node.part ?? '', contentType: type, charset: node.parameters?.charset }); - } - } - return out; -} - -export function pickPreferredPart(parts: TextPartRef[]): TextPartRef | undefined { - if (parts.length === 0) return undefined; - const plain = parts.find((p) => p.contentType === 'text/plain'); - return plain ?? parts[0]; -} - -/** Non-body leaves: anything explicitly `attachment`, or non-text leaves (covers inline images). */ -export function collectAttachments(root?: MessageStructureObject): AttachmentRef[] { - const out: AttachmentRef[] = []; - for (const node of leafNodes(root)) { - const type = node.type.toLowerCase(); - const disposition = node.disposition?.toLowerCase(); - if (disposition !== 'attachment' && type.startsWith('text/')) continue; - out.push({ - part: node.part ?? '', - filename: node.dispositionParameters?.filename ?? node.parameters?.name, - contentType: type, - size: node.size, - disposition, - }); - } - return out; -} - -/** Decode a raw body part Buffer using the declared charset; Node ships full-ICU TextDecoder (gbk/gb2312/big5). */ -export function decodeBodyPart(buffer: Buffer | undefined, charset?: string): string { - if (!buffer) return ''; - const label = normalizeCharset(charset); - try { - return new TextDecoder(label).decode(buffer); - } catch { - return new TextDecoder('utf-8').decode(buffer); - } -} - -function normalizeCharset(charset?: string): string { - if (!charset) return 'utf-8'; - const lower = charset.toLowerCase(); - // gb2312 is a subset of gbk; Node's TextDecoder resolves both to the same decoder. - if (lower === 'gb2312' || lower === 'gb18030') return 'gbk'; - return lower; -} - -export function truncate(text: string, max: number): string { - if (text.length <= max) return text; - const overflow = text.length - max; - return `${text.slice(0, max)}\n…[truncated ${overflow} chars]`; -} diff --git a/packages/integrations/mail-mcp/src/config.ts b/packages/integrations/mail-mcp/src/config.ts deleted file mode 100644 index 001c4cd75..000000000 --- a/packages/integrations/mail-mcp/src/config.ts +++ /dev/null @@ -1,127 +0,0 @@ -import process from 'node:process'; -import { clamp } from 'foxts/clamp'; -import type { MailConfig, MailPreset } from './types'; - -const DEFAULT_MAX_BODY_CHARS = 8000; -const MIN_BODY_CHARS = 100; -const MAX_BODY_CHARS = 100000; - -const PRESETS: Record< - MailPreset, - { imap: { host: string; port: number }; smtp: { host: string; port: number } } -> = { - '163': { imap: { host: 'imap.163.com', port: 993 }, smtp: { host: 'smtp.163.com', port: 465 } }, - qq: { imap: { host: 'imap.qq.com', port: 993 }, smtp: { host: 'smtp.qq.com', port: 465 } }, - exmail: { - imap: { host: 'imap.exmail.qq.com', port: 993 }, - smtp: { host: 'smtp.exmail.qq.com', port: 465 }, - }, -}; - -const RE_TRUTHY = /^(?:1|true|yes|on)$/i; - -export class ConfigError extends Error { - override name = 'ConfigError'; -} - -function parseBool(value: string | undefined, fallback: boolean): boolean { - if (value === undefined || value === '') return fallback; - return RE_TRUTHY.test(value.trim()); -} - -function parsePreset(value: string | undefined): MailPreset | null { - if (value === undefined || value === '') return null; - const lower = value.trim().toLowerCase(); - if (lower === '163' || lower === 'qq' || lower === 'exmail') return lower; - throw new ConfigError(`MAIL_PRESET must be one of: 163, qq, exmail (got: ${value})`); -} - -/** Infer the two consumer-mail presets whose domains uniquely identify their provider. */ -export function inferPresetFromEmail(user: string): MailPreset | null { - const domain = user.trim().toLowerCase().split('@').at(-1); - if (domain === 'qq.com') return 'qq'; - if (domain === '163.com') return '163'; - return null; -} - -function resolvePreset(user: string, configuredPreset: MailPreset | null): MailPreset | null { - // The settings form has a backwards-compatible default of 163. Prefer a recognisable account - // suffix so a QQ account cannot accidentally be sent to 163's IMAP/SMTP endpoints. - return inferPresetFromEmail(user) ?? configuredPreset; -} - -function defaultImapPort(secure: boolean): number { - return secure ? 993 : 143; -} - -function defaultSmtpPort(secure: boolean): number { - return secure ? 465 : 587; -} - -function parsePort(value: string | undefined, name: string): number | null { - if (value === undefined || value === '') return null; - const port = Number(value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new ConfigError(`${name} must be an integer between 1 and 65535 (got: ${value})`); - } - return port; -} - -export function loadConfig(env: NodeJS.ProcessEnv = process.env): MailConfig { - const user = env.MAIL_USER?.trim(); - if (!user) throw new ConfigError('MAIL_USER is required'); - - const password = env.MAIL_PASSWORD; - if (!password) { - throw new ConfigError( - 'MAIL_PASSWORD is required (the 163/QQ authorization code, not the login password)', - ); - } - - const preset = resolvePreset(user, parsePreset(env.MAIL_PRESET)); - const presetHosts = preset ? PRESETS[preset] : null; - - const imapHost = env.IMAP?.trim() || presetHosts?.imap.host; - if (!imapHost) { - throw new ConfigError('IMAP host is required: set MAIL_PRESET=163|qq|exmail or provide IMAP'); - } - - const smtpHost = env.SMTP?.trim() || presetHosts?.smtp.host; - if (!smtpHost) { - throw new ConfigError('SMTP host is required: set MAIL_PRESET=163|qq|exmail or provide SMTP'); - } - - const imapSecure = parseBool(env.IMAP_SECURE, true); - const smtpSecure = parseBool(env.SMTP_SECURE, true); - // Precedence: explicit port env > preset's pinned port (host not overridden) > secure default. - const imapPort = - parsePort(env.IMAP_PORT, 'IMAP_PORT') ?? - (presetHosts && !env.IMAP ? presetHosts.imap.port : defaultImapPort(imapSecure)); - const smtpPort = - parsePort(env.SMTP_PORT, 'SMTP_PORT') ?? - (presetHosts && !env.SMTP ? presetHosts.smtp.port : defaultSmtpPort(smtpSecure)); - - const smtpUser = env.SMTP_USER?.trim() || user; - // `||` not `??`: an empty SMTP_PASSWORD would otherwise log in with no credential. - const smtpPassword = env.SMTP_PASSWORD || password; - const smtpFrom = env.SMTP_FROM?.trim() || user; - - const parsedMax = Number(env.MAX_BODY_CHARS); - const maxBodyChars = - Number.isFinite(parsedMax) && parsedMax > 0 - ? clamp(Math.trunc(parsedMax), MIN_BODY_CHARS, MAX_BODY_CHARS) - : DEFAULT_MAX_BODY_CHARS; - - return { - imap: { host: imapHost, port: imapPort, secure: imapSecure, user, password }, - smtp: { - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - user: smtpUser, - password: smtpPassword, - }, - smtpFrom, - maxBodyChars, - }; -} diff --git a/packages/integrations/mail-mcp/src/imap.ts b/packages/integrations/mail-mcp/src/imap.ts deleted file mode 100644 index c5c5702cc..000000000 --- a/packages/integrations/mail-mcp/src/imap.ts +++ /dev/null @@ -1,411 +0,0 @@ -import type { - FetchMessageObject, - FetchQueryObject, - ImapFlowOptions, - ListResponse, - MailboxObject, - MessageAddressObject, - SearchObject, -} from 'imapflow'; -import { ImapFlow } from 'imapflow'; -import { - collectAttachments, - decodeBodyPart, - pickPreferredPart, - selectReadableParts, - truncate, -} from './body'; -import type { MailConfig } from './types'; - -export interface FolderSummary { - readonly path: string; - readonly specialUse?: string; - readonly messages?: number; - readonly unseen?: number; -} - -export interface MessageSummary { - readonly uid: number; - readonly subject?: string; - readonly from?: string; - readonly to?: string; - readonly date?: string; - readonly flags?: string[]; - readonly size?: number; -} - -export interface FullMessage { - readonly uid: number; - readonly subject?: string; - readonly from?: string; - readonly to?: string; - readonly cc?: string; - readonly date?: string; - readonly messageId?: string; - readonly inReplyTo?: string; - readonly flags?: string[]; - readonly size?: number; - readonly body?: string; - readonly attachments: ReadonlyArray<{ - readonly part: string; - readonly filename?: string; - readonly contentType: string; - readonly size?: number; - }>; -} - -export interface MailboxLock { - release(): void; -} - -export interface ImapFlowPort { - readonly mailbox: MailboxObject | false; - // Property-style so tests can reference the vi.fn() doubles without an unbound-method warning. - connect: () => Promise; - logout(): Promise; - close(): void; - on(event: 'error' | 'close', handler: (error?: unknown) => void): void; - list(options?: { statusQuery?: Partial> }): Promise; - getMailboxLock(path: string, options?: { readOnly?: boolean }): Promise; - search(query: SearchObject, options?: { uid?: boolean }): Promise; - fetchOne( - seq: number, - query: FetchQueryObject, - options?: { uid?: boolean }, - ): Promise; - fetchAll( - range: string | number[], - query: FetchQueryObject, - options?: { uid?: boolean }, - ): Promise; - messageFlagsAdd(range: number, flags: string[], options?: { uid?: boolean }): Promise; - messageFlagsRemove(range: number, flags: string[], options?: { uid?: boolean }): Promise; - messageMove(range: number, destination: string, options?: { uid?: boolean }): Promise; -} - -export interface ReplyOrigin { - readonly messageId?: string; - readonly subject?: string; - readonly from: Address[]; - readonly to: Address[]; - readonly cc: Address[]; - readonly references: string[]; -} - -export interface MailImapClient { - listFolders(): Promise; - listMessages(folder: string, limit: number): Promise; - searchMessages( - folder: string, - query: Record, - limit: number, - ): Promise; - getMessage(folder: string, uid: number): Promise; - getReplyOrigin(folder: string, uid: number): Promise; - markRead(folder: string, uid: number, read: boolean): Promise; - moveMessage(folder: string, uid: number, destination: string): Promise; - close(): Promise; -} - -export type ImapFlowFactory = (config: MailConfig) => ImapFlowPort; - -/** The subset of ImapFlow's EventEmitter surface used to keep cached connections healthy. */ -interface EventedImapFlowPort { - on(event: 'close', listener: () => void): unknown; - on(event: 'error', listener: (error: Error) => void): unknown; -} - -const SEEN_FLAG = String.raw`\Seen`; - -export class MailImap implements MailImapClient { - private flow: ImapFlowPort | undefined; - private connecting: Promise | undefined; - private pendingFlow: ImapFlowPort | undefined; - - constructor( - private readonly config: MailConfig, - private readonly flowFactory?: ImapFlowFactory, - ) {} - - async listFolders(): Promise { - const flow = await this.ensureConnected(); - const folders = await flow.list({ statusQuery: { messages: true, unseen: true } }); - return folders.map((f) => ({ - path: f.path, - specialUse: f.specialUse, - messages: f.status?.messages, - unseen: f.status?.unseen, - })); - } - - async listMessages(folder: string, limit: number): Promise { - const flow = await this.ensureConnected(); - const lock = await flow.getMailboxLock(folder, { readOnly: true }); - try { - const exists = flow.mailbox ? flow.mailbox.exists : 0; - if (exists === 0) return []; - const start = Math.max(1, exists - limit + 1); - const range = `${start}:${exists}`; - const messages = await flow.fetchAll(range, { envelope: true, flags: true, size: true }, {}); - return messages.reverse().map(toSummary); - } finally { - lock.release(); - } - } - - async searchMessages( - folder: string, - query: SearchObject, - limit: number, - ): Promise { - const flow = await this.ensureConnected(); - const lock = await flow.getMailboxLock(folder, { readOnly: true }); - try { - const result = await flow.search(query, { uid: true }); - const uids = Array.isArray(result) ? result : []; - if (uids.length === 0) return []; - const capped = uids.slice(-limit); - const messages = await flow.fetchAll( - capped, - { envelope: true, flags: true, size: true }, - { uid: true }, - ); - return messages.sort((a, b) => b.uid - a.uid).map(toSummary); - } finally { - lock.release(); - } - } - - async getMessage(folder: string, uid: number): Promise { - const flow = await this.ensureConnected(); - const lock = await flow.getMailboxLock(folder, { readOnly: true }); - try { - const meta = await flow.fetchOne( - uid, - { envelope: true, bodyStructure: true, flags: true, internalDate: true, size: true }, - { uid: true }, - ); - if (!meta) throw new Error(`Message uid=${uid} not found in ${folder}`); - const structure = meta.bodyStructure; - const readable = pickPreferredPart(selectReadableParts(structure)); - let body: string | undefined; - if (readable) { - const bodyMsg = await flow.fetchOne(uid, { bodyParts: [readable.part] }, { uid: true }); - const buf = bodyMsg ? bodyMsg.bodyParts?.get(readable.part) : undefined; - body = truncate(decodeBodyPart(buf, readable.charset), this.config.maxBodyChars); - } - const attachments = collectAttachments(structure).map((a) => ({ - part: a.part, - filename: a.filename, - contentType: a.contentType, - size: a.size, - })); - const env = meta.envelope; - return { - uid: meta.uid, - subject: env?.subject, - from: formatAddresses(env?.from), - to: formatAddresses(env?.to), - cc: formatAddresses(env?.cc), - date: env?.date ? new Date(env.date).toISOString() : undefined, - messageId: env?.messageId, - inReplyTo: env?.inReplyTo, - flags: meta.flags ? [...meta.flags] : undefined, - size: meta.size, - body, - attachments, - }; - } finally { - lock.release(); - } - } - - async getReplyOrigin(folder: string, uid: number): Promise { - const flow = await this.ensureConnected(); - const lock = await flow.getMailboxLock(folder, { readOnly: true }); - try { - const meta = await flow.fetchOne( - uid, - { envelope: true, headers: ['references'] }, - { uid: true }, - ); - if (!meta) throw new Error(`Message uid=${uid} not found in ${folder}`); - const env = meta.envelope; - return { - messageId: env?.messageId, - subject: env?.subject, - from: toAddresses(env?.from), - to: toAddresses(env?.to), - cc: toAddresses(env?.cc), - references: parseReferences(meta.headers), - }; - } finally { - lock.release(); - } - } - - async markRead(folder: string, uid: number, read: boolean): Promise { - const flow = await this.ensureConnected(); - const lock = await flow.getMailboxLock(folder); - try { - if (read) await flow.messageFlagsAdd(uid, [SEEN_FLAG], { uid: true }); - else await flow.messageFlagsRemove(uid, [SEEN_FLAG], { uid: true }); - } finally { - lock.release(); - } - } - - async moveMessage(folder: string, uid: number, destination: string): Promise { - const flow = await this.ensureConnected(); - const lock = await flow.getMailboxLock(folder); - try { - const result = await flow.messageMove(uid, destination, { uid: true }); - if (result === false) throw new Error(`Failed to move uid=${uid} to ${destination}`); - } finally { - lock.release(); - } - } - - async close(): Promise { - const flow = this.flow ?? this.pendingFlow; - if (!flow) return; - this.flow = undefined; - this.pendingFlow = undefined; - try { - await flow.logout(); - } catch { - flow.close(); - } - } - - private async ensureConnected(): Promise { - if (this.flow) return this.flow; - if (this.connecting) return this.connecting; - const connecting = this.connect(); - this.connecting = connecting; - try { - return await connecting; - } finally { - if (this.connecting === connecting) this.connecting = undefined; - } - } - - private async connect(): Promise { - const flow = this.flowFactory ? this.flowFactory(this.config) : createImapFlow(this.config); - this.pendingFlow = flow; - this.attachLifecycleHandlers(flow); - try { - await flow.connect(); - // `close()` may have been called while the asynchronous connect was in progress. - if (this.pendingFlow !== flow) { - flow.close(); - throw new Error('IMAP connection closed while connecting'); - } - this.flow = flow; - return flow; - } finally { - if (this.pendingFlow === flow) this.pendingFlow = undefined; - } - } - - private attachLifecycleHandlers(flow: ImapFlowPort): void { - if (!isEventedImapFlow(flow)) return; - flow.on('close', () => this.invalidateFlow(flow)); - // An EventEmitter `error` event without a listener terminates Node. Log it on stderr (stdout - // is MCP JSON-RPC) and invalidate the cached flow so the next tool call establishes a socket. - flow.on('error', (error) => { - this.invalidateFlow(flow); - process.stderr.write(`[linkcode-mail-mcp] IMAP connection error: ${error.message}\n`); - }); - } - - private invalidateFlow(flow: ImapFlowPort): void { - if (this.flow === flow) this.flow = undefined; - if (this.pendingFlow === flow) this.pendingFlow = undefined; - } -} - -function isEventedImapFlow(flow: ImapFlowPort): flow is ImapFlowPort & EventedImapFlowPort { - return 'on' in flow && typeof flow.on === 'function'; -} - -function createImapFlow(config: MailConfig): ImapFlowPort { - const options: ImapFlowOptions = { - host: config.imap.host, - port: config.imap.port, - // ImapFlow logs to stdout by default; stdout is the MCP JSON-RPC channel, so disable. - logger: false, - secure: config.imap.secure, - auth: { user: config.imap.user, pass: config.imap.password }, - // 163 rejects connections without an RFC 2971 ID response; ImapFlow sends it when clientInfo is set. - clientInfo: { name: 'linkcode-mail-mcp', vendor: 'linkcode' }, - }; - return new ImapFlow(options); -} - -export interface Address { - readonly name?: string; - readonly address?: string; -} - -const RE_NEWLINE = /\r?\n/; -const RE_REFERENCES_LINE = /^references:/i; -const RE_REFERENCES_PREFIX = /^references:\s*/i; -const RE_FOLDED = /^\s/; -const RE_WS = /\s+/; - -/** Parsed `References:` header chain (RFC 5322 message-id tokens), honoring folded continuation lines. */ -function parseReferences(headers: Buffer | undefined): string[] { - if (!headers) return []; - const out: string[] = []; - let capturing = false; - for (const line of headers.toString('utf-8').split(RE_NEWLINE)) { - if (RE_REFERENCES_LINE.test(line)) { - capturing = true; - for (const token of line.replace(RE_REFERENCES_PREFIX, '').trim().split(RE_WS)) { - if (token) out.push(token); - } - } else if (capturing && RE_FOLDED.test(line)) { - for (const token of line.trim().split(RE_WS)) { - if (token) out.push(token); - } - } else if (capturing) { - break; - } - } - return out; -} - -function toSummary(msg: FetchMessageObject): MessageSummary { - const env = msg.envelope; - return { - uid: msg.uid, - subject: env?.subject, - from: formatAddresses(env?.from), - to: formatAddresses(env?.to), - date: env?.date ? new Date(env.date).toISOString() : undefined, - flags: msg.flags ? [...msg.flags] : undefined, - size: msg.size, - }; -} - -function toAddresses(addresses?: MessageAddressObject[]): Address[] { - if (!addresses) return []; - const out: Address[] = []; - for (const a of addresses) { - if (a.name !== undefined || a.address !== undefined) { - out.push({ name: a.name, address: a.address }); - } - } - return out; -} - -export function formatAddresses(addresses?: readonly Address[]): string | undefined { - if (!addresses || addresses.length === 0) return undefined; - const parts: string[] = []; - for (const a of addresses) { - const formatted = a.name ? `${a.name} <${a.address ?? ''}>` : (a.address ?? ''); - if (formatted) parts.push(formatted); - } - return parts.length ? parts.join(', ') : undefined; -} diff --git a/packages/integrations/mail-mcp/src/index.ts b/packages/integrations/mail-mcp/src/index.ts deleted file mode 100644 index 91c3cd64e..000000000 --- a/packages/integrations/mail-mcp/src/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import process from 'node:process'; -// 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'; -// eslint-disable-next-line import-x/no-unresolved -- same exports-map subpath as above -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -import { loadConfig } from './config'; -import { MailImap } from './imap'; -import { MailSmtp } from './smtp'; -import { registerMailTools } from './tools'; - -// Build-time injected from package.json by tsup `define`; the fallback only covers unbundled runs. -declare const __MAIL_MCP_VERSION__: string | undefined; -const VERSION = typeof __MAIL_MCP_VERSION__ === 'string' ? __MAIL_MCP_VERSION__ : '0.0.0'; - -async function main(): Promise { - const config = loadConfig(); - const imap = new MailImap(config); - const smtp = new MailSmtp(config); - const server = new McpServer( - { name: 'linkcode-mail-mcp', version: VERSION }, - { - instructions: - 'Read and send email over IMAP/SMTP for 163, QQ, and exmail accounts. Use list_folders, list_messages, search_messages, get_message, send_message, reply_message, mark_read, move_message. Credentials are supplied via the host environment and never appear in tool output.', - }, - ); - registerMailTools(server, { imap, smtp, accountEmail: config.imap.user }); - const transport = new StdioServerTransport(); - await server.connect(transport); - - let shuttingDown = false; - const shutdown = (exitProcess = false): void => { - if (shuttingDown) return; - shuttingDown = true; - void (async () => { - try { - await server.close(); - } catch { - // best-effort during shutdown - } - await Promise.allSettled([imap.close(), smtp.close()]); - process.exitCode = 0; - if (exitProcess) process.exit(0); - })(); - }; - - process.on('SIGINT', () => shutdown(true)); - process.on('SIGTERM', () => shutdown(true)); - // The daemon owns stdin. If it dies or closes the MCP session, do not leave this plugin process - // running with open IMAP/SMTP sockets. - transport.onclose = () => shutdown(true); -} - -main().catch((error) => { - process.stderr.write( - `[linkcode-mail-mcp] fatal: ${extractErrorMessage(error) ?? 'unknown error'}\n`, - ); - process.exit(1); -}); diff --git a/packages/integrations/mail-mcp/src/smtp.ts b/packages/integrations/mail-mcp/src/smtp.ts deleted file mode 100644 index d7987ebb7..000000000 --- a/packages/integrations/mail-mcp/src/smtp.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createTransport } from 'nodemailer'; -import type { MailConfig } from './types'; - -export interface SendOptions { - readonly to: string; - readonly subject: string; - readonly body: string; - readonly cc?: string; - readonly bcc?: string; - readonly html?: string; - readonly replyTo?: string; - readonly inReplyTo?: string; - readonly references?: string | string[]; -} - -export interface SendResult { - readonly messageId?: string; - readonly response: string; -} - -export interface SmtpTransporter { - sendMail(options: Record): Promise<{ messageId?: string; response?: string }>; - close(): void; -} - -export type SmtpTransporterFactory = (config: MailConfig) => SmtpTransporter; - -export interface MailSmtpClient { - send(opts: SendOptions): Promise; - close(): Promise; -} - -export class MailSmtp implements MailSmtpClient { - private transporter: SmtpTransporter | undefined; - - constructor( - private readonly config: MailConfig, - private readonly transporterFactory?: SmtpTransporterFactory, - ) {} - - async send(opts: SendOptions): Promise { - const transporter = this.ensureTransporter(); - const info = await transporter.sendMail({ - from: this.config.smtpFrom, - to: opts.to, - cc: opts.cc, - bcc: opts.bcc, - subject: opts.subject, - text: opts.body, - html: opts.html, - replyTo: opts.replyTo, - inReplyTo: opts.inReplyTo, - references: opts.references, - }); - return { messageId: info.messageId, response: info.response ?? '' }; - } - - close(): Promise { - const transporter = this.transporter; - if (!transporter) return Promise.resolve(); - this.transporter = undefined; - try { - transporter.close(); - } catch { - // best-effort; the MCP server is shutting down regardless - } - return Promise.resolve(); - } - - private ensureTransporter(): SmtpTransporter { - if (this.transporter) return this.transporter; - this.transporter = this.transporterFactory - ? this.transporterFactory(this.config) - : createSmtpTransporter(this.config); - return this.transporter; - } -} - -function createSmtpTransporter(config: MailConfig): SmtpTransporter { - return createTransport({ - host: config.smtp.host, - port: config.smtp.port, - secure: config.smtp.secure, - // On the non-secure port, require STARTTLS instead of silently falling back to plaintext. - requireTLS: !config.smtp.secure, - auth: { user: config.smtp.user, pass: config.smtp.password }, - }); -} diff --git a/packages/integrations/mail-mcp/src/tools.ts b/packages/integrations/mail-mcp/src/tools.ts deleted file mode 100644 index fa2df1046..000000000 --- a/packages/integrations/mail-mcp/src/tools.ts +++ /dev/null @@ -1,225 +0,0 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { clamp } from 'foxts/clamp'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -import { z } from 'zod'; -import type { Address, FullMessage, MailImapClient, MessageSummary } from './imap'; -import { formatAddresses } from './imap'; -import type { MailSmtpClient, SendResult } from './smtp'; - -const DEFAULT_LIST_LIMIT = 20; -const MAX_LIST_LIMIT = 100; - -// The SDK's CallToolResult is inferred from a passthrough zod schema, so it carries a -// `[x: string]: unknown` index signature; mirror it so the helpers stay assignable. -interface TextContent { - [x: string]: unknown; - type: 'text'; - text: string; -} -interface ToolResult { - [x: string]: unknown; - content: TextContent[]; - isError?: boolean; -} - -function json(data: unknown): ToolResult { - return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; -} - -function fail(error: unknown): ToolResult { - return { - content: [{ type: 'text', text: extractErrorMessage(error) ?? 'unknown error' }], - isError: true, - }; -} - -async function run(fn: () => Promise): Promise { - try { - return json(await fn()); - } catch (error) { - return fail(error); - } -} - -/** Drop duplicate recipients by address (case-insensitive), preserving first-seen order. */ -function dedupeAddresses(addresses: readonly Address[]): Address[] { - const seen = new Set(); - const out: Address[] = []; - for (const a of addresses) { - const key = (a.address ?? '').toLowerCase(); - if (!key || seen.has(key)) continue; - seen.add(key); - out.push(a); - } - return out; -} - -export interface MailToolDeps { - readonly imap: MailImapClient; - readonly smtp: MailSmtpClient; - /** Authenticated account address, used to drop the user from reply-all recipients. */ - readonly accountEmail: string; -} - -export function registerMailTools(server: McpServer, deps: MailToolDeps): void { - const { imap, smtp, accountEmail } = deps; - - server.registerTool( - 'list_folders', - { - description: - 'List all IMAP folders (mailboxes) for the account, with message and unseen counts.', - }, - async () => run(() => imap.listFolders()), - ); - - server.registerTool( - 'list_messages', - { - description: - 'List the most recent messages in a folder. Returns summaries (uid, subject, from, to, date, flags, size).', - inputSchema: { - folder: z.string().min(1).describe('Folder path, e.g. INBOX or Sent'), - limit: z - .number() - .int() - .positive() - .max(MAX_LIST_LIMIT) - .optional() - .describe(`Default ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT}`), - }, - }, - async ({ folder, limit }) => - run(() => - imap.listMessages(folder, clamp(limit ?? DEFAULT_LIST_LIMIT, 1, MAX_LIST_LIMIT)), - ), - ); - - server.registerTool( - 'search_messages', - { - description: - 'Search messages in a folder by subject/from/to/body/seen/date. Returns matching message summaries.', - inputSchema: { - folder: z.string().min(1), - subject: z.string().optional(), - from: z.string().optional().describe('Sender name or address fragment'), - to: z.string().optional(), - body: z.string().optional().describe('Body text fragment'), - seen: z.boolean().optional().describe('Filter by read state; omit for either'), - since: z.string().optional().describe('ISO date; messages received after'), - before: z.string().optional().describe('ISO date; messages received before'), - limit: z.number().int().positive().max(MAX_LIST_LIMIT).optional(), - }, - }, - async (args) => { - const { folder, limit, seen, ...rest } = args; - const query: Record = { ...rest }; - if (seen !== undefined) query.seen = seen; - return run(() => - imap.searchMessages(folder, query, clamp(limit ?? DEFAULT_LIST_LIMIT, 1, MAX_LIST_LIMIT)), - ); - }, - ); - - server.registerTool( - 'get_message', - { - description: - 'Fetch a single message by uid: headers, decoded text body (truncated), and attachment metadata. Attachments are not downloaded.', - inputSchema: { - folder: z.string().min(1), - uid: z.number().int().positive().describe('Message UID from list_messages/search_messages'), - }, - }, - async ({ folder, uid }) => run(() => imap.getMessage(folder, uid)), - ); - - server.registerTool( - 'send_message', - { - description: 'Send a new email over SMTP. `to`/`cc`/`bcc` accept comma-separated addresses.', - inputSchema: { - to: z.string().min(1), - subject: z.string().min(1), - body: z.string().min(1).describe('Plain-text body'), - cc: z.string().optional(), - bcc: z.string().optional(), - html: z.string().optional().describe('Optional HTML body'), - replyTo: z.string().optional(), - }, - }, - async (args) => run(() => smtp.send(args)), - ); - - server.registerTool( - 'reply_message', - { - description: - 'Reply to a message by uid: fetches the original, sets In-Reply-To/References and `Re:` subject, sends via SMTP.', - inputSchema: { - folder: z.string().min(1), - uid: z.number().int().positive(), - body: z.string().min(1), - html: z.string().optional(), - replyAll: z - .boolean() - .optional() - .describe('Reply to original To+Cc instead of just From (default false)'), - }, - }, - async ({ folder, uid, body, html, replyAll }) => - run(async () => { - const origin = await imap.getReplyOrigin(folder, uid); - const recipients = replyAll ? [...origin.from, ...origin.to, ...origin.cc] : origin.from; - const self = accountEmail.toLowerCase(); - const filtered = recipients.filter((a) => (a.address ?? '').toLowerCase() !== self); - const to = formatAddresses(dedupeAddresses(filtered)); - if (!to) throw new Error('Original message has no replyable From address'); - const subject = origin.subject?.toLowerCase().startsWith('re:') - ? origin.subject - : `Re: ${origin.subject ?? ''}`; - const references = [...origin.references]; - if (origin.messageId && !references.includes(origin.messageId)) { - references.push(origin.messageId); - } - return smtp.send({ - to, - subject, - body, - html, - inReplyTo: origin.messageId, - references, - }); - }), - ); - - server.registerTool( - 'mark_read', - { - description: String.raw`Set or clear the \Seen flag on a message by uid.`, - inputSchema: { - folder: z.string().min(1), - uid: z.number().int().positive(), - read: z - .boolean() - .optional() - .describe('true (default) marks as read; false marks as unread'), - }, - }, - async ({ folder, uid, read }) => run(() => imap.markRead(folder, uid, read ?? true)), - ); - - server.registerTool( - 'move_message', - { - description: 'Move a message by uid from one folder to another.', - inputSchema: { - folder: z.string().min(1).describe('Source folder'), - uid: z.number().int().positive(), - destination: z.string().min(1).describe('Destination folder path'), - }, - }, - async ({ folder, uid, destination }) => run(() => imap.moveMessage(folder, uid, destination)), - ); -} diff --git a/packages/integrations/mail-mcp/src/types.ts b/packages/integrations/mail-mcp/src/types.ts deleted file mode 100644 index eb1eb37d3..000000000 --- a/packages/integrations/mail-mcp/src/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface ImapEndpointConfig { - readonly host: string; - readonly port: number; - readonly secure: boolean; - readonly user: string; - readonly password: string; -} - -export interface SmtpEndpointConfig { - readonly host: string; - readonly port: number; - readonly secure: boolean; - readonly user: string; - readonly password: string; -} - -export interface MailConfig { - readonly imap: ImapEndpointConfig; - readonly smtp: SmtpEndpointConfig; - readonly smtpFrom: string; - readonly maxBodyChars: number; -} - -export type MailPreset = '163' | 'qq' | 'exmail'; diff --git a/packages/integrations/mail-mcp/tsconfig.json b/packages/integrations/mail-mcp/tsconfig.json deleted file mode 100644 index 855b324df..000000000 --- a/packages/integrations/mail-mcp/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "include": ["src", "tsup.config.ts"] -} diff --git a/packages/integrations/mail-mcp/tsup.config.ts b/packages/integrations/mail-mcp/tsup.config.ts deleted file mode 100644 index f2ff81faf..000000000 --- a/packages/integrations/mail-mcp/tsup.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { defineConfig } from 'tsup'; - -const { version } = JSON.parse(readFileSync(new URL('package.json', import.meta.url), 'utf8')) as { - version: string; -}; - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['esm'], - target: 'node24', - clean: true, - splitting: false, - sourcemap: true, - platform: 'node', - // Installed plugin copies run without node_modules: bundle every dependency into one file. - noExternal: [/.+/], - define: { __MAIL_MCP_VERSION__: JSON.stringify(version) }, - banner: { - // CJS deps (imapflow) keep their require() calls after bundling; give them a real require. - js: "#!/usr/bin/env node\nimport { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);", - }, -}); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index fdf327a86..33f2c205c 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -825,6 +825,7 @@ export const en = { notInstalled: 'Not installed', cancel: 'Cancel', install: 'Install', + update: 'Update', uninstall: 'Uninstall', uninstallTitle: 'Uninstall “{title}”?', uninstallHint: @@ -881,9 +882,12 @@ export const en = { installedTitle: 'Installed', installedEmpty: 'No LinkCode plugins installed yet — pick one from a marketplace below.', noMarketplaces: 'No marketplaces configured yet.', + allMarketplacesDisabled: 'All configured marketplaces are disabled.', catalogEmpty: 'This marketplace has no plugins yet.', refresh: 'Refresh catalog', installed: 'Installed', + installedNewer: 'Newer version installed', + switchVersion: 'Switch to this version', configure: 'Configure', settingsTitle: '“{title}” settings', form: { @@ -926,11 +930,6 @@ export const en = { restoreSecret: 'Keep key', save: 'Save', cancel: 'Cancel', - mailTemplate: 'Email template', - mailTemplateNone: 'None — start from scratch', - template163: '163 mailbox', - templateQq: 'QQ mailbox', - templateExmail: 'Tencent Exmail', }, }, }, diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 2e026e44f..a3ca0ab8a 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -810,6 +810,7 @@ export const zhCN = { notInstalled: '未安装', cancel: '取消', install: '安装', + update: '更新', uninstall: '卸载', uninstallTitle: '卸载「{title}」?', uninstallHint: '将删除本机上的插件文件与配置;条目会回到「市场」,重新安装即可。', @@ -865,9 +866,12 @@ export const zhCN = { installedTitle: '已安装', installedEmpty: '还没有安装任何 LinkCode 插件;从下方市场目录挑一个。', noMarketplaces: '还没有配置任何插件市场。', + allMarketplacesDisabled: '已配置的插件市场都已停用。', catalogEmpty: '这个市场暂时没有可用的插件。', refresh: '刷新目录', installed: '已安装', + installedNewer: '已安装更新的版本', + switchVersion: '切换到此版本', configure: '设置', settingsTitle: '「{title}」设置', form: { @@ -909,11 +913,6 @@ export const zhCN = { restoreSecret: '保留此键', save: '保存', cancel: '取消', - mailTemplate: '邮箱模板', - mailTemplateNone: '不使用 · 从零开始', - template163: '163 邮箱', - templateQq: 'QQ 邮箱', - templateExmail: '腾讯企业邮箱', }, }, }, diff --git a/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx b/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx index 092620ec1..de0efa78c 100644 --- a/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx +++ b/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx @@ -201,6 +201,13 @@ function CatalogCard({ onInstall: (card: LinkCodeCatalogCardView) => void; }): React.ReactNode { const t = useTranslations('settings.plugins'); + // An `installedNewer` card must still offer its action: the catalog's pick is the stable release + // when a prerelease is installed, and hiding the button there leaves no way back off the beta. + const actionLabel = card.installedNewer + ? t('linkcode.switchVersion') + : card.updateAvailable + ? t('update') + : t('install'); return (
@@ -208,6 +215,9 @@ function CatalogCard({ {card.title} v{card.version} {card.installed ? {t('linkcode.installed')} : null} + {card.installedNewer ? ( + {t('linkcode.installedNewer')} + ) : null}
{card.description === undefined ? null : (

{card.description}

@@ -223,7 +233,7 @@ function CatalogCard({ onClick={() => onInstall(card)} > - {t('install')} + {actionLabel} )}
diff --git a/packages/presentation/ui/src/shell/plugins/types.ts b/packages/presentation/ui/src/shell/plugins/types.ts index b0697124c..fe2cdb8d9 100644 --- a/packages/presentation/ui/src/shell/plugins/types.ts +++ b/packages/presentation/ui/src/shell/plugins/types.ts @@ -83,14 +83,20 @@ export interface CustomMcpServerRow { /** One entry of a LinkCode marketplace catalog (the daemon-refreshed index). */ export interface LinkCodeCatalogCardView { - /** `${marketplaceId}:${pluginId}` — stable across refreshes. */ + /** `${marketplaceId}:${pluginId}` — one card per plugin (its latest release), stable across refreshes. */ key: string; marketplaceId: string; pluginId: string; version: string; title: string; description: string | undefined; + /** This exact version is installed. */ installed: boolean; + /** An older version of this plugin is installed — the install action upgrades it. */ + updateAvailable: boolean; + /** A newer version than the catalog's pick is installed — usually a prerelease, since the catalog + * prefers the stable release. The action stays available and switches to the carded version. */ + installedNewer: boolean; /** Precomputed lowercase haystack for the client-side filter. */ searchText: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2b77b622..e989c93d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1159,40 +1159,6 @@ importers: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' - packages/integrations/mail-mcp: - dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) - foxts: - specifier: ^5.8.0 - version: 5.8.1 - imapflow: - specifier: ^1.7.2 - version: 1.7.2 - nodemailer: - specifier: ^9.0.5 - version: 9.0.5 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 26.1.1 - '@types/nodemailer': - specifier: ^8.0.1 - version: 8.0.1 - tsup: - specifier: 'catalog:' - version: 8.5.1(@typescript/typescript6@6.0.2)(jiti@2.7.0)(postcss@8.5.24)(tsx@4.23.1)(yaml@2.9.0) - typescript: - specifier: 'catalog:' - version: '@typescript/typescript6@6.0.2' - vitest: - specifier: 'catalog:' - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0)) - packages/presentation/i18n: devDependencies: typescript: @@ -5470,9 +5436,6 @@ packages: '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - '@types/nodemailer@8.0.1': - resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5878,9 +5841,6 @@ packages: engines: {node: '>=14.6'} deprecated: this version has critical issues, please update to the latest version - '@zone-eu/mailsplit@5.4.15': - resolution: {integrity: sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==} - abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -7135,10 +7095,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding-japanese@2.2.0: - resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} - engines: {node: '>=8.10.0'} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -8296,9 +8252,6 @@ packages: engines: {node: '>=16.x'} hasBin: true - imapflow@1.7.2: - resolution: {integrity: sha512-1pWZgWQ/M2Q7kPSW7Sp7QDn+ZPEqs/9IymYh34RY+3J7d3vfPayhSmRAl0tB7weblGU0SR/t7eYES3TW6vSiOQ==} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -8624,15 +8577,6 @@ packages: engines: {node: '>=16'} hasBin: true - libbase64@1.3.0: - resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} - - libmime@5.4.2: - resolution: {integrity: sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==} - - libqp@2.1.1: - resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} - lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} @@ -9442,10 +9386,6 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} - nodemailer@9.0.5: - resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} - engines: {node: '>=6.0.0'} - nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -15824,10 +15764,6 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/nodemailer@8.0.1': - dependencies: - '@types/node': 26.1.1 - '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -16155,12 +16091,6 @@ snapshots: '@xmldom/xmldom@0.9.10': {} - '@zone-eu/mailsplit@5.4.15': - dependencies: - libbase64: 1.3.0 - libmime: 5.4.2 - libqp: 2.1.1 - abbrev@4.0.0: {} abort-controller@3.0.0: @@ -17448,8 +17378,6 @@ snapshots: encodeurl@2.0.0: {} - encoding-japanese@2.2.0: {} - end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -18985,17 +18913,6 @@ snapshots: dependencies: queue: 6.0.2 - imapflow@1.7.2: - dependencies: - '@zone-eu/mailsplit': 5.4.15 - encoding-japanese: 2.2.0 - iconv-lite: 0.7.3 - libbase64: 1.3.0 - libmime: 5.4.2 - libqp: 2.1.1 - pino: 10.3.1 - socks: 2.8.9 - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -19303,17 +19220,6 @@ snapshots: dependencies: isomorphic.js: 0.2.5 - libbase64@1.3.0: {} - - libmime@5.4.2: - dependencies: - encoding-japanese: 2.2.0 - iconv-lite: 0.7.3 - libbase64: 1.3.0 - libqp: 2.1.1 - - libqp@2.1.1: {} - lighthouse-logger@1.4.2: dependencies: debug: 2.6.9 @@ -20371,8 +20277,6 @@ snapshots: node-releases@2.0.50: {} - nodemailer@9.0.5: {} - nopt@9.0.0: dependencies: abbrev: 4.0.0 diff --git a/scripts/dev-marketplace.mts b/scripts/dev-marketplace.mts index 5931dcda4..ef4189f90 100644 --- a/scripts/dev-marketplace.mts +++ b/scripts/dev-marketplace.mts @@ -1,8 +1,9 @@ /** - * Dev marketplace for LinkCode plugin debugging: packs @linkcode/mail-mcp into a tgz with a - * manifest, writes an index.json (schema: LinkCodeMarketplaceIndexSchema), and serves the - * directory over loopback HTTP with ETag support so the daemon's conditional refresh (304) path - * is exercised too. + * Dev marketplace for LinkCode plugin debugging: packs a synthetic `linkcode/echo` plugin into a + * tgz with a manifest, writes an index.json (schema: LinkCodeMarketplaceIndexSchema), and serves + * the directory over loopback HTTP with ETag support so the daemon's conditional refresh (304) + * path is exercised too. The echo plugin is fully self-contained (its stdio MCP server payload is + * inlined below) — real plugins ship via the marketplace index, never via this fixture. * * Usage: * node scripts/dev-marketplace.mts # build fixture + serve on 127.0.0.1:18741 @@ -18,24 +19,15 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; import { extname, join } from 'node:path'; import process from 'node:process'; const repoRoot = new URL('..', import.meta.url).pathname; const outDir = join(repoRoot, 'node_modules', '.cache', 'dev-marketplace'); -const mailDist = join(repoRoot, 'packages', 'integrations', 'mail-mcp', 'dist'); -const PLUGIN_ID = 'linkcode/mail'; +const PLUGIN_ID = 'linkcode/echo'; const VERSION = '0.1.0'; const PORT = Number(process.env.DEV_MARKETPLACE_PORT ?? 18741); @@ -45,62 +37,124 @@ const manifest = { manifestVersion: 1, id: PLUGIN_ID, version: VERSION, - displayName: '邮箱(163 / QQ)', - description: '通过 IMAP 收信、SMTP 发信,支持 163、QQ 和腾讯企业邮箱(授权码登录)。', - keywords: ['mail', 'imap', 'smtp', '163', 'qq'], + displayName: 'Echo(市场调试)', + description: '合成调试插件:回显文本,覆盖 string / password(secret) / enum 三种设置形态。', + keywords: ['echo', 'debug', 'marketplace'], components: [ { kind: 'mcp-server', - name: 'mail', - description: 'IMAP/SMTP mail tools (list/search/read/send/reply/mark/move)', + name: 'echo', + description: 'Echo tool (returns the input text, optionally uppercased)', command: 'node', entry: 'dist/index.js', env: { - MAIL_USER: 'account', - MAIL_PASSWORD: 'authcode', - MAIL_PRESET: 'preset', + ECHO_GREETING: 'greeting', + ECHO_TOKEN: 'token', + ECHO_MODE: 'mode', }, }, ], settings: { - account: { + greeting: { type: 'string', - label: '邮箱账号', - description: '完整邮箱地址,例如 you@163.com', + label: '问候语', + description: '回显内容的前缀', required: true, }, - authcode: { + token: { type: 'password', - label: '授权码', - description: '邮箱网页端生成的客户端授权码,不是登录密码', + label: '令牌', + description: '仅用于验证 secret 字段走 vault 而不落 config.json', secret: true, required: true, }, - preset: { + mode: { type: 'enum', - label: '服务商', - description: '会根据 @qq.com / @163.com 自动识别;腾讯企业邮箱请手动选择 exmail。', - enum: ['163', 'qq', 'exmail'], - default: '163', + label: '模式', + description: 'shout 会把回显内容转成大写', + enum: ['plain', 'shout'], + default: 'plain', }, }, assets: [], }; -function buildFixture(): void { - if (!existsSync(join(mailDist, 'index.js'))) { - console.error( - 'packages/integrations/mail-mcp/dist is missing — run: pnpm -F @linkcode/mail-mcp build', - ); - process.exit(1); +// Minimal newline-delimited-JSON stdio MCP server with zero dependencies. +const MCP_PAYLOAD = `#!/usr/bin/env node +'use strict'; +const readline = require('node:readline'); + +const GREETING = process.env.ECHO_GREETING || 'echo'; +const MODE = process.env.ECHO_MODE || 'plain'; + +const ECHO_TOOL = { + name: 'echo', + description: 'Echo the input text back, prefixed with the configured greeting.', + inputSchema: { + type: 'object', + properties: { text: { type: 'string', description: 'Text to echo' } }, + required: ['text'], + }, +}; + +function reply(id, result) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\\n'); +} +function replyError(id, code, message) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\\n'); +} + +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + if (!line.trim()) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + if (msg.id === undefined || msg.id === null) return; // notification + switch (msg.method) { + case 'initialize': + reply(msg.id, { + protocolVersion: (msg.params && msg.params.protocolVersion) || '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'echo', version: '${VERSION}' }, + }); + break; + case 'ping': + reply(msg.id, {}); + break; + case 'tools/list': + reply(msg.id, { tools: [ECHO_TOOL] }); + break; + case 'tools/call': { + const params = msg.params || {}; + if (params.name !== 'echo') { + replyError(msg.id, -32602, 'unknown tool: ' + params.name); + break; + } + const text = String((params.arguments && params.arguments.text) ?? ''); + const body = GREETING + ': ' + text; + reply(msg.id, { + content: [{ type: 'text', text: MODE === 'shout' ? body.toUpperCase() : body }], + }); + break; + } + default: + replyError(msg.id, -32601, 'method not found: ' + msg.method); } +}); +`; + +function buildFixture(): void { rmSync(outDir, { recursive: true, force: true }); const staging = join(outDir, 'staging', 'package'); mkdirSync(join(staging, 'dist'), { recursive: true }); writeFileSync(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - copyFileSync(join(mailDist, 'index.js'), join(staging, 'dist', 'index.js')); + writeFileSync(join(staging, 'dist', 'index.js'), MCP_PAYLOAD); - const tgzName = `mail-${VERSION}.tgz`; + const tgzName = `echo-${VERSION}.tgz`; const tgzPath = join(outDir, tgzName); // The installer extracts with strip:1, so the archive must wrap everything in one top-level dir. execFileSync('tar', ['-czf', tgzPath, '-C', join(outDir, 'staging'), 'package']); diff --git a/tsconfig.json b/tsconfig.json index d8a318d9d..e8b7e410c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,7 +32,6 @@ { "path": "packages/host/engine/tests" }, { "path": "packages/host/sim" }, { "path": "packages/integrations/im-render" }, - { "path": "packages/integrations/mail-mcp" }, { "path": "packages/presentation/i18n" }, { "path": "packages/presentation/ui" }, { "path": "packages/system-plane/ipc" } From a02615f206c615dfe0246859f98bf7431d8429aa Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Tue, 25 Aug 2026 23:27:11 +0800 Subject: [PATCH 05/19] fix(plugin-marketplace): harden recovery and legacy refresh --- .../daemon/src/__tests__/plugin-store.test.ts | 186 +++++++++++++++- apps/daemon/src/plugin-store/paths.ts | 18 +- apps/daemon/src/plugin-store/store.ts | 207 +++++++++++++++++- .../core/src/__tests__/plugin-market.test.ts | 30 ++- packages/client/core/src/client.ts | 17 +- .../__tests__/linkcode-config-dialog.test.tsx | 7 +- .../plugins/__tests__/linkcode-config.test.ts | 43 ++-- .../plugins/linkcode-config-dialog.tsx | 12 +- .../src/settings/plugins/linkcode-config.ts | 28 ++- .../src/settings/plugins/linkcode-tab.tsx | 16 +- .../schema/src/model/__tests__/plugin.test.ts | 9 + .../schema/src/model/linkcode-plugin.ts | 10 +- 12 files changed, 512 insertions(+), 71 deletions(-) diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts index 38607292e..a3b064229 100644 --- a/apps/daemon/src/__tests__/plugin-store.test.ts +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -1,4 +1,11 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { @@ -15,10 +22,35 @@ import { createInMemoryVault } from './fixtures/in-memory-vault'; const mocks = vi.hoisted(() => ({ downloadVerified: vi.fn(), tarExtract: vi.fn(), + removeFailurePrefix: undefined as string | undefined, + renameFailureSource: undefined as string | undefined, })); vi.mock('@linkcode/assets', () => ({ downloadVerified: mocks.downloadVerified })); vi.mock('tar', () => ({ extract: mocks.tarExtract })); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync(source: import('node:fs').PathLike, destination: import('node:fs').PathLike): void { + if (source === mocks.renameFailureSource) { + throw Object.assign(new Error('injected rename failure'), { code: 'EACCES' }); + } + actual.renameSync(source, destination); + }, + rmSync(...args: Parameters): void { + const [path] = args; + if ( + typeof path === 'string' && + mocks.removeFailurePrefix !== undefined && + path.startsWith(mocks.removeFailurePrefix) + ) { + throw Object.assign(new Error('injected remove failure'), { code: 'EACCES' }); + } + actual.rmSync(...args); + }, + }; +}); let savedHome: string | undefined; @@ -28,6 +60,8 @@ beforeEach(() => { process.env.LINKCODE_CHANNEL = 'release'; mocks.downloadVerified.mockReset().mockResolvedValue(undefined); mocks.tarExtract.mockReset(); + mocks.removeFailurePrefix = undefined; + mocks.renameFailureSource = undefined; }); afterEach(() => { @@ -159,14 +193,158 @@ describe('DaemonLinkCodePluginStore', () => { ]); }); + it('keeps the live package and registry intact when a reinstall fails before publishing', async () => { + const live = record('0.2.0'); + writePackage(live, manifest('0.2.0', 'live-skill')); + writeRegistry([live]); + // A staged package that fails the id/version check: the common failure shape (download, extract, + // or manifest mismatch) must never touch the live package, and must leave no staging sibling. + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(manifest('9.9.9', 'staged-skill'))); + }); + const release = { + manifest: manifest('0.2.0', 'index-skill'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + await expect(store.install(release, 'linkcode-official')).rejects.toThrow(); + + expect(existsSync(live.path)).toBe(true); + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('live-skill'); + expect(JSON.parse(readFileSync(pluginRegistryPath(), 'utf8'))).toMatchObject([ + { id: 'arcbox/latex', version: '0.2.0' }, + ]); + const siblings = readdirSync(join(live.path, '..')); + expect(siblings.filter((name) => name.startsWith('.tmp-'))).toEqual([]); + }); + + it('commits the install when retired-package cleanup fails', async () => { + const live = record('0.2.0'); + writePackage(live, manifest('0.2.0', 'live-skill')); + writeRegistry([live]); + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(manifest('0.2.0', 'new-skill'))); + }); + mocks.removeFailurePrefix = join(live.path, '..', '.tmp-retired-'); + const release = { + manifest: manifest('0.2.0'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + await expect(store.install(release, 'linkcode-official')).resolves.toMatchObject({ + installed: { version: '0.2.0' }, + }); + + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('new-skill'); + expect( + readdirSync(join(live.path, '..')).some((name) => name.startsWith('.tmp-retired-')), + ).toBe(true); + }); + + it('sweeps orphaned staging directories at construction', () => { + const live = record('0.1.0'); + writePackage(live, manifest('0.1.0')); + writeRegistry([live]); + const orphan = join(live.path, '..', '.tmp-999-0.1.0-abandoned'); + mkdirSync(orphan, { recursive: true }); + + // Construction runs the sweep; the store handle itself is unused here. + expect(new DaemonLinkCodePluginStore(createInMemoryVault())).toBeDefined(); + + expect(existsSync(orphan)).toBe(false); + expect(existsSync(live.path)).toBe(true); + }); + + it('promotes a retired package back when a hard kill left it as the only copy', () => { + // A hard kill between retire and publish leaves this as the registry's only live copy. + const live = record('0.1.0'); + writeRegistry([live]); + const retired = join(live.path, '..', '.tmp-retired-999-abandoned'); + mkdirSync(retired, { recursive: true }); + writeFileSync(join(retired, 'manifest.json'), JSON.stringify(manifest('0.1.0', 'live-skill'))); + expect(existsSync(live.path)).toBe(false); + + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + expect(existsSync(retired)).toBe(false); + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('live-skill'); + }); + + it('restores a retired package only to its exact recorded version', () => { + const legacy = record('0.1.0'); + const live = record('0.2.0'); + writeRegistry([legacy, live]); + const retired = join(live.path, '..', '.tmp-retired-999-versioned'); + mkdirSync(retired, { recursive: true }); + writeFileSync(join(retired, 'manifest.json'), JSON.stringify(manifest('0.2.0', 'live-skill'))); + + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + expect(existsSync(legacy.path)).toBe(false); + expect(existsSync(live.path)).toBe(true); + expect(store.get('arcbox/latex')?.installed.version).toBe('0.2.0'); + }); + + it('keeps a retired package when restoring it fails', () => { + const live = record('0.1.0'); + writeRegistry([live]); + const retired = join(live.path, '..', '.tmp-retired-999-unrestored'); + mkdirSync(retired, { recursive: true }); + writeFileSync(join(retired, 'manifest.json'), JSON.stringify(manifest('0.1.0', 'live-skill'))); + mocks.renameFailureSource = retired; + + expect(new DaemonLinkCodePluginStore(createInMemoryVault())).toBeDefined(); + + expect(existsSync(retired)).toBe(true); + expect(existsSync(live.path)).toBe(false); + }); + + it('keeps a retired package when its target is occupied by a different package', () => { + const live = record('0.1.0'); + writePackage(live, manifest('9.9.9', 'unexpected-skill')); + writeRegistry([live]); + const retired = join(live.path, '..', '.tmp-retired-999-conflict'); + mkdirSync(retired, { recursive: true }); + writeFileSync(join(retired, 'manifest.json'), JSON.stringify(manifest('0.1.0', 'live-skill'))); + + expect(new DaemonLinkCodePluginStore(createInMemoryVault())).toBeDefined(); + + expect(existsSync(retired)).toBe(true); + expect(readFileSync(join(live.path, 'manifest.json'), 'utf8')).toContain('9.9.9'); + }); + + it('deletes a retired sibling once its version dir is already back in place', () => { + // Once the exact published package is present, the retired copy is redundant. + const live = record('0.1.0'); + writePackage(live, manifest('0.1.0', 'published-skill')); + writeRegistry([live]); + const retired = join(live.path, '..', '.tmp-retired-999-stale'); + mkdirSync(retired, { recursive: true }); + writeFileSync(join(retired, 'manifest.json'), JSON.stringify(manifest('0.1.0', 'old-skill'))); + + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + expect(existsSync(retired)).toBe(false); + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('published-skill'); + }); + it('uninstall prunes only its own secrets, even beside a dotted sibling id', async () => { const installed = record('0.1.0'); writePackage(installed, settingsManifest('0.1.0')); const neighbour: InstalledLinkCodePlugin = { ...record('0.3.0'), - // Dots are legal inside id segments, so `arcbox/latex.pro` is a real neighbour whose keys - // would match a naive `arcbox/latex.` prefix; a corrupt manifest must not turn them into - // prunable orphans either. + // Dots are legal in ids, so this neighbour matches a naive `arcbox/latex.` secret prefix. + // Its corrupt manifest must not make those secrets look orphaned either. id: 'arcbox/latex.pro', path: pluginPackageDir('arcbox/latex.pro', '0.3.0'), }; diff --git a/apps/daemon/src/plugin-store/paths.ts b/apps/daemon/src/plugin-store/paths.ts index 8878a7c70..16cfc947d 100644 --- a/apps/daemon/src/plugin-store/paths.ts +++ b/apps/daemon/src/plugin-store/paths.ts @@ -29,13 +29,19 @@ export function pluginPackageDir(pluginId: string, version: string): string { return join(pluginsRoot(), ...safe, version); } -/** Unique staging dir beside the package dir, so concurrent installs publish through one same-volume - * `rename` without sharing a partially extracted archive. */ +/** Staging and retired-package siblings share this prefix so a boot sweep can recognize both. */ +export const PLUGIN_STAGING_PREFIX = '.tmp-'; + +/** Retired packages use `${PLUGIN_STAGING_PREFIX}${PLUGIN_RETIRED_INFIX}...`; a boot sweep may need + * to restore them when a hard kill interrupts publishing. */ +export const PLUGIN_RETIRED_INFIX = 'retired-'; + +/** Allocate staging beside the target for same-volume rename; create only the parent so failed + * installs do not leave an empty version directory. */ export function makePluginTmpDir(pluginId: string, version: string): string { - const dir = pluginPackageDir(pluginId, version); - const parent = join(dir, '..'); - mkdirSync(dir, { recursive: true }); - return join(parent, `.tmp-${process.pid}-${version}-${randomUUID()}`); + const parent = join(pluginPackageDir(pluginId, version), '..'); + mkdirSync(parent, { recursive: true }); + return join(parent, `${PLUGIN_STAGING_PREFIX}${process.pid}-${version}-${randomUUID()}`); } /** Resolve product channel for callers that must not reach into the paths module's side effects. */ diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 012384fb4..699fcb17d 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -1,10 +1,13 @@ import { randomUUID } from 'node:crypto'; +import type { Dirent } from 'node:fs'; import { chmodSync, closeSync, fsyncSync, + lstatSync, mkdirSync, openSync, + readdirSync, readFileSync, renameSync, rmSync, @@ -36,14 +39,23 @@ import { extract as tarExtract } from 'tar'; import { loadPluginConfigValues, pluginSecretStore, savePluginConfigValues } from '../config'; import { logger } from '../logger'; import type { SecretStore, SecretVault } from '../secrets'; -import { makePluginTmpDir, pluginPackageDir, pluginRegistryPath } from './paths'; +import { + makePluginTmpDir, + PLUGIN_RETIRED_INFIX, + PLUGIN_STAGING_PREFIX, + pluginPackageDir, + pluginRegistryPath, + pluginsRoot, +} from './paths'; /** Daemon-backed LinkCode plugin store: reads the install registry + on-disk manifests, splits * setting values between `config.json` (non-secret) and the vault `plugin` namespace (secret) per * each manifest's `secret` flag, and installs releases by downloading, SRI-verifying, extracting, * and atomically renaming into the package dir. */ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { - constructor(private readonly vault: SecretVault) {} + constructor(private readonly vault: SecretVault) { + sweepStagingDirs(); + } list(): InstalledLinkCodePluginEntry[] { const entries: InstalledLinkCodePluginEntry[] = []; @@ -191,6 +203,7 @@ async function installExclusive( const stagingDir = makePluginTmpDir(manifest.id, manifest.version); const tgzPath = join(stagingDir, 'package.tgz'); let installedManifest: LinkCodePluginManifest; + let retiredDir: string | undefined; mkdirSync(stagingDir, { recursive: true }); try { const downloadArtifact: ManagedAssetArtifact = { @@ -200,7 +213,9 @@ async function installExclusive( format: 'tgz', }; await downloadVerified(downloadArtifact, tgzPath, {}); - await tarExtract({ file: tgzPath, cwd: stagingDir, strip: 1 }); + // Without `strict`, node-tar only warns when it rejects unsafe members, so a partial archive + // whose manifest still matches could otherwise appear to install successfully. + await tarExtract({ file: tgzPath, cwd: stagingDir, strip: 1, strict: true }); const onDisk = readManifest(stagingDir); if (onDisk?.id !== manifest.id || onDisk.version !== manifest.version) { throw new Error( @@ -208,11 +223,14 @@ async function installExclusive( ); } installedManifest = onDisk; - rmSync(targetDir, { recursive: true, force: true }); mkdirSync(dirname(targetDir), { recursive: true }); + // Retire the live package before publishing so a failed rename can restore it without leaving + // the registry pointed at a missing directory. + retiredDir = retirePluginPackage(targetDir); renameSync(stagingDir, targetDir); } catch (error) { rmSync(stagingDir, { recursive: true, force: true }); + if (retiredDir !== undefined) restorePluginPackage(retiredDir, targetDir, manifest.id); throw new Error( `Failed to install plugin ${manifest.id}: ${extractErrorMessage(error) ?? 'unknown'}`, { cause: error }, @@ -227,6 +245,16 @@ async function installExclusive( path: targetDir, }; upsertRegistry(record); + if (retiredDir !== undefined) { + try { + rmSync(retiredDir, { recursive: true, force: true }); + } catch (error) { + logger.warn( + { error, pluginId: manifest.id, path: retiredDir, operation: 'plugin.install.gc-retired' }, + 'Failed to remove the retired plugin package after publishing', + ); + } + } // A plugin id has one active settings block and one wire identity, so keep exactly one installed // version. Remove stale package directories only after the new package and registry record exist. for (const previous of previousRecords) { @@ -247,6 +275,169 @@ async function installExclusive( return { installed: record, manifest: installedManifest }; } +/** Delete incomplete staging dirs, but retain retired backups unless their exact state is known. */ +function sweepStagingDirs(): void { + const root = pluginsRoot(); + const records = readRegistry(); + let swept = 0; + let restored = 0; + const walk = (dir: string, depth: number): void => { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const path = join(dir, entry.name); + if (entry.name.startsWith(PLUGIN_STAGING_PREFIX)) { + const isRetired = entry.name.startsWith(`${PLUGIN_STAGING_PREFIX}${PLUGIN_RETIRED_INFIX}`); + if (isRetired) { + const outcome = reconcileRetiredPackage(path, dir, records); + if (outcome === 'restored') restored += 1; + else if (outcome === 'swept') swept += 1; + continue; + } + try { + rmSync(path, { recursive: true, force: true }); + swept += 1; + } catch (error) { + logger.warn( + { error, path, operation: 'plugin.staging.sweep' }, + 'Failed to remove an orphaned plugin staging directory', + ); + } + continue; + } + // publisher/name/: staging siblings live at depth 2, so stop descending there. + if (depth < 2) walk(path, depth + 1); + } + }; + walk(root, 0); + if (swept > 0 || restored > 0) { + logger.info( + { swept, restored, operation: 'plugin.staging.sweep' }, + 'Reconciled orphaned plugin staging directories', + ); + } +} + +type RetiredPackageOutcome = 'retained' | 'restored' | 'swept'; + +function reconcileRetiredPackage( + path: string, + parentDir: string, + records: readonly InstalledLinkCodePlugin[], +): RetiredPackageOutcome { + const manifest = readManifest(path); + if (manifest === undefined) { + logger.warn( + { path, operation: 'plugin.staging.retain' }, + 'Keeping an unverifiable retired plugin package', + ); + return 'retained'; + } + + const target = pluginPackageDir(manifest.id, manifest.version); + const hasExactRecord = records.some( + (record) => + record.id === manifest.id && record.version === manifest.version && record.path === target, + ); + if (!hasExactRecord || dirname(target) !== parentDir) { + logger.warn( + { path, target, operation: 'plugin.staging.retain' }, + 'Keeping a retired plugin package without an exact registry record', + ); + return 'retained'; + } + + let targetStat: ReturnType | undefined; + try { + targetStat = lstatSync(target, { throwIfNoEntry: false }); + } catch (error) { + logger.warn( + { error, path, target, operation: 'plugin.staging.inspect' }, + 'Keeping a retired plugin package because its target could not be inspected', + ); + return 'retained'; + } + + if (targetStat === undefined) { + try { + renameSync(path, target); + return 'restored'; + } catch (error) { + logger.error( + { error, path, target, operation: 'plugin.staging.restore' }, + 'Failed to restore a retired plugin package; keeping the backup', + ); + return 'retained'; + } + } + + const published = targetStat.isDirectory() ? readManifest(target) : undefined; + if (published?.id !== manifest.id || published.version !== manifest.version) { + logger.warn( + { path, target, operation: 'plugin.staging.retain' }, + 'Keeping a retired plugin package because its target is occupied', + ); + return 'retained'; + } + + try { + rmSync(path, { recursive: true, force: true }); + return 'swept'; + } catch (error) { + logger.warn( + { error, path, operation: 'plugin.staging.sweep' }, + 'Failed to remove a retired plugin package after a completed publish', + ); + return 'retained'; + } +} + +/** Move a live package aside so the publish can be undone; undefined when there was nothing there. */ +function retirePluginPackage(targetDir: string): string | undefined { + const retired = join( + dirname(targetDir), + `${PLUGIN_STAGING_PREFIX}${PLUGIN_RETIRED_INFIX}${process.pid}-${randomUUID()}`, + ); + try { + renameSync(targetDir, retired); + return retired; + } catch (error) { + // ENOENT is the ordinary first-install case; anything else means the live package is still there + // and the caller's own rename will fail, which the catch turns into a clean install failure. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn( + { error, path: targetDir, operation: 'plugin.install.retire' }, + 'Could not move the existing plugin package aside', + ); + } + return undefined; + } +} + +/** Put a retired package back after a failed publish. Best-effort: the throw is already in flight. */ +function restorePluginPackage(retiredDir: string, targetDir: string, pluginId: string): void { + try { + if (lstatSync(targetDir, { throwIfNoEntry: false }) !== undefined) { + logger.error( + { pluginId, path: targetDir, retiredDir, operation: 'plugin.install.restore' }, + 'Cannot restore the previous plugin package because its target is occupied', + ); + return; + } + renameSync(retiredDir, targetDir); + } catch (error) { + logger.error( + { error, pluginId, path: targetDir, operation: 'plugin.install.restore' }, + 'Failed to restore the previous plugin package after a failed install; it is left at the retired path', + ); + } +} + function applySecretPatch( secrets: SecretStore, patch: ReadonlyMap, @@ -339,12 +530,8 @@ function readManifest(packageDir: string): LinkCodePluginManifest | undefined { } function prunePluginSecrets(secrets: SecretStore, pluginId: string): void { - // Prune by key prefix, never by re-deriving keys from each surviving plugin's manifest: a - // manifest that is unreadable (corrupt, or schema-drifted after an upgrade) must not turn an - // unrelated plugin's secrets into "orphans" that replaceAll then deletes. - // The separator must be `/`, not `.`: dots are legal inside both id segments (a `linkcode/mail.pro` - // plugin would have its keys match a `linkcode/mail.` prefix), while `/` can never appear in a - // setting id and a plugin id always has exactly two segments, so the prefix is unambiguous. + // Prune by `pluginId/`; `/` is forbidden in ids, while dots are legal and would match siblings. + // Do not infer orphans from manifests because unreadable survivors must keep their secrets. const prefix = `${pluginId}/`; const surviving = new Map(); for (const key of secrets.keys()) { diff --git a/packages/client/core/src/__tests__/plugin-market.test.ts b/packages/client/core/src/__tests__/plugin-market.test.ts index c665202b0..4f38733f5 100644 --- a/packages/client/core/src/__tests__/plugin-market.test.ts +++ b/packages/client/core/src/__tests__/plugin-market.test.ts @@ -1,6 +1,7 @@ import type { ValidatedWireMessage, WirePayload } from '@linkcode/schema'; +import { MIN_COMPATIBLE_WIRE_VERSION, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; -import { createWireMessage, pong } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; import { describe, expect, it, vi } from 'vitest'; import { LinkCodeClient } from '../client'; @@ -37,12 +38,18 @@ class ControlledTransport implements Transport { } } -async function connect(): Promise<{ client: LinkCodeClient; transport: ControlledTransport }> { +async function connect( + peerWireVersion: number = WIRE_PROTOCOL_VERSION, +): Promise<{ client: LinkCodeClient; transport: ControlledTransport }> { const transport = new ControlledTransport(); const client = new LinkCodeClient(transport); const connecting = client.connect(); await vi.waitFor(() => expect(transport.sent).toContainEqual({ kind: 'ping' })); - transport.receive(pong()); + transport.receive({ + kind: 'pong', + version: peerWireVersion, + minCompatible: MIN_COMPATIBLE_WIRE_VERSION, + }); await connecting; return { client, transport }; } @@ -114,6 +121,23 @@ describe('LinkCodeClient plugin-market / plugin-config requests', () => { client.dispose(); }); + it('rejects an incomplete empty 304 from a wire-v79 host', async () => { + const { client, transport } = await connect(79); + const pending = client.refreshPluginMarketplace('linkcode-official'); + const request = lastRequest(transport); + + transport.receive({ + kind: 'plugin-market.refreshed', + replyTo: request.clientReqId, + marketplaceId: 'linkcode-official', + releases: [], + notModified: true, + }); + + await expect(pending).rejects.toThrow('incomplete cached marketplace response'); + client.dispose(); + }); + it('installs a release and resolves with the installed identity', async () => { const { client, transport } = await connect(); const release = { diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 898c2c637..6cf878f62 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -204,6 +204,8 @@ type LoopEventCb = (event: LoopEvent) => void; type ConnectionState = 'idle' | 'connecting' | 'ready' | 'closed' | 'disposed'; const HANDSHAKE_TIMEOUT_MS = 5000; +// Wire v79 can return an empty 304 after losing its daemon cache; rejecting preserves client data. +const PLUGIN_MARKET_CACHED_304_WIRE_VERSION = 80; /** The message to fail the handshake with, or null when the two builds overlap. */ function wireIncompatibility(peerVersion: number, peerMinCompatible: number): string | null { @@ -954,8 +956,19 @@ export class LinkCodeClient { } /** Refresh one marketplace index; `notModified` replies carry the cached catalog. */ - refreshPluginMarketplace(marketplaceId: string): Promise { - return this.control.refreshPluginMarketplace(marketplaceId); + async refreshPluginMarketplace(marketplaceId: string): Promise { + const refresh = await this.control.refreshPluginMarketplace(marketplaceId); + if ( + this.peerWireVersion !== null && + this.peerWireVersion < PLUGIN_MARKET_CACHED_304_WIRE_VERSION && + refresh.notModified === true && + refresh.releases.length === 0 + ) { + throw new Error( + 'LinkCodeClient: the host returned an incomplete cached marketplace response; update the host and retry', + ); + } + return refresh; } /** Install a marketplace release; resolves with the installed release identity. */ diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx index 6b6b12f11..1e43257bc 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx @@ -30,7 +30,9 @@ const SETTINGS: LinkCodePluginSettings = { required: true, }, preset: { type: 'enum', label: 'Provider preset', enum: ['163', 'qq'], default: '163' }, - maxBodyChars: { type: 'number', label: 'Max body characters', default: 8000 }, + // Dotted on purpose: react-hook-form reads `.` as a path separator, so this id is the one shape + // that silently dropped its value before `pluginConfigFormKey` escaped it. + 'body.max': { type: 'number', label: 'Max body characters', default: 8000 }, readonly: { type: 'boolean', label: 'Read-only', default: false }, }; @@ -87,7 +89,8 @@ describe('LinkCodePluginConfigDialog', () => { expect(onSubmit).toHaveBeenCalledWith({ set: { account: 'new@163.com', - maxBodyChars: 4000, + // Keyed by the real setting id, not the escaped form key the input was registered under. + 'body.max': 4000, readonly: true, }, }); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts index ccd9649a3..ff6d1ea40 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts @@ -11,7 +11,9 @@ const SETTINGS: LinkCodePluginSettings = { password: { type: 'password', secret: true, required: true }, preset: { type: 'enum', enum: ['163', 'qq'], default: '163' }, nickname: { type: 'string' }, - maxBodyChars: { type: 'number', default: 8000 }, + // Dotted on purpose: `.` is legal in a setting id, and it is the shape react-hook-form would + // otherwise read as a nested path. Form-value maps below key it as `body$max`. + 'body.max': { type: 'number', default: 8000 }, readonly: { type: 'boolean', default: false }, }; @@ -20,7 +22,7 @@ describe('pluginConfigDefaults', () => { expect( pluginConfigDefaults(SETTINGS, { account: 'you@163.com', - maxBodyChars: 4000, + 'body.max': 4000, readonly: true, }), ).toEqual({ @@ -28,7 +30,7 @@ describe('pluginConfigDefaults', () => { password: '', preset: '163', nickname: '', - maxBodyChars: '4000', + body$max: '4000', readonly: true, }); }); @@ -49,9 +51,9 @@ describe('validatePluginConfigField', () => { }); it('rejects a non-numeric number field, blank optional number passes', () => { - expect(validatePluginConfigField(SETTINGS.maxBodyChars, 'abc')).toBe('invalidNumber'); - expect(validatePluginConfigField(SETTINGS.maxBodyChars, '42')).toBe(true); - expect(validatePluginConfigField(SETTINGS.maxBodyChars, '')).toBe(true); + expect(validatePluginConfigField(SETTINGS['body.max'], 'abc')).toBe('invalidNumber'); + expect(validatePluginConfigField(SETTINGS['body.max'], '42')).toBe(true); + expect(validatePluginConfigField(SETTINGS['body.max'], '')).toBe(true); }); it('always passes a boolean', () => { @@ -69,7 +71,7 @@ describe('buildPluginConfigPatch', () => { password: 'secret', preset: 'qq', nickname: '', - maxBodyChars: '4000', + body$max: '4000', readonly: true, }, ); @@ -77,7 +79,7 @@ describe('buildPluginConfigPatch', () => { account: 'you@163.com', password: 'secret', preset: 'qq', - maxBodyChars: 4000, + 'body.max': 4000, readonly: true, }); expect(patch.remove).toBeUndefined(); @@ -92,7 +94,7 @@ describe('buildPluginConfigPatch', () => { password: '', preset: '163', nickname: '', - maxBodyChars: '8000', + body$max: '8000', readonly: false, }, ); @@ -122,16 +124,31 @@ describe('buildPluginConfigPatch', () => { it('stores a value equal to the manifest default as a removal, so upgrades can change it', () => { const patch = buildPluginConfigPatch( SETTINGS, - { preset: 'qq', maxBodyChars: 4000 }, + { preset: 'qq', 'body.max': 4000 }, { - ...pluginConfigDefaults(SETTINGS, { preset: 'qq', maxBodyChars: 4000 }), + ...pluginConfigDefaults(SETTINGS, { preset: 'qq', 'body.max': 4000 }), preset: '163', - maxBodyChars: '8000', + body$max: '8000', }, ); expect(patch.set).toBeUndefined(); - expect(patch.remove).toEqual(['preset', 'maxBodyChars']); + expect(patch.remove).toEqual(['preset', 'body.max']); + }); + + it('carries a dotted setting id through the form key back to its real id', () => { + // RHF nested the raw dotted key and left its flat default stale, dropping the edit on save. + const patch = buildPluginConfigPatch(SETTINGS, { 'body.max': 4000 }, { body$max: '512' }); + + expect(patch.set).toEqual({ 'body.max': 512 }); + expect(patch.remove).toBeUndefined(); + }); + + it('ignores a nested form shape, which is what a raw dotted RHF name would produce', () => { + const nested = { body: { max: '512' } } as unknown as Parameters< + typeof buildPluginConfigPatch + >[2]; + expect(buildPluginConfigPatch(SETTINGS, {}, nested)).toEqual({}); }); it('omits both sides of an empty patch', () => { diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx index 6c19725d4..d0126f904 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx @@ -27,6 +27,7 @@ import type { PluginConfigFormValues } from './linkcode-config'; import { buildPluginConfigPatch, pluginConfigDefaults, + pluginConfigFormKey, validatePluginConfigField, } from './linkcode-config'; @@ -119,6 +120,9 @@ function ConfigField({ }): React.ReactNode { const t = useTranslations('settings.plugins.linkcode'); const label = field.label ?? fieldId; + // Every RHF name and the that pairs errors to it: a dotted setting id must not be + // read as a nested path (see pluginConfigFormKey). + const formKey = pluginConfigFormKey(fieldId); if (field.type === 'boolean') { // Kept out of : a bare Switch is not a Field control, and base-ui's Fieldset does not @@ -133,7 +137,7 @@ function ConfigField({ ( + {label} {field.type === 'enum' ? ( ( ; +/** Escape `.` because RHF treats it as a path separator; `$` is forbidden in setting ids and stays + * a literal path segment. */ +export function pluginConfigFormKey(fieldId: string): string { + return fieldId.replaceAll('.', '$'); +} + /** Validation outcome tokens the form maps to translated messages. */ export type PluginConfigFieldError = 'required' | 'invalidNumber'; @@ -20,9 +23,10 @@ export function pluginConfigDefaults( ): PluginConfigFormValues { const defaults: PluginConfigFormValues = {}; for (const [fieldId, field] of Object.entries(settings)) { + const formKey = pluginConfigFormKey(fieldId); const stored = values[fieldId]; if (field.type === 'boolean') { - defaults[fieldId] = + defaults[formKey] = typeof stored === 'boolean' ? stored : typeof field.default === 'boolean' @@ -32,15 +36,15 @@ export function pluginConfigDefaults( } if (field.secret) { // Secret values never arrive over the wire; the blank input carries "keep as-is". - defaults[fieldId] = ''; + defaults[formKey] = ''; continue; } // `in` over indexed access: the masked read may omit keys the index-signature type claims exist. if (fieldId in values) { - defaults[fieldId] = String(values[fieldId]); + defaults[formKey] = String(values[fieldId]); continue; } - defaults[fieldId] = field.default === undefined ? '' : String(field.default); + defaults[formKey] = field.default === undefined ? '' : String(field.default); } return defaults; } @@ -83,8 +87,10 @@ export function buildPluginConfigPatch( const set: Record = {}; const remove: string[] = []; for (const [fieldId, field] of Object.entries(settings)) { - if (!(fieldId in form)) continue; - const raw = form[fieldId]; + // The form is keyed by form key; the patch is keyed by setting id. This is the only crossing. + const formKey = pluginConfigFormKey(fieldId); + if (!(formKey in form)) continue; + const raw = form[formKey]; if (field.type === 'boolean') { const typed = raw === true; if (field.default !== undefined && typed === field.default) { diff --git a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx index e07b74a4b..b5ccad973 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx @@ -1,6 +1,4 @@ -import type { PluginMarketRefresh } from '@linkcode/client-core'; import type { LinkCodeMarketplaceConfig, LinkCodePluginId } from '@linkcode/schema'; -import { refreshPluginMarketplace } from '@linkcode/sdk'; import type { LinkCodeCatalogCardView, LinkCodeInstalledPluginRow } from '@linkcode/ui'; import { LinkCodeCatalogSection, LinkCodeInstalledSection } from '@linkcode/ui'; import { Card } from 'coss-ui/components/card'; @@ -148,20 +146,8 @@ function MarketplaceCatalog({ searchQuery, ); - // Keep this defensive merge for pre-wire-80 daemons that still return an empty 304 payload. const onRefresh = (): void => { - void mutate( - async (current): Promise => { - const { data: next } = await refreshPluginMarketplace({ - marketplaceId: marketplace.id, - }); - if (current !== undefined && next.notModified === true) { - return { ...next, releases: current.releases }; - } - return next; - }, - { revalidate: false }, - ).catch(noop); + void mutate().catch(noop); }; return ( diff --git a/packages/foundation/schema/src/model/__tests__/plugin.test.ts b/packages/foundation/schema/src/model/__tests__/plugin.test.ts index d0f3547f7..e2ddea371 100644 --- a/packages/foundation/schema/src/model/__tests__/plugin.test.ts +++ b/packages/foundation/schema/src/model/__tests__/plugin.test.ts @@ -317,6 +317,15 @@ describe('LinkCode plugin package contracts', () => { }, ); + it('rejects a dotted mcp-server name, which would collide as a provider config key', () => { + expect( + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + components: [{ ...mailManifest.components[0], name: 'mail.server' }], + }).success, + ).toBe(false); + }); + it('rejects an mcp-server env binding to an undeclared setting', () => { expect( LinkCodePluginManifestSchema.safeParse({ diff --git a/packages/foundation/schema/src/model/linkcode-plugin.ts b/packages/foundation/schema/src/model/linkcode-plugin.ts index 4bf4ab5e4..292ce46ae 100644 --- a/packages/foundation/schema/src/model/linkcode-plugin.ts +++ b/packages/foundation/schema/src/model/linkcode-plugin.ts @@ -2,6 +2,9 @@ import { z } from 'zod'; import { PluginAssetRequirementSchema, PluginAuthorSchema, PluginLinksSchema } from './plugin'; const ID_SEGMENT_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; +// MCP server names become provider-config keys, where `.` is a path separator that can reshape or +// collide with another entry. +const MCP_SERVER_NAME_RE = /^[a-z0-9]+(?:[_-][a-z0-9]+)*$/; const PACKAGE_PATH_SEGMENT_RE = /^[0-9A-Z][\w.-]*$/i; const WINDOWS_RESERVED_SEGMENT_RE = /^(?:aux|con|nul|prn|com[1-9]|lpt[1-9])(?:\.|$)/i; const NUMERIC_IDENTIFIER_RE = /^\d+$/; @@ -164,7 +167,12 @@ export type LinkCodePluginSettings = z.infer value.length <= MAX_ID_SEGMENT_LENGTH && MCP_SERVER_NAME_RE.test(value), + 'Expected a safe lowercase server name (letters, digits, _ or -; no dot)', + ), description: z.string().optional(), command: z.string().min(1), /** Package-relative entry point. When present, the host resolves it under the installed plugin From 444224bd54a13a30129d1c1b6a1eeaa9efcd6d84 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Wed, 26 Aug 2026 00:03:55 +0800 Subject: [PATCH 06/19] fix(plugin-marketplace): roll back failed registry writes --- apps/daemon/AGENTS.md | 3 + .../daemon/src/__tests__/plugin-store.test.ts | 47 ++++++++++++++- apps/daemon/src/plugin-store/paths.ts | 15 +---- apps/daemon/src/plugin-store/store.ts | 58 +++++++++++++++---- .../core/src/__tests__/plugin-market.test.ts | 30 +--------- packages/client/core/src/client.ts | 17 +----- 6 files changed, 100 insertions(+), 70 deletions(-) diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index 37525e949..b27978c09 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -93,6 +93,9 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr round-trip through this store can (`__tests__/session-store.test.ts`). - **`runtime.json`** — endpoint discovery (`{name,pid,startedAt,listeners:[{type,url}]}`), written `0600` only AFTER every listener binds and removed on graceful `SIGINT`/`SIGTERM` shutdown. +- **Marketplace sources are trusted code origins.** Installed manifests may launch their declared + command, args, and env; SRI pins bytes to the index but does not make a hostile index safe. Keep + adding sources config/env-only until an explicit consent and trust design is approved. ## Ports & one-per-profile diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts index a3b064229..38d54390f 100644 --- a/apps/daemon/src/__tests__/plugin-store.test.ts +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ downloadVerified: vi.fn(), tarExtract: vi.fn(), removeFailurePrefix: undefined as string | undefined, + renameFailureDestination: undefined as string | undefined, renameFailureSource: undefined as string | undefined, })); @@ -33,7 +34,7 @@ vi.mock('node:fs', async (importOriginal) => { return { ...actual, renameSync(source: import('node:fs').PathLike, destination: import('node:fs').PathLike): void { - if (source === mocks.renameFailureSource) { + if (source === mocks.renameFailureSource || destination === mocks.renameFailureDestination) { throw Object.assign(new Error('injected rename failure'), { code: 'EACCES' }); } actual.renameSync(source, destination); @@ -61,6 +62,7 @@ beforeEach(() => { mocks.downloadVerified.mockReset().mockResolvedValue(undefined); mocks.tarExtract.mockReset(); mocks.removeFailurePrefix = undefined; + mocks.renameFailureDestination = undefined; mocks.renameFailureSource = undefined; }); @@ -251,6 +253,37 @@ describe('DaemonLinkCodePluginStore', () => { ).toBe(true); }); + it('restores the live package when registry persistence fails after publishing', async () => { + const live = record('0.2.0'); + writePackage(live, manifest('0.2.0', 'live-skill')); + writeRegistry([live]); + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(manifest('0.2.0', 'new-skill'))); + }); + mocks.renameFailureDestination = pluginRegistryPath(); + const release = { + manifest: manifest('0.2.0'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + await expect(store.install(release, 'linkcode-official')).rejects.toThrow( + 'injected rename failure', + ); + + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('live-skill'); + expect(JSON.parse(readFileSync(pluginRegistryPath(), 'utf8'))).toMatchObject([ + { id: 'arcbox/latex', version: '0.2.0' }, + ]); + expect(readdirSync(join(live.path, '..')).filter((name) => name.startsWith('.tmp-'))).toEqual( + [], + ); + }); + it('sweeps orphaned staging directories at construction', () => { const live = record('0.1.0'); writePackage(live, manifest('0.1.0')); @@ -287,11 +320,19 @@ describe('DaemonLinkCodePluginStore', () => { const retired = join(live.path, '..', '.tmp-retired-999-versioned'); mkdirSync(retired, { recursive: true }); writeFileSync(join(retired, 'manifest.json'), JSON.stringify(manifest('0.2.0', 'live-skill'))); + expect([existsSync(legacy.path), existsSync(live.path), existsSync(retired)]).toEqual([ + false, + false, + true, + ]); const store = new DaemonLinkCodePluginStore(createInMemoryVault()); - expect(existsSync(legacy.path)).toBe(false); - expect(existsSync(live.path)).toBe(true); + expect([existsSync(legacy.path), existsSync(live.path), existsSync(retired)]).toEqual([ + false, + true, + false, + ]); expect(store.get('arcbox/latex')?.installed.version).toBe('0.2.0'); }); diff --git a/apps/daemon/src/plugin-store/paths.ts b/apps/daemon/src/plugin-store/paths.ts index 16cfc947d..db3dcda18 100644 --- a/apps/daemon/src/plugin-store/paths.ts +++ b/apps/daemon/src/plugin-store/paths.ts @@ -1,20 +1,14 @@ import { randomUUID } from 'node:crypto'; import { mkdirSync } from 'node:fs'; -import { homedir } from 'node:os'; import { join } from 'node:path'; -import { linkcodeStateDirName } from '@linkcode/schema'; -import { resolveProductChannel } from '@linkcode/schema/product'; -import { daemonChannel, daemonProfile } from '../paths'; +import { daemonStateDir } from '../paths'; const RE_PATH_SEP = /[/\\]/g; /** Per-universe LinkCode plugin store root: `/plugins`. A fake `$HOME` redirects it, * the same property that isolates an E2E daemon from the release one. */ export function pluginsRoot(): string { - const channel = daemonChannel(); - const profile = daemonProfile(); - const stateDir = join(homedir(), linkcodeStateDirName(channel, profile)); - return join(stateDir, 'plugins'); + return join(daemonStateDir(), 'plugins'); } /** The central install registry: one `InstalledLinkCodePlugin` record per installed version. */ @@ -43,8 +37,3 @@ export function makePluginTmpDir(pluginId: string, version: string): string { mkdirSync(parent, { recursive: true }); return join(parent, `${PLUGIN_STAGING_PREFIX}${process.pid}-${version}-${randomUUID()}`); } - -/** Resolve product channel for callers that must not reach into the paths module's side effects. */ -export function resolvedChannel(): ReturnType { - return resolveProductChannel(process.env.LINKCODE_CHANNEL, process.env.LINKCODE_BUILD_CHANNEL); -} diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 699fcb17d..6dce372bd 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -202,8 +202,17 @@ async function installExclusive( const targetDir = pluginPackageDir(manifest.id, manifest.version); const stagingDir = makePluginTmpDir(manifest.id, manifest.version); const tgzPath = join(stagingDir, 'package.tgz'); + const record: InstalledLinkCodePlugin = { + id: manifest.id, + version: manifest.version, + marketplaceId, + integrity: artifact.integrity, + enabled: true, + path: targetDir, + }; let installedManifest: LinkCodePluginManifest; let retiredDir: string | undefined; + let published = false; mkdirSync(stagingDir, { recursive: true }); try { const downloadArtifact: ManagedAssetArtifact = { @@ -228,23 +237,24 @@ async function installExclusive( // the registry pointed at a missing directory. retiredDir = retirePluginPackage(targetDir); renameSync(stagingDir, targetDir); + published = true; + upsertRegistry(record); } catch (error) { - rmSync(stagingDir, { recursive: true, force: true }); - if (retiredDir !== undefined) restorePluginPackage(retiredDir, targetDir, manifest.id); + try { + rmSync(stagingDir, { recursive: true, force: true }); + } catch (cleanupError) { + logger.warn( + { error: cleanupError, path: stagingDir, operation: 'plugin.install.cleanup-staging' }, + 'Failed to remove plugin staging after an install failure', + ); + } + if (published) rollbackPublishedPluginPackage(targetDir, retiredDir, manifest.id); + else if (retiredDir !== undefined) restorePluginPackage(retiredDir, targetDir, manifest.id); throw new Error( `Failed to install plugin ${manifest.id}: ${extractErrorMessage(error) ?? 'unknown'}`, { cause: error }, ); } - const record: InstalledLinkCodePlugin = { - id: manifest.id, - version: manifest.version, - marketplaceId, - integrity: artifact.integrity, - enabled: true, - path: targetDir, - }; - upsertRegistry(record); if (retiredDir !== undefined) { try { rmSync(retiredDir, { recursive: true, force: true }); @@ -275,7 +285,7 @@ async function installExclusive( return { installed: record, manifest: installedManifest }; } -/** Delete incomplete staging dirs, but retain retired backups unless their exact state is known. */ +/** Delete incomplete staging dirs; retain unproven backups until their exact version is reinstalled. */ function sweepStagingDirs(): void { const root = pluginsRoot(); const records = readRegistry(); @@ -438,6 +448,30 @@ function restorePluginPackage(retiredDir: string, targetDir: string, pluginId: s } } +/** Undo a package publish after registry persistence fails; the target is the package just staged. */ +function rollbackPublishedPluginPackage( + targetDir: string, + retiredDir: string | undefined, + pluginId: string, +): void { + try { + rmSync(targetDir, { recursive: true, force: true }); + } catch (error) { + logger.error( + { + error, + pluginId, + path: targetDir, + retiredDir, + operation: 'plugin.install.rollback-publish', + }, + 'Failed to remove a published plugin after registry persistence failed', + ); + return; + } + if (retiredDir !== undefined) restorePluginPackage(retiredDir, targetDir, pluginId); +} + function applySecretPatch( secrets: SecretStore, patch: ReadonlyMap, diff --git a/packages/client/core/src/__tests__/plugin-market.test.ts b/packages/client/core/src/__tests__/plugin-market.test.ts index 4f38733f5..c665202b0 100644 --- a/packages/client/core/src/__tests__/plugin-market.test.ts +++ b/packages/client/core/src/__tests__/plugin-market.test.ts @@ -1,7 +1,6 @@ import type { ValidatedWireMessage, WirePayload } from '@linkcode/schema'; -import { MIN_COMPATIBLE_WIRE_VERSION, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; -import { createWireMessage } from '@linkcode/transport'; +import { createWireMessage, pong } from '@linkcode/transport'; import { describe, expect, it, vi } from 'vitest'; import { LinkCodeClient } from '../client'; @@ -38,18 +37,12 @@ class ControlledTransport implements Transport { } } -async function connect( - peerWireVersion: number = WIRE_PROTOCOL_VERSION, -): Promise<{ client: LinkCodeClient; transport: ControlledTransport }> { +async function connect(): Promise<{ client: LinkCodeClient; transport: ControlledTransport }> { const transport = new ControlledTransport(); const client = new LinkCodeClient(transport); const connecting = client.connect(); await vi.waitFor(() => expect(transport.sent).toContainEqual({ kind: 'ping' })); - transport.receive({ - kind: 'pong', - version: peerWireVersion, - minCompatible: MIN_COMPATIBLE_WIRE_VERSION, - }); + transport.receive(pong()); await connecting; return { client, transport }; } @@ -121,23 +114,6 @@ describe('LinkCodeClient plugin-market / plugin-config requests', () => { client.dispose(); }); - it('rejects an incomplete empty 304 from a wire-v79 host', async () => { - const { client, transport } = await connect(79); - const pending = client.refreshPluginMarketplace('linkcode-official'); - const request = lastRequest(transport); - - transport.receive({ - kind: 'plugin-market.refreshed', - replyTo: request.clientReqId, - marketplaceId: 'linkcode-official', - releases: [], - notModified: true, - }); - - await expect(pending).rejects.toThrow('incomplete cached marketplace response'); - client.dispose(); - }); - it('installs a release and resolves with the installed identity', async () => { const { client, transport } = await connect(); const release = { diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 6cf878f62..898c2c637 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -204,8 +204,6 @@ type LoopEventCb = (event: LoopEvent) => void; type ConnectionState = 'idle' | 'connecting' | 'ready' | 'closed' | 'disposed'; const HANDSHAKE_TIMEOUT_MS = 5000; -// Wire v79 can return an empty 304 after losing its daemon cache; rejecting preserves client data. -const PLUGIN_MARKET_CACHED_304_WIRE_VERSION = 80; /** The message to fail the handshake with, or null when the two builds overlap. */ function wireIncompatibility(peerVersion: number, peerMinCompatible: number): string | null { @@ -956,19 +954,8 @@ export class LinkCodeClient { } /** Refresh one marketplace index; `notModified` replies carry the cached catalog. */ - async refreshPluginMarketplace(marketplaceId: string): Promise { - const refresh = await this.control.refreshPluginMarketplace(marketplaceId); - if ( - this.peerWireVersion !== null && - this.peerWireVersion < PLUGIN_MARKET_CACHED_304_WIRE_VERSION && - refresh.notModified === true && - refresh.releases.length === 0 - ) { - throw new Error( - 'LinkCodeClient: the host returned an incomplete cached marketplace response; update the host and retry', - ); - } - return refresh; + refreshPluginMarketplace(marketplaceId: string): Promise { + return this.control.refreshPluginMarketplace(marketplaceId); } /** Install a marketplace release; resolves with the installed release identity. */ From d5edb11c18d60414d6aa55336d912b147ec97f60 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Wed, 26 Aug 2026 01:05:13 +0800 Subject: [PATCH 07/19] fix(plugin-marketplace): harden client mutation state --- apps/daemon/e2e/plugin-marketplace.e2e.ts | 17 +++-- apps/daemon/src/__tests__/config.test.ts | 12 ++-- .../daemon/src/__tests__/plugin-store.test.ts | 27 ++++++++ apps/daemon/src/config.ts | 8 ++- apps/daemon/src/index.ts | 45 +++++++------ apps/daemon/src/plugin-store/paths.ts | 3 +- apps/daemon/src/plugin-store/store.ts | 49 ++++++++------ .../settings/plugins/__tests__/view.test.ts | 52 +++++++-------- .../src/settings/plugins/linkcode-tab.tsx | 65 ++++++++++++------- .../src/settings/plugins/plugins-settings.tsx | 9 ++- .../src/__tests__/start-options-mcp.test.ts | 49 +++++++------- scripts/dev-marketplace.mts | 6 +- 12 files changed, 205 insertions(+), 137 deletions(-) diff --git a/apps/daemon/e2e/plugin-marketplace.e2e.ts b/apps/daemon/e2e/plugin-marketplace.e2e.ts index b681a3e81..ec941a69d 100644 --- a/apps/daemon/e2e/plugin-marketplace.e2e.ts +++ b/apps/daemon/e2e/plugin-marketplace.e2e.ts @@ -145,7 +145,7 @@ async function main(): Promise { const before = await client.listLinkCodePluginConfigs(); const view = before.find((entry) => entry.id === PLUGIN_ID); assert(view, 'installed plugin missing from plugin-config.list'); - assert(view.settings.token?.secret, 'token must be a secret field'); + assert(view.settings.token.secret, 'token must be a secret field'); assert.equal(view.values.token, undefined, 'secret value leaked in masked read'); await client.setLinkCodePluginConfig({ @@ -155,9 +155,11 @@ async function main(): Promise { const configFile = JSON.parse(readFileSync(join(home, '.linkcode', 'config.json'), 'utf8')) as { pluginConfigs?: Record>; }; - assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.greeting, '你好'); - assert.equal(configFile.pluginConfigs?.[PLUGIN_ID]?.mode, 'shout'); - assert(!('token' in (configFile.pluginConfigs?.[PLUGIN_ID] ?? {})), 'secret in config.json'); + const pluginConfig = configFile.pluginConfigs?.[PLUGIN_ID]; + assert(pluginConfig, 'installed plugin config missing from config.json'); + assert.equal(pluginConfig.greeting, '你好'); + assert.equal(pluginConfig.mode, 'shout'); + assert(!('token' in pluginConfig), 'secret in config.json'); const secretsFile = JSON.parse( readFileSync(join(home, '.linkcode', 'secrets.json'), 'utf8'), ) as { @@ -174,8 +176,9 @@ async function main(): Promise { const after = await client.listLinkCodePluginConfigs(); const afterView = after.find((entry) => entry.id === PLUGIN_ID); - assert.equal(afterView?.values.greeting, '你好'); - assert.equal(afterView?.values.token, undefined, 'secret value leaked after set'); + assert(afterView, 'installed plugin missing after set'); + assert.equal(afterView.values.greeting, '你好'); + assert.equal(afterView.values.token, undefined, 'secret value leaked after set'); // 5. Uninstall removes the package and prunes its config. const removed = await client.uninstallLinkCodePlugin(PLUGIN_ID); @@ -186,8 +189,10 @@ async function main(): Promise { const shutdown = await waitFor(() => exit ?? false, 50, AbortSignal.timeout(10000)); assert.deepEqual(shutdown, { code: 0, signal: null }); + // eslint-disable-next-line no-console -- e2e progress line; the daemon's own logs are captured below. console.log('PASS marketplace refresh (ETag 304), install, settings vault split, uninstall'); } catch (error) { + // eslint-disable-next-line no-console -- dump the captured daemon log on failure for triage. console.error(logs.join('').slice(-8000)); throw error; } finally { diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index d2b0a861b..21267d92b 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -316,6 +316,12 @@ describe('saveProviderConfiguration', () => { }); }); +function writeCustomMcpConfig(customMcpServers: unknown): void { + const dir = join(process.env.HOME ?? '', '.linkcode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify({ customMcpServers })); +} + describe('loadConfig custom MCP servers', () => { const validServer = { id: 'custom-1', @@ -329,12 +335,6 @@ describe('loadConfig custom MCP servers', () => { 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' } }]); diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts index 38d54390f..eda7dff42 100644 --- a/apps/daemon/src/__tests__/plugin-store.test.ts +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -195,6 +195,33 @@ describe('DaemonLinkCodePluginStore', () => { ]); }); + it('applies an uninstall issued during an install after the install completes', async () => { + // The install stalls in extraction; the uninstall must queue behind it, not race the + // still-incomplete registry read and let the plugin come back after being removed. + mocks.tarExtract.mockImplementation(async ({ cwd }: { cwd: string }) => { + await wait(20); + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(manifest('0.2.0'))); + }); + const release = { + manifest: manifest('0.2.0'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + await Promise.all([ + store.install(release, 'linkcode-official'), + store.uninstall('arcbox/latex'), + ]); + + expect(store.get('arcbox/latex')).toBeUndefined(); + expect(existsSync(pluginPackageDir('arcbox/latex', '0.2.0'))).toBe(false); + expect(JSON.parse(readFileSync(pluginRegistryPath(), 'utf8'))).toEqual([]); + }); + it('keeps the live package and registry intact when a reinstall fails before publishing', async () => { const live = record('0.2.0'); writePackage(live, manifest('0.2.0', 'live-skill')); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 55035e115..daedcd76d 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -319,8 +319,12 @@ export function savePluginConfigValues( ): void { const file = readConfigFile(); const configs = isRecord(file.pluginConfigs) ? { ...file.pluginConfigs } : {}; - if (isObjectEmpty(values)) delete configs[pluginId]; - else configs[pluginId] = values; + if (isObjectEmpty(values)) { + const { [pluginId]: _removed, ...rest } = configs; + writeConfigFields(file, { pluginConfigs: rest }); + return; + } + configs[pluginId] = values; writeConfigFields(file, { pluginConfigs: configs }); } diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 93ecf4046..0ba01633f 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -289,30 +289,29 @@ async function main(): Promise { const EngineReady = Layer.effectDiscard( Effect.gen(function* () { const engine = yield* EngineService; - void agentRuntimesReady - .then((agentRuntimes) => { - for (const kind of agentsToRefresh(consentedAgents, agentRuntimes, assets)) { - void assets - .ensure(managedAgentAssetId(kind)) - .catch((err) => { - logger.warn( - { err, agentKind: kind, operation: 'asset.ensure' }, - 'Managed agent install failed', + void (async () => { + const agentRuntimes = await agentRuntimesReady; + for (const kind of agentsToRefresh(consentedAgents, agentRuntimes, assets)) { + void assets + .ensure(managedAgentAssetId(kind)) + .catch((err) => { + logger.warn( + { err, agentKind: kind, operation: 'asset.ensure' }, + 'Managed agent install failed', + ); + }) + .then((installed) => { + if (installed) { + logger.info( + { agentKind: kind, operation: 'asset.ensure' }, + 'Managed agent runtime ready', ); - }) - .then((installed) => { - if (installed) { - logger.info( - { agentKind: kind, operation: 'asset.ensure' }, - 'Managed agent runtime ready', - ); - } - }); - } - }) - .catch((err) => { - logger.warn({ err, operation: 'agent.probe' }, 'Boot agent probe failed'); - }); + } + }); + } + })().catch((err) => { + logger.warn({ err, operation: 'agent.probe' }, 'Boot agent probe failed'); + }); // Runs before any listener binds, so `workspace.list` always includes the chat workspace. yield* engine.ensureChatWorkspace(chatWorkspaceRoot()); }), diff --git a/apps/daemon/src/plugin-store/paths.ts b/apps/daemon/src/plugin-store/paths.ts index db3dcda18..ed01cd177 100644 --- a/apps/daemon/src/plugin-store/paths.ts +++ b/apps/daemon/src/plugin-store/paths.ts @@ -19,7 +19,8 @@ export function pluginRegistryPath(): string { /** Escapes a plugin id (`publisher/name`) into a stable two-level path; the id's `/` is the level. */ export function pluginPackageDir(pluginId: string, version: string): string { const segments = pluginId.split('/'); - const safe = segments.length === 2 ? segments : ['unmanaged', pluginId.replace(RE_PATH_SEP, '_')]; + const safe = + segments.length === 2 ? segments : ['unmanaged', pluginId.replaceAll(RE_PATH_SEP, '_')]; return join(pluginsRoot(), ...safe, version); } diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 6dce372bd..1ee3f3725 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -100,21 +100,24 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { const settings = manifest.settings; const secrets = pluginSecretStore(this.vault); const previousNonSecret = loadPluginConfigValues(pluginId); - const nextNonSecret = { ...previousNonSecret }; + let nextNonSecret = { ...previousNonSecret }; const secretPatch = new Map(); if (patch.remove) { for (const fieldId of patch.remove) { + if (!(fieldId in settings)) continue; const field = settings[fieldId]; - if (field === undefined) continue; if (field.secret) secretPatch.set(`${pluginId}/${fieldId}`, undefined); - else delete nextNonSecret[fieldId]; + else { + const { [fieldId]: _removed, ...rest } = nextNonSecret; + nextNonSecret = rest; + } } } if (patch.set) { for (const [fieldId, value] of Object.entries(patch.set)) { + if (!(fieldId in settings)) continue; const field = settings[fieldId]; - if (field === undefined) continue; if (field.secret) secretPatch.set(`${pluginId}/${fieldId}`, String(value)); else nextNonSecret[fieldId] = value; } @@ -150,18 +153,35 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { return Promise.resolve(); } - /** Serializes installs of one plugin id; a concurrent second install would otherwise publish to - * the same package dir and delete the first install's just-renamed package. */ + /** Serializes installs and uninstalls per plugin id; a concurrent second mutation would + * otherwise publish to / delete the same package dir and leave the registry inconsistent. */ private readonly installChains = new Map>(); install( release: LinkCodePluginRelease, marketplaceId: string, ): Promise { - const pluginId = release.manifest.id; + return this.serialize(release.manifest.id, () => installExclusive(release, marketplaceId)); + } + + uninstall(pluginId: string): Promise { + return this.serialize(pluginId, () => { + const records = readRegistry(); + const matches = records.filter((entry) => entry.id === pluginId); + if (matches.length > 0) { + for (const record of matches) rmSync(record.path, { recursive: true, force: true }); + writeRegistry(records.filter((entry) => entry.id !== pluginId)); + } + // Non-secret values are dropped by writing an empty block; secret values are pruned below. + savePluginConfigValues(pluginId, {}); + prunePluginSecrets(pluginSecretStore(this.vault), pluginId); + }); + } + + private serialize(pluginId: string, task: () => Promise | T): Promise { const run = (this.installChains.get(pluginId) ?? Promise.resolve()) .catch(noop) - .then(() => installExclusive(release, marketplaceId)); + .then(() => task()); this.installChains.set(pluginId, run); const settle = (): void => { if (this.installChains.get(pluginId) === run) this.installChains.delete(pluginId); @@ -169,19 +189,6 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { void run.catch(noop).finally(settle); return run; } - - uninstall(pluginId: string): Promise { - const records = readRegistry(); - const matches = records.filter((entry) => entry.id === pluginId); - if (matches.length > 0) { - for (const record of matches) rmSync(record.path, { recursive: true, force: true }); - writeRegistry(records.filter((entry) => entry.id !== pluginId)); - } - // Non-secret values are dropped by writing an empty block; secret values are pruned below. - savePluginConfigValues(pluginId, {}); - prunePluginSecrets(pluginSecretStore(this.vault), pluginId); - return Promise.resolve(); - } } async function installExclusive( diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts index 2e86d4ec8..a76c1d139 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -276,33 +276,33 @@ describe('pluginMcpServerRows', () => { }); }); -describe('linkcodeCatalogCards', () => { - function catalogEntry(version: string): PluginMarketReleaseEntry { - return { - pluginId: 'linkcode/mail', - release: { - manifest: { - manifestVersion: 1, - id: 'linkcode/mail', - version, - displayName: 'Mail (163 / QQ)', - description: 'Receive and send mail.', - keywords: ['mail'], - components: [ - { kind: 'mcp-server', name: 'mail', command: 'npx', env: { MAIL_USER: 'account' } }, - ], - settings: { account: { type: 'string', required: true } }, - assets: [], - }, - artifact: { - urls: [`plugins/mail-${version}.tgz`], - integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', - format: 'tgz', - }, +function catalogEntry(version: string): PluginMarketReleaseEntry { + return { + pluginId: 'linkcode/mail', + release: { + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version, + displayName: 'Mail (163 / QQ)', + description: 'Receive and send mail.', + keywords: ['mail'], + components: [ + { kind: 'mcp-server', name: 'mail', command: 'npx', env: { MAIL_USER: 'account' } }, + ], + settings: { account: { type: 'string', required: true } }, + assets: [], + }, + artifact: { + urls: [`plugins/mail-${version}.tgz`], + integrity: 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + format: 'tgz', }, - }; - } + }, + }; +} +describe('linkcodeCatalogCards', () => { it('projects a marketplace release entry to a catalog card with install state', () => { const [card] = linkcodeCatalogCards( 'linkcode-official', @@ -320,7 +320,7 @@ describe('linkcodeCatalogCards', () => { updateAvailable: false, installedNewer: false, }); - expect(card?.searchText).toContain('linkcode/mail'); + expect(card.searchText).toContain('linkcode/mail'); }); it('distinguishes not-installed from an upgrade to an older installed version', () => { diff --git a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx index b5ccad973..eeee0b238 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx @@ -3,7 +3,7 @@ import type { LinkCodeCatalogCardView, LinkCodeInstalledPluginRow } from '@linkc import { LinkCodeCatalogSection, LinkCodeInstalledSection } from '@linkcode/ui'; import { Card } from 'coss-ui/components/card'; import { noop } from 'foxts/noop'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; import { useInstallLinkCodePlugin, @@ -35,6 +35,10 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac const uninstall = useUninstallLinkCodePlugin(); const save = useSetLinkCodePluginConfig(); const [configuring, setConfiguring] = useState(null); + // Synchronous re-entry gates: `isMutating` updates on the next render, so a fast double action + // could otherwise fire two wire requests before the busy state disables the buttons. + const lifecyclePendingRef = useRef(false); + const savePendingRef = useRef(false); const installedVersions = new Map((configs ?? []).map((view) => [view.id, view.version])); const rows = configs?.map(linkcodeInstalledRow); @@ -46,33 +50,50 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac const enabledMarketplaces = marketplaces?.filter((marketplace) => marketplace.enabled); const onInstall = async (card: LinkCodeCatalogCardView): Promise => { - await install.trigger({ - release: { - marketplaceId: card.marketplaceId, - pluginId: card.pluginId, - version: card.version, - }, - }); - await mutateConfigs(); + if (lifecyclePendingRef.current) return; + lifecyclePendingRef.current = true; + try { + await install.trigger({ + release: { + marketplaceId: card.marketplaceId, + pluginId: card.pluginId, + version: card.version, + }, + }); + await mutateConfigs(); + } finally { + lifecyclePendingRef.current = false; + } }; const onUninstall = async (row: LinkCodeInstalledPluginRow): Promise => { - await uninstall.trigger({ pluginId: row.pluginId }); - await mutateConfigs(); + if (lifecyclePendingRef.current) return; + lifecyclePendingRef.current = true; + try { + await uninstall.trigger({ pluginId: row.pluginId }); + await mutateConfigs(); + } finally { + lifecyclePendingRef.current = false; + } }; const onSubmitConfig = async (patch: LinkCodePluginConfigPatch): Promise => { - if (editing === undefined) return; - const result = await save.trigger({ pluginId: editing.id, ...patch }); - // Fold the post-patch masked values into the cache instead of re-listing. - await mutateConfigs( - (current) => - current?.map((view) => - view.id === result.pluginId ? { ...view, values: result.values } : view, - ), - { revalidate: false }, - ); - setConfiguring(null); + if (editing === undefined || savePendingRef.current) return; + savePendingRef.current = true; + try { + const result = await save.trigger({ pluginId: editing.id, ...patch }); + // Fold the post-patch masked values into the cache instead of re-listing. + await mutateConfigs( + (current) => + current?.map((view) => + view.id === result.pluginId ? { ...view, values: result.values } : view, + ), + { revalidate: false }, + ); + setConfiguring((current) => (current === editing.id ? null : current)); + } finally { + savePendingRef.current = false; + } }; return ( diff --git a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx index f0b31e59a..124d2dc79 100644 --- a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx +++ b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx @@ -74,7 +74,6 @@ export function PluginsSettingsPanel(): React.ReactNode { scope: row.standaloneScope, enabled, }); - if (updated === undefined) return; void mutate( (current) => current && { @@ -94,15 +93,15 @@ export function PluginsSettingsPanel(): React.ReactNode { enabled: boolean, ): Promise => { const updated = await toggle.trigger({ provider: card.provider, id: card.id, enabled, scope }); - patchPlugin(updated?.plugin); + patchPlugin(updated.plugin); }; const onInstall = async (card: PluginCardView): Promise => { const result = await install.trigger({ provider: card.provider, id: card.id }); - patchPlugin(result?.plugin); + patchPlugin(result.plugin); // Most codex plugins are `ON_INSTALL`: the install lands but its apps stay unauthorized, and // LinkCode has no OAuth flow — say so rather than let it read as finished. - if (result?.pendingAuthApps && result.pendingAuthApps.length > 0) { + if (result.pendingAuthApps && result.pendingAuthApps.length > 0) { toastManager.add({ title: t('installNeedsAuthTitle', { title: card.title }), description: t('installNeedsAuth', { apps: result.pendingAuthApps.join('、') }), @@ -112,7 +111,7 @@ export function PluginsSettingsPanel(): React.ReactNode { const onUninstall = async (card: PluginCardView): Promise => { const result = await uninstall.trigger({ provider: card.provider, id: card.id }); - patchPlugin(result?.plugin); + patchPlugin(result.plugin); }; return ( diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 4bbef92c4..4b95f56bf 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -170,25 +170,27 @@ describe('injectedMcpServerNames', () => { }); }); -describe('account binding at session start', () => { - function storeWith(account: Account, agent: AgentKind): InMemoryProviderConfigStore { - const store = new InMemoryProviderConfigStore(); - // An account with nothing picked refuses to start; these cases are about endpoints. - store.update({ - providers: { [agent]: { enabled: true, enabledAccountIds: [account.id] } }, - accounts: [{ ...account, models: [{ id: 'picked-model' }] }], - }); - return store; - } +function storeWith(acc: Account, agent: AgentKind): InMemoryProviderConfigStore { + const store = new InMemoryProviderConfigStore(); + // An account with nothing picked refuses to start; these cases are about endpoints. + store.update({ + providers: { [agent]: { enabled: true, enabledAccountIds: [acc.id] } }, + accounts: [{ ...acc, models: [{ id: 'picked-model' }] }], + }); + return store; +} - const account = (overrides: Partial): Account => ({ +function account(overrides: Partial): Account { + return { id: 'acc_1', label: 'Test', credential: { type: 'api-key', key: 'sk-test' }, createdAt: 0, ...overrides, - }); + }; +} +describe('account binding at session start', () => { it('refuses an agent whose account picked no model, but lets one with none resolve its own', async () => { const store = new InMemoryProviderConfigStore(); store.update({ accounts: [account({ service: 'openai-api' })] }); @@ -248,16 +250,19 @@ describe('account binding at session start', () => { describe('custom MCP injection at session start', () => { it('folds enabled custom servers in for claude-code, codex, and opencode', async () => { - for (const kind of ['claude-code', 'codex', 'opencode'] as const) { - const resolver = new SessionStartOptionsResolver( - new InMemoryProviderConfigStore(), - undefined, - undefined, - customService(customEntry('github'), customEntry('disabled-one', false)), - ); - const { options: resolved, warnings } = await Effect.runPromise( - resolver.resolve({ kind, cwd: '/repo' }, SESSION), - ); + const kinds = ['claude-code', 'codex', 'opencode'] as const; + const results = await Promise.all( + kinds.map((kind) => { + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + customService(customEntry('github'), customEntry('disabled-one', false)), + ); + return Effect.runPromise(resolver.resolve({ kind, cwd: '/repo' }, SESSION)); + }), + ); + for (const { options: resolved, warnings } of results) { expect(resolved.mcpServers).toEqual([customEntry('github').server]); expect(warnings).toEqual([]); } diff --git a/scripts/dev-marketplace.mts b/scripts/dev-marketplace.mts index ef4189f90..d01d56753 100644 --- a/scripts/dev-marketplace.mts +++ b/scripts/dev-marketplace.mts @@ -80,7 +80,7 @@ const manifest = { }; // Minimal newline-delimited-JSON stdio MCP server with zero dependencies. -const MCP_PAYLOAD = `#!/usr/bin/env node +const MCP_PAYLOAD = String.raw`#!/usr/bin/env node 'use strict'; const readline = require('node:readline'); @@ -98,10 +98,10 @@ const ECHO_TOOL = { }; function reply(id, result) { - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\\n'); + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); } function replyError(id, code, message) { - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\\n'); + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n'); } const rl = readline.createInterface({ input: process.stdin }); From d4b91364bce1bc3069389ec318c904e52dd8b075 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Wed, 26 Aug 2026 01:36:55 +0800 Subject: [PATCH 08/19] docs(plugin-store): document the harmless publish/persist crash window --- apps/daemon/src/plugin-store/store.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 1ee3f3725..598c319ee 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -245,6 +245,8 @@ async function installExclusive( retiredDir = retirePluginPackage(targetDir); renameSync(stagingDir, targetDir); published = true; + // Publish before persisting so a failed registry write can roll the package back; a hard kill + // between the two leaves only a harmless orphan/stale-integrity the next install reclaims. upsertRegistry(record); } catch (error) { try { From 3050e53a5149fd28b3d6053fb61030766a8f56f9 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:01:24 +0800 Subject: [PATCH 09/19] feat(schema): type-check plugin setting defaults and add secret-presence wire bits --- .../schema/src/model/__tests__/plugin.test.ts | 15 ++++ .../schema/src/model/linkcode-plugin.ts | 58 ++++++++++++++ .../foundation/schema/src/wire/message.ts | 2 +- .../schema/src/wire/plugin-config.ts | 8 +- .../tests/contract/wire/plugin-config.test.ts | 77 +++++++++++++++++++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 packages/foundation/schema/tests/contract/wire/plugin-config.test.ts diff --git a/packages/foundation/schema/src/model/__tests__/plugin.test.ts b/packages/foundation/schema/src/model/__tests__/plugin.test.ts index e2ddea371..c945417ab 100644 --- a/packages/foundation/schema/src/model/__tests__/plugin.test.ts +++ b/packages/foundation/schema/src/model/__tests__/plugin.test.ts @@ -350,6 +350,21 @@ describe('LinkCode plugin package contracts', () => { ).toBe(false); }); + it("rejects a default that does not match the setting's type", () => { + const accepts = (field: unknown) => + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + settings: { ...mailManifest.settings, flag: field }, + }).success; + expect(accepts({ type: 'boolean', default: 'false' })).toBe(false); + expect(accepts({ type: 'number', default: true })).toBe(false); + expect(accepts({ type: 'string', default: 42 })).toBe(false); + expect(accepts({ type: 'enum', enum: ['163', 'qq'], default: 'gmail' })).toBe(false); + expect(accepts({ type: 'boolean', default: true })).toBe(true); + expect(accepts({ type: 'number', default: 42 })).toBe(true); + expect(accepts({ type: 'enum', enum: ['163', 'qq'], default: 'qq' })).toBe(true); + }); + it('the forward-compatible reader strips unknown mcp-server component keys', () => { expect( LinkCodePluginReleaseSchema.parse({ diff --git a/packages/foundation/schema/src/model/linkcode-plugin.ts b/packages/foundation/schema/src/model/linkcode-plugin.ts index 292ce46ae..344da8685 100644 --- a/packages/foundation/schema/src/model/linkcode-plugin.ts +++ b/packages/foundation/schema/src/model/linkcode-plugin.ts @@ -156,9 +156,54 @@ export const LinkCodePluginSettingFieldSchema = z path: ['secret'], }); } + if (field.default !== undefined) { + // valid-typeof compares against literals only, so spell the expected type out per branch. + const defaultMatchesType = + field.type === 'number' + ? typeof field.default === 'number' + : field.type === 'boolean' + ? typeof field.default === 'boolean' + : typeof field.default === 'string'; + if (!defaultMatchesType) { + ctx.addIssue({ + code: 'custom', + message: `A ${field.type} setting's default has the wrong JSON type`, + path: ['default'], + }); + } else if ( + field.type === 'enum' && + field.enum !== undefined && + typeof field.default === 'string' && + !field.enum.includes(field.default) + ) { + ctx.addIssue({ + code: 'custom', + message: "An enum setting's default must be one of its options", + path: ['default'], + }); + } + } }); export type LinkCodePluginSettingField = z.infer; +/** Runtime check mirroring the manifest schema: does a stored/patched value fit the declared field? + * The daemon enforces this on writes and on upgrade reconciliation; the UI is never the authority. */ +export function isValidPluginSettingValue( + field: LinkCodePluginSettingField, + value: unknown, +): value is string | number | boolean { + switch (field.type) { + case 'boolean': + return typeof value === 'boolean'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'enum': + return typeof value === 'string' && field.enum?.includes(value) === true; + default: + return typeof value === 'string'; + } +} + export const LinkCodePluginSettingsSchema = z.record( z.string().refine(isSafeIdSegment, 'Expected a safe lowercase setting id'), LinkCodePluginSettingFieldSchema, @@ -308,6 +353,19 @@ export const LinkCodePluginReleaseSchema = z.object({ }); export type LinkCodePluginRelease = z.infer; +/** Only `mcp-server` components are projected into agents today: a release without one (skill-only, + * or gated on manifest `assets` nothing consumes yet) must not present as installable — installing + * it would report success while providing no functionality. Filtered at the catalog/install + * boundary until skill/asset projection ships. */ +export function isProjectablePluginRelease(release: { + manifest: { components: ReadonlyArray<{ kind: string }>; assets: readonly unknown[] }; +}): boolean { + return ( + release.manifest.components.some((component) => component.kind === 'mcp-server') && + release.manifest.assets.length === 0 + ); +} + /** Mutable local Store state, deliberately separate from manifest and marketplace release data. */ export const InstalledLinkCodePluginSchema = z.object({ id: LinkCodePluginIdSchema, diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 454c86262..fd63d3d3b 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 80 as const; +export const WIRE_PROTOCOL_VERSION = 81 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/src/wire/plugin-config.ts b/packages/foundation/schema/src/wire/plugin-config.ts index b79516400..7756409bb 100644 --- a/packages/foundation/schema/src/wire/plugin-config.ts +++ b/packages/foundation/schema/src/wire/plugin-config.ts @@ -11,7 +11,9 @@ const PluginConfigValueSchema = z.union([z.string(), z.number(), z.boolean()]); /** LinkCode plugin configuration wire variants. Read returns field schemas (so the client renders a * form without executing plugin code) plus masked values — secret fields are omitted, mirroring the - * custom-MCP masked-edit contract. Write is a per-key patch: typed values set, keys removed. */ + * custom-MCP masked-edit contract; `configuredSecrets` exposes only which secret fields hold a + * stored value, so the client can tell "blank = keep" from "blank = missing a required secret". + * Write is a per-key patch: typed values set, keys removed. */ export const pluginConfigWireVariants = [ z.object({ kind: z.literal('plugin-config.list.get'), @@ -26,6 +28,9 @@ export const pluginConfigWireVariants = [ version: LinkCodePluginVersionSchema, settings: LinkCodePluginSettingsSchema, values: z.record(z.string().min(1), PluginConfigValueSchema), + /** Presence bits for secret fields (ids only, never values). Optional so an older daemon's + * reply still parses; absence means "unknown", which clients must read as configured. */ + configuredSecrets: z.array(z.string().min(1)).optional(), }), ), }), @@ -41,5 +46,6 @@ export const pluginConfigWireVariants = [ replyTo: WireRequestIdSchema, pluginId: LinkCodePluginIdSchema, values: z.record(z.string().min(1), PluginConfigValueSchema), + configuredSecrets: z.array(z.string().min(1)).optional(), }), ] as const; diff --git a/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts b/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts new file mode 100644 index 000000000..172a8d1b7 --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts @@ -0,0 +1,77 @@ +import { parseWireMessage, WIRE_PROTOCOL_VERSION } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; + +function envelope(payload: unknown) { + return { v: WIRE_PROTOCOL_VERSION, id: 'message-1', ts: 1, payload }; +} + +const pluginView = { + id: 'linkcode/mail', + version: '1.0.0', + settings: { + account: { type: 'string', required: true }, + authcode: { type: 'password', secret: true, required: true }, + }, + values: { account: 'you@163.com' }, +}; + +describe('plugin-config wire schema', () => { + it('round-trips the masked list with secret presence bits', () => { + const reply = parseWireMessage( + envelope({ + kind: 'plugin-config.listed', + replyTo: 'request-1', + plugins: [{ ...pluginView, configuredSecrets: ['authcode'] }], + }), + ); + expect(reply.ok).toBe(true); + if (!reply.ok || reply.message.payload.kind !== 'plugin-config.listed') return; + expect(reply.message.payload.plugins[0]?.configuredSecrets).toEqual(['authcode']); + expect(reply.message.payload.plugins[0]?.values).toEqual({ account: 'you@163.com' }); + }); + + it('accepts pre-presence replies, so an older daemon still parses', () => { + expect( + parseWireMessage( + envelope({ kind: 'plugin-config.listed', replyTo: 'request-1', plugins: [pluginView] }), + ).ok, + ).toBe(true); + expect( + parseWireMessage( + envelope({ + kind: 'plugin-config.updated', + replyTo: 'request-1', + pluginId: 'linkcode/mail', + values: {}, + }), + ).ok, + ).toBe(true); + }); + + it('round-trips a per-key patch and its updated reply', () => { + expect( + parseWireMessage( + envelope({ + kind: 'plugin-config.set', + clientReqId: 'request-1', + pluginId: 'linkcode/mail', + set: { account: 'me@qq.com' }, + remove: ['preset'], + }), + ).ok, + ).toBe(true); + + const reply = parseWireMessage( + envelope({ + kind: 'plugin-config.updated', + replyTo: 'request-1', + pluginId: 'linkcode/mail', + values: { account: 'me@qq.com' }, + configuredSecrets: [], + }), + ); + expect(reply.ok).toBe(true); + if (!reply.ok || reply.message.payload.kind !== 'plugin-config.updated') return; + expect(reply.message.payload.configuredSecrets).toEqual([]); + }); +}); From 42497570fb8e94034de3f725ee336245052ec8d5 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:01:57 +0800 Subject: [PATCH 10/19] feat(engine): manifest-authoritative plugin-config writes, secret-presence views, codex mcp preflight --- .../src/__tests__/plugin-config.test.ts | 93 ++++++++++++++++++ .../src/__tests__/start-options-mcp.test.ts | 87 ++++++++++++++++ packages/host/engine/src/index.ts | 1 + .../src/plugin/config-request-handler.ts | 6 +- .../host/engine/src/plugin/config-service.ts | 50 +++++++--- .../host/engine/src/plugin/linkcode-store.ts | 65 ++++++++++++ .../src/session/start-options-resolver.ts | 98 +++++++++++-------- 7 files changed, 346 insertions(+), 54 deletions(-) create mode 100644 packages/host/engine/src/__tests__/plugin-config.test.ts diff --git a/packages/host/engine/src/__tests__/plugin-config.test.ts b/packages/host/engine/src/__tests__/plugin-config.test.ts new file mode 100644 index 000000000..4ec1bbb5e --- /dev/null +++ b/packages/host/engine/src/__tests__/plugin-config.test.ts @@ -0,0 +1,93 @@ +import type { LinkCodePluginManifest } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { RequestError } from '../failure'; +import { PluginConfigService } from '../plugin/config-service'; +import type { InstalledLinkCodePluginEntry } from '../plugin/linkcode-store'; +import { InMemoryLinkCodePluginStore } from '../plugin/linkcode-store'; + +const MANIFEST: LinkCodePluginManifest = { + manifestVersion: 1, + id: 'linkcode/mail', + version: '0.1.0', + keywords: [], + components: [{ kind: 'mcp-server', name: 'mail', command: 'node', entry: 'dist/index.js' }], + settings: { + account: { type: 'string', required: true }, + authcode: { type: 'password', secret: true, required: true }, + token: { type: 'password', secret: true }, + preset: { type: 'enum', enum: ['163', 'qq'], default: '163' }, + }, + assets: [], +}; + +const ENTRY: InstalledLinkCodePluginEntry = { + installed: { + id: 'linkcode/mail', + version: '0.1.0', + marketplaceId: 'linkcode-official', + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + enabled: true, + path: '/store/linkcode/mail/0.1.0', + }, + manifest: MANIFEST, +}; + +function harness(settings: Record = {}) { + const store = new InMemoryLinkCodePluginStore(); + store.seed(ENTRY, settings); + return { store, service: new PluginConfigService(store) }; +} + +describe('PluginConfigService', () => { + it('exposes secret presence bits without ever exposing the values', async () => { + const { store, service } = harness({ + account: 'you@163.com', + authcode: 's3cret', + token: 't0ken', + }); + + const [view] = service.list(); + expect(view?.values).toEqual({ account: 'you@163.com', preset: '163' }); + expect(view?.configuredSecrets).toEqual(['authcode', 'token']); + expect(JSON.stringify(view)).not.toContain('s3cret'); + expect(JSON.stringify(view)).not.toContain('t0ken'); + + await store.setSettings('linkcode/mail', { remove: ['token'] }); + expect(service.list()[0]?.configuredSecrets).toEqual(['authcode']); + }); + + it('maps a manifest-violating patch to invalid_request and persists nothing', async () => { + const { store, service } = harness({ account: 'you@163.com', authcode: 's3cret' }); + + const typeError = await Effect.runPromise( + service.applyPatch('linkcode/mail', { set: { account: 42 } }).pipe(Effect.flip), + ); + expect(typeError).toBeInstanceOf(RequestError); + expect((typeError as RequestError).code).toBe('invalid_request'); + + const requiredError = await Effect.runPromise( + service.applyPatch('linkcode/mail', { remove: ['authcode'] }).pipe(Effect.flip), + ); + expect((requiredError as RequestError).code).toBe('invalid_request'); + + // The UI never sends a blank secret ("blank = keep"); the daemon boundary refuses one. + const emptySecretError = await Effect.runPromise( + service.applyPatch('linkcode/mail', { set: { authcode: '' } }).pipe(Effect.flip), + ); + expect((emptySecretError as RequestError).code).toBe('invalid_request'); + + expect(store.getSettings('linkcode/mail')).toMatchObject({ + account: 'you@163.com', + authcode: 's3cret', + }); + }); + + it('rejects a patch for an unknown plugin as not_found', async () => { + const { service } = harness(); + const error = await Effect.runPromise( + service.applyPatch('linkcode/ghost', { set: { account: 'x' } }).pipe(Effect.flip), + ); + expect((error as RequestError).code).toBe('not_found'); + }); +}); diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 4b95f56bf..b6f66422a 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -553,4 +553,91 @@ describe('LinkCode plugin MCP injection at session start', () => { ]); expect(warnings).toEqual([]); }); + + it('skips a LinkCode plugin whose MCP name collides with a native Codex plugin', async () => { + const store = new InMemoryLinkCodePluginStore(); + store.seed( + { + installed: { + id: 'linkcode/mail', + version: '0.1.0', + marketplaceId: 'linkcode-official', + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + enabled: true, + path: '/store/plugins/linkcode/mail/0.1.0', + }, + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '0.1.0', + keywords: ['mail'], + components: [ + { kind: 'mcp-server', name: 'mail', command: 'node', entry: 'dist/index.js' }, + ], + assets: [], + }, + }, + {}, + ); + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + undefined, + // Codex's MCP space is shared and un-namespaced: the injected config would silently + // override the native plugin's, so the collision is a warning, not an override. + pluginServiceWithMcp('mail'), + store, + ); + + const { options, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + + expect(options.mcpServers).toBeUndefined(); + expect(warnings).toEqual([{ serverName: 'mail', reason: 'name-conflict' }]); + }); + + it('skips LinkCode plugin injection when the strict Codex preflight fails', async () => { + const store = new InMemoryLinkCodePluginStore(); + store.seed( + { + installed: { + id: 'linkcode/mail', + version: '0.1.0', + marketplaceId: 'linkcode-official', + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + enabled: true, + path: '/store/plugins/linkcode/mail/0.1.0', + }, + manifest: { + manifestVersion: 1, + id: 'linkcode/mail', + version: '0.1.0', + keywords: ['mail'], + components: [ + { kind: 'mcp-server', name: 'mail', command: 'node', entry: 'dist/index.js' }, + ], + assets: [], + }, + }, + {}, + ); + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + undefined, + pluginServiceWithFailedMcpPreflight(), + store, + ); + + const { options, warnings } = await Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + + // Without the native name set an override cannot be ruled out — skip, don't inject. + expect(options.mcpServers).toBeUndefined(); + expect(warnings).toEqual([{ serverName: 'mail', reason: 'provider-preflight-failed' }]); + }); }); diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index e3ba839b1..ddcd500bf 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -16,6 +16,7 @@ export type { PluginConfigPatch, PluginConfigValue, } from './plugin/linkcode-store'; +export { PluginConfigValidationError, validatePluginConfigPatch } from './plugin/linkcode-store'; export type { LinkCodeMarketplaceService, MarketplaceCatalogEntry, diff --git a/packages/host/engine/src/plugin/config-request-handler.ts b/packages/host/engine/src/plugin/config-request-handler.ts index 6da78a133..10a53b02d 100644 --- a/packages/host/engine/src/plugin/config-request-handler.ts +++ b/packages/host/engine/src/plugin/config-request-handler.ts @@ -29,6 +29,7 @@ export class LinkCodePluginConfigRequestHandler { version: view.version, settings: view.settings, values: view.values, + configuredSecrets: [...view.configuredSecrets], })); this.transport.send( createWireMessage({ @@ -47,13 +48,14 @@ export class LinkCodePluginConfigRequestHandler { .pipe( Effect.flatMap(() => Effect.sync(() => { - const values = this.config.maskedValues(payload.pluginId); + const masked = this.config.maskedView(payload.pluginId); this.transport.send( createWireMessage({ kind: 'plugin-config.updated', replyTo: payload.clientReqId, pluginId: payload.pluginId, - values, + values: masked.values, + configuredSecrets: [...masked.configuredSecrets], }), ); }), diff --git a/packages/host/engine/src/plugin/config-service.ts b/packages/host/engine/src/plugin/config-service.ts index 2b2f45007..9a87baff4 100644 --- a/packages/host/engine/src/plugin/config-service.ts +++ b/packages/host/engine/src/plugin/config-service.ts @@ -6,17 +6,21 @@ import type { LinkCodePluginStore, PluginConfigValue, } from './linkcode-store'; +import { PluginConfigValidationError } from './linkcode-store'; /** A plugin's settings as the wire exposes them: the manifest's field schemas plus the non-secret * values. Secret fields appear in `settings` (so the client renders a masked input) but never in - * `values` — the same masked-edit contract custom-MCP uses. Every installed plugin appears here, - * even one declaring no settings (`settings: {}`), so this list doubles as the installed - * inventory: "has settings" must never decide "is installed". */ + * `values` — the same masked-edit contract custom-MCP uses; `configuredSecrets` carries presence + * only, so the client can tell "blank keeps the stored secret" from "no secret stored yet". Every + * installed plugin appears here, even one declaring no settings (`settings: {}`), so this list + * doubles as the installed inventory: "has settings" must never decide "is installed". */ export interface PluginConfigView { readonly id: string; readonly version: string; readonly settings: LinkCodePluginSettings; readonly values: Readonly>; + /** Ids of secret fields with a stored value — presence bits only, never the values. */ + readonly configuredSecrets: readonly string[]; } /** @@ -49,21 +53,27 @@ export class PluginConfigService { await this.store.setSettings(pluginId, patch); }, catch: (cause) => - new OperationError({ - subsystem: 'store', - operation: 'plugin-config.set', - publicMessage: 'Failed to persist the plugin config', - cause, - }), + cause instanceof PluginConfigValidationError + ? new RequestError({ code: 'invalid_request', message: cause.message }) + : new OperationError({ + subsystem: 'store', + operation: 'plugin-config.set', + publicMessage: 'Failed to persist the plugin config', + cause, + }), }); }); } /** Post-patch masked re-read, so the client patches one cache entry instead of re-listing. */ - maskedValues(pluginId: string): Readonly> { + maskedView(pluginId: string): Pick { const entry = this.store.get(pluginId); - if (entry === undefined) return {}; - return maskValues(entry, this.store.getSettings(pluginId)); + if (entry === undefined) return { values: {}, configuredSecrets: [] }; + const merged = this.store.getSettings(pluginId); + return { + values: maskValues(entry, merged), + configuredSecrets: configuredSecrets(entry, merged), + }; } } @@ -76,9 +86,25 @@ function viewFor( version: entry.installed.version, settings: entry.manifest.settings ?? {}, values: maskValues(entry, merged), + configuredSecrets: configuredSecrets(entry, merged), }; } +/** Presence bits for secret fields: the merged read holds stored secret values, and only their + * field ids may cross the wire. */ +function configuredSecrets( + entry: InstalledLinkCodePluginEntry, + merged: Record, +): string[] { + const settings = entry.manifest.settings; + if (settings === undefined) return []; + const ids: string[] = []; + for (const [fieldId, field] of Object.entries(settings)) { + if (field.secret === true && fieldId in merged) ids.push(fieldId); + } + return ids; +} + function maskValues( entry: InstalledLinkCodePluginEntry, merged: Record, diff --git a/packages/host/engine/src/plugin/linkcode-store.ts b/packages/host/engine/src/plugin/linkcode-store.ts index 5bec8229e..6f58d3249 100644 --- a/packages/host/engine/src/plugin/linkcode-store.ts +++ b/packages/host/engine/src/plugin/linkcode-store.ts @@ -3,7 +3,10 @@ import type { LinkCodeMarketplaceId, LinkCodePluginManifest, LinkCodePluginRelease, + LinkCodePluginSettingField, + LinkCodePluginSettings, } from '@linkcode/schema'; +import { isValidPluginSettingValue } from '@linkcode/schema'; /** An installed LinkCode plugin: its install record plus the parsed manifest. */ export interface InstalledLinkCodePluginEntry { @@ -20,6 +23,65 @@ export interface PluginConfigPatch { readonly remove?: readonly string[]; } +/** A patch rejected by the daemon's manifest validation; surfaced as `invalid_request`, never + * persisted. Distinct from an I/O failure, which is an `OperationError`. */ +export class PluginConfigValidationError extends Error { + override name = 'PluginConfigValidationError'; +} + +/** + * The daemon-side authority for `plugin-config.set`: the wire only guarantees primitive values, so + * every store validates the patch against the manifest before persisting — each `set` value must + * fit its declared field (type, enum membership), and the post-patch state must satisfy every + * `required` field. `current` is the store's merged effective values (defaults folded, stored + * secrets included), exactly what {@link LinkCodePluginStore.getSettings} returns. + */ +export function validatePluginConfigPatch( + settings: LinkCodePluginSettings, + current: Readonly>, + patch: PluginConfigPatch, +): void { + if (patch.set) { + for (const [fieldId, value] of Object.entries(patch.set)) { + const field: LinkCodePluginSettingField | undefined = settings[fieldId]; + // eslint-disable-next-line sukka/prefer-nullthrow -- the write boundary requires the typed error the engine maps to invalid_request, not nullthrow's TypeError + if (field === undefined) { + throw new PluginConfigValidationError(`Unknown plugin setting: ${fieldId}`); + } + if (!isValidPluginSettingValue(field, value)) { + throw new PluginConfigValidationError( + `Invalid value for plugin setting ${fieldId}: expected ${field.type}`, + ); + } + // The UI contract never sends a blank secret ("blank = keep"); an empty string would be + // stored in the vault and then read back as "configured". Reject it at the authority. + if (value === '' && field.secret === true) { + throw new PluginConfigValidationError( + `Plugin setting ${fieldId} must not be an empty secret`, + ); + } + } + } + const effective: Record = { ...current }; + for (const fieldId of patch.remove ?? []) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- the patch is a per-key removal over a plain record + delete effective[fieldId]; + } + for (const [fieldId, value] of Object.entries(patch.set ?? {})) effective[fieldId] = value; + // A removal of a defaulted field re-exposes the manifest default on the next read, so fold it + // back in here rather than rejecting the patch as missing a required value. + for (const [fieldId, field] of Object.entries(settings)) { + if (!field.secret && field.default !== undefined && !(fieldId in effective)) { + effective[fieldId] = field.default; + } + } + for (const [fieldId, field] of Object.entries(settings)) { + if (field.required === true && !(fieldId in effective)) { + throw new PluginConfigValidationError(`Missing required plugin setting: ${fieldId}`); + } + } +} + /** * Daemon-owned LinkCode plugin store. Enumerates installed plugins (manifest + install record), * reads/writes their declared settings (non-secret in `config.json`, secret in the vault — the @@ -78,6 +140,9 @@ export class InMemoryLinkCodePluginStore implements LinkCodePluginStore { } setSettings(pluginId: string, patch: PluginConfigPatch): Promise { + // Same manifest validation as the daemon store: tests exercise the real write contract. + const settings = this.entries.get(pluginId)?.manifest.settings ?? {}; + validatePluginConfigPatch(settings, this.getSettings(pluginId), patch); let map = this.values.get(pluginId); if (!map) { map = new Map(); diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 8b4820ad7..c969e002e 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -70,7 +70,7 @@ export class SessionStartOptionsResolver { ); } const custom = yield* withCustomMcpServers(defaults.options); - const pluginInjected = withPluginMcpServers(custom.options, custom.warnings); + const pluginInjected = yield* withPluginMcpServers(custom.options, custom.warnings); const resolved = withSimulatorMcp(pluginInjected.options, sessionId); const upstream = translationUpstream(resolved); if (!upstream) return { options: resolved, ...account, warnings: pluginInjected.warnings }; @@ -180,15 +180,17 @@ export class SessionStartOptionsResolver { /** Fold enabled LinkCode plugin mcp-server components into the session's server list, resolving * each component's `env` mapping against the plugin's stored settings. Same warning contract as - * custom-MCP: an unsupported agent or a name collision is a user-visible advisory, not a drop. */ + * custom-MCP — an unsupported agent, a failed Codex preflight, or a name collision is a + * user-visible advisory, not a drop — including Codex's shared, un-namespaced MCP space, where + * an enabled native plugin of the same name would be silently overridden. */ private withPluginMcpServers( options: StartOptions, warnings: McpWarning[], - ): { options: StartOptions; warnings: McpWarning[] } { + ): Effect.Effect<{ options: StartOptions; warnings: McpWarning[] }> { const store = this.linkCodePluginStore; - if (store === undefined) return { options, warnings }; + if (store === undefined) return Effect.succeed({ options, warnings }); const entries = store.list().filter((entry) => entry.installed.enabled); - if (entries.length === 0) return { options, warnings }; + if (entries.length === 0) return Effect.succeed({ options, warnings }); if (!MCP_CAPABLE_AGENT_KINDS.has(options.kind)) { for (const entry of entries) { for (const component of entry.manifest.components) { @@ -197,45 +199,61 @@ export class SessionStartOptionsResolver { } } } - return { options, warnings }; + return Effect.succeed({ options, warnings }); } - const servers = [...(options.mcpServers ?? [])]; - for (const entry of entries) { - const settings = store.getSettings(entry.installed.id); - for (const component of entry.manifest.components) { - if (component.kind !== 'mcp-server') continue; - if (servers.some((server) => server.name === component.name)) { - warnings.push({ serverName: component.name, reason: 'name-conflict' }); - continue; - } - const env: Record = {}; - if (component.env) { - for (const [envVar, settingId] of Object.entries(component.env)) { - if (settingId in settings) env[envVar] = String(settings[settingId]); + const pluginNames = + options.kind === 'codex' && this.plugins + ? this.plugins + .enabledMcpServerNames('codex', { cwd: options.cwd }) + .pipe(Effect.match({ onSuccess: (names) => names, onFailure: () => null })) + : Effect.succeed(new Set()); + return Effect.map(pluginNames, (names) => { + const servers = [...(options.mcpServers ?? [])]; + for (const entry of entries) { + const settings = store.getSettings(entry.installed.id); + for (const component of entry.manifest.components) { + if (component.kind !== 'mcp-server') continue; + if (names === null) { + // Without the native name set an override cannot be ruled out — skip, don't inject. + warnings.push({ serverName: component.name, reason: 'provider-preflight-failed' }); + continue; + } + if ( + names.has(component.name) || + servers.some((server) => server.name === component.name) + ) { + warnings.push({ serverName: component.name, reason: 'name-conflict' }); + continue; + } + const env: Record = {}; + if (component.env) { + for (const [envVar, settingId] of Object.entries(component.env)) { + if (settingId in settings) env[envVar] = String(settings[settingId]); + } } + // No missing-config advisory yet: shipped clients validate `reason` against the old enum + // and would drop the whole session.started frame, so emission waits for a tolerant floor. + const server: McpServer = { + type: 'stdio', + name: component.name, + command: component.command, + ...(component.entry && { + args: [resolvePath(entry.installed.path, component.entry), ...(component.args ?? [])], + }), + ...(!component.entry && component.args && { args: component.args }), + ...(!isObjectEmpty(env) && { env }), + }; + servers.push(server); } - // No missing-config advisory yet: shipped clients validate `reason` against the old enum - // and would drop the whole session.started frame, so emission waits for a tolerant floor. - const server: McpServer = { - type: 'stdio', - name: component.name, - command: component.command, - ...(component.entry && { - args: [resolvePath(entry.installed.path, component.entry), ...(component.args ?? [])], - }), - ...(!component.entry && component.args && { args: component.args }), - ...(!isObjectEmpty(env) && { env }), - }; - servers.push(server); } - } - return { - options: - servers.length === 0 && options.mcpServers === undefined - ? options - : { ...options, mcpServers: servers }, - warnings, - }; + return { + options: + servers.length === 0 && options.mcpServers === undefined + ? options + : { ...options, mcpServers: servers }, + warnings, + }; + }); } /** Append the session's simulator MCP endpoint for agents whose SDK can consume it. */ From f9858abd9fb0fba3b9a1053c1fac5373f77c4544 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:02:12 +0800 Subject: [PATCH 11/19] feat(plugin-store): crash-safe uninstall with retry markers, fail-closed registry reads, upgrade settings reconciliation --- apps/daemon/src/__tests__/marketplace.test.ts | 50 +++- .../daemon/src/__tests__/plugin-store.test.ts | 236 ++++++++++++++- apps/daemon/src/marketplace/service.ts | 34 ++- apps/daemon/src/plugin-store/paths.ts | 6 + apps/daemon/src/plugin-store/store.ts | 281 +++++++++++++++++- 5 files changed, 586 insertions(+), 21 deletions(-) diff --git a/apps/daemon/src/__tests__/marketplace.test.ts b/apps/daemon/src/__tests__/marketplace.test.ts index b3c4b41e9..71c88f195 100644 --- a/apps/daemon/src/__tests__/marketplace.test.ts +++ b/apps/daemon/src/__tests__/marketplace.test.ts @@ -44,7 +44,9 @@ const INDEX = { id: 'arcbox/latex', version: '1.2.0', keywords: [], - components: [{ kind: 'skill', name: 'latex', entry: 'skills/latex/SKILL.md' }], + components: [ + { kind: 'mcp-server', name: 'latex', command: 'node', entry: 'dist/index.js' }, + ], assets: [], }, artifact: { @@ -58,6 +60,34 @@ const INDEX = { ], }; +/** A release with nothing the host can project: no mcp-server component, so installing it would + * report success while providing no functionality. Filtered at the catalog/install boundary. */ +const SKILL_ONLY_INDEX = { + ...INDEX, + plugins: [ + { + id: 'linkcode/notes', + releases: [ + { + manifest: { + manifestVersion: 1, + id: 'linkcode/notes', + version: '0.2.0', + keywords: [], + components: [{ kind: 'skill', name: 'notes', entry: 'skills/notes/SKILL.md' }], + assets: [], + }, + artifact: { + urls: ['releases/notes-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + }, + ], + }, + ], +}; + function fakeResponse( status: number, body = '', @@ -278,4 +308,22 @@ describe('DaemonLinkCodeMarketplaceService.resolveRelease', () => { expect(service.resolveRelease(identity)).toBeUndefined(); } }); + + it('hides a skill-only release from the catalog and refuses to resolve it for install', async () => { + const fetchIndex = vi.fn(() => + Promise.resolve(fakeResponse(200, JSON.stringify(SKILL_ONLY_INDEX))), + ); + const service = new DaemonLinkCodeMarketplaceService(MARKETPLACES, fetchIndex); + + const result = await service.refresh('linkcode-official'); + + expect(result.releases).toEqual([]); + expect( + service.resolveRelease({ + marketplaceId: 'linkcode-official', + pluginId: 'linkcode/notes', + version: '0.2.0', + }), + ).toBeUndefined(); + }); }); diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts index eda7dff42..6b1a7ab1c 100644 --- a/apps/daemon/src/__tests__/plugin-store.test.ts +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -7,7 +7,7 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import type { InstalledLinkCodePlugin, LinkCodePluginManifest, @@ -15,7 +15,14 @@ import type { } from '@linkcode/schema'; import { wait } from 'foxts/wait'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makePluginTmpDir, pluginPackageDir, pluginRegistryPath } from '../plugin-store/paths'; +import { loadPluginConfigValues } from '../config'; +import { + makePluginTmpDir, + pluginPackageDir, + pluginRegistryPath, + pluginsRoot, + pluginUninstallTombstonePath, +} from '../plugin-store/paths'; import { DaemonLinkCodePluginStore } from '../plugin-store/store'; import { createInMemoryVault } from './fixtures/in-memory-vault'; @@ -343,12 +350,14 @@ describe('DaemonLinkCodePluginStore', () => { it('restores a retired package only to its exact recorded version', () => { const legacy = record('0.1.0'); const live = record('0.2.0'); + // Both versions are really on disk, so the sweep must touch neither except the 0.2.0 restore. + writePackage(legacy, manifest('0.1.0', 'legacy-skill')); writeRegistry([legacy, live]); const retired = join(live.path, '..', '.tmp-retired-999-versioned'); mkdirSync(retired, { recursive: true }); writeFileSync(join(retired, 'manifest.json'), JSON.stringify(manifest('0.2.0', 'live-skill'))); expect([existsSync(legacy.path), existsSync(live.path), existsSync(retired)]).toEqual([ - false, + true, false, true, ]); @@ -356,10 +365,11 @@ describe('DaemonLinkCodePluginStore', () => { const store = new DaemonLinkCodePluginStore(createInMemoryVault()); expect([existsSync(legacy.path), existsSync(live.path), existsSync(retired)]).toEqual([ - false, + true, true, false, ]); + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('live-skill'); expect(store.get('arcbox/latex')?.installed.version).toBe('0.2.0'); }); @@ -490,4 +500,222 @@ describe('DaemonLinkCodePluginStore', () => { authcode: 'old-secret', }); }); + + it('blocks install and uninstall when the registry is malformed, leaving it untouched', async () => { + const live = record('0.1.0'); + writePackage(live, manifest('0.1.0')); + writeRegistry([live]); + writeFileSync(pluginRegistryPath(), '{corrupted'); + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(manifest('0.2.0'))); + }); + const release = { + manifest: manifest('0.2.0'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + // A degraded read must never participate in an overwrite: both mutations fail closed. + await expect(store.install(release, 'linkcode-official')).rejects.toThrow(); + await expect(store.uninstall('arcbox/latex')).rejects.toThrow(); + + expect(readFileSync(pluginRegistryPath(), 'utf8')).toBe('{corrupted'); + expect(existsSync(pluginPackageDir('arcbox/latex', '0.2.0'))).toBe(false); + expect(existsSync(live.path)).toBe(true); + }); + + it('refuses mutations when a registry record points outside the store', async () => { + const escaped = { ...record('0.1.0'), path: join(dirname(pluginsRoot()), 'escaped-package') }; + mkdirSync(escaped.path, { recursive: true }); + writeFileSync(join(escaped.path, 'manifest.json'), JSON.stringify(manifest('0.1.0'))); + writeRegistry([escaped]); + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + // Reads degrade (the record is dropped); mutations fail closed and never touch the path. + expect(store.list()).toEqual([]); + await expect(store.uninstall('arcbox/latex')).rejects.toThrow('Invalid plugin install record'); + + expect(existsSync(escaped.path)).toBe(true); + }); + + it('restores the retired package when the registry write fails during uninstall', async () => { + const live = record('0.2.0'); + writePackage(live, manifest('0.2.0', 'live-skill')); + writeRegistry([live]); + mocks.renameFailureDestination = pluginRegistryPath(); + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + await expect(store.uninstall('arcbox/latex')).rejects.toThrow('injected rename failure'); + + expect(store.get('arcbox/latex')?.manifest.components[0]?.name).toBe('live-skill'); + expect(JSON.parse(readFileSync(pluginRegistryPath(), 'utf8'))).toMatchObject([ + { id: 'arcbox/latex', version: '0.2.0' }, + ]); + expect(readdirSync(join(live.path, '..')).filter((name) => name.startsWith('.tmp-'))).toEqual( + [], + ); + expect(readdirSync(pluginsRoot()).filter((name) => name.startsWith('.uninstall-'))).toEqual([]); + }); + + it('retries a failed settings cleanup at boot via the uninstall marker', async () => { + const installed = record('0.1.0'); + writePackage(installed, settingsManifest('0.1.0')); + writeRegistry([installed]); + const baseVault = createInMemoryVault(); + const store = new DaemonLinkCodePluginStore(baseVault); + await store.setSettings('arcbox/latex', { + set: { account: 'a@example.com', authcode: 'secret-a' }, + }); + let failReplaceAll = true; + const flakyVault = { + ...baseVault, + namespace(name: Parameters[0]) { + const secrets = baseVault.namespace(name); + if (name !== 'plugin') return secrets; + return { + ...secrets, + replaceAll(entries: ReadonlyMap) { + if (failReplaceAll) { + failReplaceAll = false; + throw new Error('vault write failed'); + } + secrets.replaceAll(entries); + }, + }; + }, + }; + + // The registry commits; the vault failure must not fail the uninstall — the marker retries. + await new DaemonLinkCodePluginStore(flakyVault).uninstall('arcbox/latex'); + + expect(store.get('arcbox/latex')).toBeUndefined(); + expect(loadPluginConfigValues('arcbox/latex')).toEqual({}); + expect(baseVault.refs.get('plugin:arcbox/latex/authcode')).toBe('secret-a'); + expect(existsSync(pluginUninstallTombstonePath('arcbox/latex'))).toBe(true); + + // Construction runs the boot sweep, which retries the cleanup and clears the marker. + expect(new DaemonLinkCodePluginStore(baseVault)).toBeDefined(); + + expect(baseVault.refs.get('plugin:arcbox/latex/authcode')).toBeUndefined(); + expect(existsSync(pluginUninstallTombstonePath('arcbox/latex'))).toBe(false); + }); + + it('rejects settings writes that violate the manifest field schemas', () => { + const installed = record('0.1.0'); + writePackage(installed, settingsManifest('0.1.0')); + writeRegistry([installed]); + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + expect(() => store.setSettings('arcbox/latex', { set: { account: 42 } })).toThrow( + 'Invalid value for plugin setting account', + ); + expect(() => store.setSettings('arcbox/latex', { set: { nickname: 'x' } })).toThrow( + 'Unknown plugin setting: nickname', + ); + // The UI contract never sends a blank secret ("blank = keep"); the daemon refuses to store one. + expect(() => store.setSettings('arcbox/latex', { set: { authcode: '' } })).toThrow( + 'must not be an empty secret', + ); + + expect(store.getSettings('arcbox/latex')).toEqual({}); + }); + + it('rejects a patch that leaves a required setting without any value', () => { + const installed = record('0.1.0'); + const requiredManifest = settingsManifest('0.1.0'); + requiredManifest.settings = { + ...requiredManifest.settings, + account: { type: 'string', required: true }, + authcode: { type: 'password', secret: true, required: true }, + }; + writePackage(installed, requiredManifest); + writeRegistry([installed]); + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + // A blank secret is "keep" only when a value exists; nothing is stored yet. + expect(() => store.setSettings('arcbox/latex', { set: { account: 'a@example.com' } })).toThrow( + 'Missing required plugin setting: authcode', + ); + + store.setSettings('arcbox/latex', { + set: { account: 'a@example.com', authcode: 'secret-a' }, + }); + expect(() => store.setSettings('arcbox/latex', { remove: ['account'] })).toThrow( + 'Missing required plugin setting: account', + ); + }); + + it('reconciles stored settings against the new manifest on upgrade', async () => { + // v0.1.0: plain + legacy in config.json, creds/quota/verbose/retained in the vault. v0.2.0 + // drops legacy, flips creds/quota/verbose to non-secret, turns plain into a secret password + // field, and keeps retained a secret number. + const v1: LinkCodePluginManifest = { + ...manifest('0.1.0'), + settings: { + plain: { type: 'string' }, + legacy: { type: 'string' }, + creds: { type: 'password', secret: true }, + quota: { type: 'number', secret: true }, + verbose: { type: 'boolean', secret: true }, + retained: { type: 'number', secret: true }, + }, + }; + const v2: LinkCodePluginManifest = { + ...manifest('0.2.0'), + settings: { + plain: { type: 'password', secret: true }, + creds: { type: 'string' }, + quota: { type: 'number' }, + verbose: { type: 'boolean' }, + retained: { type: 'number', secret: true }, + }, + }; + const installed = record('0.1.0'); + writePackage(installed, v1); + writeRegistry([installed]); + const vault = createInMemoryVault(); + const store = new DaemonLinkCodePluginStore(vault); + await store.setSettings('arcbox/latex', { + set: { plain: 'p', legacy: 'l', creds: 'c-value', quota: 42, verbose: true, retained: 7 }, + }); + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(v2)); + }); + const release = { + manifest: v2, + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + + await store.install(release, 'linkcode-official'); + + // non-secret → secret moved into the vault; secret → non-secret landed in config.json with its + // declared type restored from the vault's string form; the removed field is gone from both. + expect(loadPluginConfigValues('arcbox/latex')).toEqual({ + creds: 'c-value', + quota: 42, + verbose: true, + }); + expect(vault.refs.get('plugin:arcbox/latex/plain')).toBe('p'); + // A number secret that stays secret keeps its (stringified) vault value across the upgrade. + expect(vault.refs.get('plugin:arcbox/latex/retained')).toBe('7'); + expect(vault.refs.get('plugin:arcbox/latex/creds')).toBeUndefined(); + expect(vault.refs.get('plugin:arcbox/latex/quota')).toBeUndefined(); + expect(vault.refs.get('plugin:arcbox/latex/verbose')).toBeUndefined(); + expect(vault.refs.get('plugin:arcbox/latex/legacy')).toBeUndefined(); + expect(store.getSettings('arcbox/latex')).toEqual({ + plain: 'p', + creds: 'c-value', + quota: 42, + verbose: true, + retained: '7', + }); + }); }); diff --git a/apps/daemon/src/marketplace/service.ts b/apps/daemon/src/marketplace/service.ts index cda79d213..4f08f8392 100644 --- a/apps/daemon/src/marketplace/service.ts +++ b/apps/daemon/src/marketplace/service.ts @@ -12,7 +12,11 @@ import { } from 'node:fs'; import { dirname, join } from 'node:path'; import { fetchWithSystemProxy } from '@linkcode/assets'; -import type { LinkCodeMarketplaceService, MarketplaceRefreshResult } from '@linkcode/engine'; +import type { + LinkCodeMarketplaceService, + MarketplaceCatalogEntry, + MarketplaceRefreshResult, +} from '@linkcode/engine'; import type { LinkCodeMarketplaceConfigList, LinkCodeMarketplaceIndexReader, @@ -21,6 +25,7 @@ import type { LinkCodePluginRelease, } from '@linkcode/schema'; import { + isProjectablePluginRelease, LinkCodeMarketplaceIndexReaderSchema, LinkCodeMarketplaceRefreshStateSchema, } from '@linkcode/schema'; @@ -139,7 +144,9 @@ export class DaemonLinkCodeMarketplaceService implements LinkCodeMarketplaceServ const release = plugin?.releases.find( (candidate) => candidate.manifest.version === identity.version, ); - if (release === undefined) return undefined; + // Same boundary as the catalog filter: a release with nothing the host can project (skill-only, + // or gated on unconsumed manifest assets) must not install "successfully" into a no-op. + if (release === undefined || !isProjectablePluginRelease(release)) return undefined; return { ...release, artifact: { @@ -180,9 +187,26 @@ function parseIndex(body: string): LinkCodeMarketplaceIndexReader { function flattenReleases( index: LinkCodeMarketplaceIndexReader, ): MarketplaceRefreshResult['releases'] { - return index.plugins.flatMap((plugin) => - plugin.releases.map((release) => ({ pluginId: plugin.id, release })), - ); + const entries: MarketplaceCatalogEntry[] = []; + for (const plugin of index.plugins) { + for (const release of plugin.releases) { + if (!isProjectablePluginRelease(release)) { + // Deliberate, steady-state filtering: info would spam every refresh for marketplaces that + // legitimately carry skill-only releases. + logger.debug( + { + pluginId: plugin.id, + version: release.manifest.version, + operation: 'marketplace.release.filter', + }, + 'Hiding a marketplace release with no projectable component (skill projection and manifest assets are not supported yet)', + ); + continue; + } + entries.push({ pluginId: plugin.id, release }); + } + } + return entries; } function readRefreshState(marketplaceId: string): LinkCodeMarketplaceRefreshState | undefined { diff --git a/apps/daemon/src/plugin-store/paths.ts b/apps/daemon/src/plugin-store/paths.ts index ed01cd177..78cf6c5d3 100644 --- a/apps/daemon/src/plugin-store/paths.ts +++ b/apps/daemon/src/plugin-store/paths.ts @@ -31,6 +31,12 @@ export const PLUGIN_STAGING_PREFIX = '.tmp-'; * to restore them when a hard kill interrupts publishing. */ export const PLUGIN_RETIRED_INFIX = 'retired-'; +/** Marks a committed uninstall whose settings cleanup (config.json + vault) has not finished; the + * boot sweep retries the cleanup, or discards the marker when the plugin is still registered. */ +export function pluginUninstallTombstonePath(pluginId: string): string { + return join(pluginsRoot(), `.uninstall-${pluginId.replaceAll(RE_PATH_SEP, '~')}.json`); +} + /** Allocate staging beside the target for same-volume rename; create only the parent so failed * installs do not leave an empty version directory. */ export function makePluginTmpDir(pluginId: string, version: string): string { diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 598c319ee..ebd4a3b1b 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -22,18 +22,23 @@ import type { PluginConfigPatch, PluginConfigValue, } from '@linkcode/engine'; +import { validatePluginConfigPatch } from '@linkcode/engine'; import type { InstalledLinkCodePlugin, LinkCodePluginManifest, LinkCodePluginRelease, + LinkCodePluginSettingField, ManagedAssetArtifact, } from '@linkcode/schema'; import { InstalledLinkCodePluginSchema, isAllowedMarketplaceUrl, + isValidPluginSettingValue, + LinkCodePluginIdSchema, LinkCodePluginManifestReaderSchema, } from '@linkcode/schema'; import { extractErrorMessage } from 'foxts/extract-error-message'; +import { nullthrow } from 'foxts/guard'; import { noop } from 'foxts/noop'; import { extract as tarExtract } from 'tar'; import { loadPluginConfigValues, pluginSecretStore, savePluginConfigValues } from '../config'; @@ -46,6 +51,7 @@ import { pluginPackageDir, pluginRegistryPath, pluginsRoot, + pluginUninstallTombstonePath, } from './paths'; /** Daemon-backed LinkCode plugin store: reads the install registry + on-disk manifests, splits @@ -55,6 +61,7 @@ import { export class DaemonLinkCodePluginStore implements LinkCodePluginStore { constructor(private readonly vault: SecretVault) { sweepStagingDirs(); + sweepUninstallTombstones(pluginSecretStore(this.vault)); } list(): InstalledLinkCodePluginEntry[] { @@ -98,6 +105,9 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { throw new Error(`Plugin ${pluginId} declares no settings`); } const settings = manifest.settings; + // The wire only guarantees primitive values — validate against the manifest before persisting. + // Throws PluginConfigValidationError, which the engine maps to `invalid_request`. + validatePluginConfigPatch(settings, this.getSettings(pluginId), patch); const secrets = pluginSecretStore(this.vault); const previousNonSecret = loadPluginConfigValues(pluginId); let nextNonSecret = { ...previousNonSecret }; @@ -161,20 +171,73 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { release: LinkCodePluginRelease, marketplaceId: string, ): Promise { - return this.serialize(release.manifest.id, () => installExclusive(release, marketplaceId)); + return this.serialize(release.manifest.id, () => + installExclusive(release, marketplaceId, pluginSecretStore(this.vault)), + ); } uninstall(pluginId: string): Promise { return this.serialize(pluginId, () => { - const records = readRegistry(); + const records = readRegistryStrict(); const matches = records.filter((entry) => entry.id === pluginId); + const tombstone = pluginUninstallTombstonePath(pluginId); if (matches.length > 0) { - for (const record of matches) rmSync(record.path, { recursive: true, force: true }); - writeRegistry(records.filter((entry) => entry.id !== pluginId)); + // Retire each live package before committing so a failed registry write can restore them, + // exactly as install's publish does; a mid-loop retire failure restores what already moved. + const retired: Array<{ + record: InstalledLinkCodePlugin; + retiredDir: string | undefined; + }> = []; + try { + for (const record of matches) { + retired.push({ record, retiredDir: retireForUninstall(record) }); + } + } catch (error) { + for (const { record, retiredDir } of retired) { + if (retiredDir !== undefined) restorePluginPackage(retiredDir, record.path, pluginId); + } + throw error; + } + // Written before the registry commit so a crash on either side leaves a retryable cleanup; + // the boot sweep discards it unread while the plugin is still registered. + writeUninstallTombstone(tombstone, pluginId); + try { + writeRegistry(records.filter((entry) => entry.id !== pluginId)); + } catch (error) { + for (const { record, retiredDir } of retired) { + if (retiredDir !== undefined) restorePluginPackage(retiredDir, record.path, pluginId); + } + rmSync(tombstone, { force: true }); + throw error; + } + for (const { retiredDir } of retired) { + if (retiredDir === undefined) continue; + try { + rmSync(retiredDir, { recursive: true, force: true }); + } catch (error) { + logger.warn( + { error, pluginId, path: retiredDir, operation: 'plugin.uninstall.gc-retired' }, + 'Failed to remove the retired plugin package after uninstall', + ); + } + } + } else { + writeUninstallTombstone(tombstone, pluginId); } - // Non-secret values are dropped by writing an empty block; secret values are pruned below. - savePluginConfigValues(pluginId, {}); - prunePluginSecrets(pluginSecretStore(this.vault), pluginId); + // The registry has already committed (or never held the plugin), so a cleanup failure must + // not fail the uninstall — the tombstone retries it at the next boot. + try { + // Non-secret values are dropped by writing an empty block; secret values are pruned. + savePluginConfigValues(pluginId, {}); + prunePluginSecrets(pluginSecretStore(this.vault), pluginId); + } catch (error) { + logger.error( + { error, pluginId, operation: 'plugin.uninstall.cleanup' }, + 'Plugin settings cleanup failed after uninstall; the cleanup marker retries it at boot', + ); + return; + } + rmSync(tombstone, { force: true }); }); } @@ -194,6 +257,7 @@ export class DaemonLinkCodePluginStore implements LinkCodePluginStore { async function installExclusive( release: LinkCodePluginRelease, marketplaceId: string, + secrets: SecretStore, ): Promise { const { manifest, artifact } = release; if (artifact.format !== 'tgz') { @@ -205,7 +269,7 @@ async function installExclusive( if (downloadUrls.length === 0) { throw new Error('Plugin release has no HTTPS (or loopback HTTP) download URL'); } - const previousRecords = readRegistry().filter((entry) => entry.id === manifest.id); + const previousRecords = readRegistryStrict().filter((entry) => entry.id === manifest.id); const targetDir = pluginPackageDir(manifest.id, manifest.version); const stagingDir = makePluginTmpDir(manifest.id, manifest.version); const tgzPath = join(stagingDir, 'package.tgz'); @@ -287,6 +351,18 @@ async function installExclusive( ); } } + // Reconcile stored settings against the new manifest only after the install commits; a failure + // must not roll back a committed install, so it is logged and left for the next upgrade. + if (previousRecords.length > 0) { + try { + reconcileSettingsForManifest(installedManifest, secrets); + } catch (error) { + logger.warn( + { error, pluginId: manifest.id, operation: 'plugin.install.reconcile-settings' }, + 'Failed to reconcile stored plugin settings after an upgrade', + ); + } + } logger.info( { pluginId: manifest.id, version: manifest.version, operation: 'plugin.install' }, 'Installed LinkCode plugin', @@ -481,6 +557,154 @@ function rollbackPublishedPluginPackage( if (retiredDir !== undefined) restorePluginPackage(retiredDir, targetDir, pluginId); } +/** Uninstall's retire is strict: a package that exists but cannot be moved aborts the mutation + * before the registry is touched, instead of orphaning the live directory. */ +function retireForUninstall(record: InstalledLinkCodePlugin): string | undefined { + if (lstatSync(record.path, { throwIfNoEntry: false }) === undefined) return undefined; + return nullthrow( + retirePluginPackage(record.path), + `Failed to retire the plugin package: ${record.path}`, + ); +} + +/** Best-effort: a missing marker only means a mid-cleanup crash would not be retried at boot. */ +function writeUninstallTombstone(path: string, pluginId: string): void { + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify({ pluginId })}\n`, { encoding: 'utf8', mode: 0o600 }); + } catch (error) { + logger.warn( + { error, pluginId, path, operation: 'plugin.uninstall.tombstone' }, + 'Failed to persist the uninstall cleanup marker', + ); + } +} + +/** + * Retry settings cleanups whose uninstall committed but crashed or failed before config/vault were + * pruned. A marker whose plugin is still registered belongs to an aborted uninstall and is + * discarded. A corrupt registry blocks the whole sweep: reading it degraded as "not registered" + * would wipe the settings of plugins that are, in fact, still installed. + */ +function sweepUninstallTombstones(secrets: SecretStore): void { + let names: string[]; + try { + names = readdirSync(pluginsRoot()); + } catch { + return; + } + const markers = names.filter((name) => name.startsWith('.uninstall-') && name.endsWith('.json')); + if (markers.length === 0) return; + let registered: ReadonlySet; + try { + registered = new Set(readRegistryStrict().map((record) => record.id)); + } catch (error) { + logger.warn( + { error, operation: 'plugin.uninstall.sweep' }, + 'Skipping uninstall cleanup retries while the plugin registry is unreadable', + ); + return; + } + for (const name of markers) { + const path = join(pluginsRoot(), name); + let pluginId: string; + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); + pluginId = LinkCodePluginIdSchema.parse((parsed as { pluginId?: unknown }).pluginId); + } catch (error) { + logger.warn( + { error, path, operation: 'plugin.uninstall.sweep' }, + 'Keeping an unreadable uninstall cleanup marker', + ); + continue; + } + if (registered.has(pluginId)) { + rmSync(path, { force: true }); + continue; + } + try { + savePluginConfigValues(pluginId, {}); + prunePluginSecrets(secrets, pluginId); + rmSync(path, { force: true }); + logger.info( + { pluginId, operation: 'plugin.uninstall.sweep' }, + 'Retried a plugin settings cleanup after an interrupted uninstall', + ); + } catch (error) { + logger.warn( + { error, pluginId, operation: 'plugin.uninstall.sweep' }, + 'Plugin settings cleanup retry failed; keeping the marker', + ); + } + } +} + +/** + * Re-key stored setting values after an upgrade: values whose field vanished or no longer + * validates are dropped, and values cross the config/vault boundary when their field's `secret` + * classification changed — otherwise a non-secret → secret flip would leave the value in + * plaintext in config.json. + */ +function reconcileSettingsForManifest( + manifest: LinkCodePluginManifest, + secrets: SecretStore, +): void { + // Partial: arbitrary-key access can miss, and the honest initializer type keeps + // no-unnecessary-condition from reading the undefined checks as dead. + const declared: Partial> = manifest.settings ?? {}; + const next: Record = {}; + const secretPatch = new Map(); + for (const [fieldId, value] of Object.entries(loadPluginConfigValues(manifest.id))) { + const field = declared[fieldId]; + if (field === undefined || !isValidPluginSettingValue(field, value)) continue; + if (field.secret) secretPatch.set(`${manifest.id}/${fieldId}`, String(value)); + else next[fieldId] = value; + } + const prefix = `${manifest.id}/`; + for (const key of secrets.keys()) { + if (!key.startsWith(prefix)) continue; + const field = declared[key.slice(prefix.length)]; + const value = secrets.get(key); + if (field === undefined) { + secretPatch.set(key, undefined); + continue; + } + // The vault stores every secret stringified; coerce back to the declared type so a `secret` + // classification change moves a usable value instead of dropping it. + const coerced = value === null ? undefined : coerceStoredSecretValue(field, value); + if (field.secret) { + if (coerced === undefined) secretPatch.set(key, undefined); + continue; + } + if (coerced !== undefined) next[key.slice(prefix.length)] = coerced; + secretPatch.set(key, undefined); + } + // Vault before config: a failed config write then leaves an inert duplicate (re-reconciled on + // the next upgrade) rather than a value lost between the two stores. + applySecretPatch(secrets, secretPatch); + savePluginConfigValues(manifest.id, next); +} + +/** The vault stores secrets as strings; restore one to the field's declared type (undefined when + * unrecoverable, e.g. a non-numeric string under a number field). */ +function coerceStoredSecretValue( + field: LinkCodePluginSettingField, + raw: string, +): PluginConfigValue | undefined { + switch (field.type) { + case 'number': { + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : undefined; + } + case 'boolean': + return raw === 'true' ? true : raw === 'false' ? false : undefined; + case 'enum': + return field.enum?.includes(raw) === true ? raw : undefined; + default: + return raw; + } +} + function applySecretPatch( secrets: SecretStore, patch: ReadonlyMap, @@ -510,12 +734,47 @@ function readRegistry(): InstalledLinkCodePlugin[] { const records: InstalledLinkCodePlugin[] = []; for (const value of parsed) { const result = InstalledLinkCodePluginSchema.safeParse(value); - if (result.success) records.push(result.data); - else logger.warn({ operation: 'plugin.registry' }, 'Dropping invalid plugin install record'); + if (!result.success || !hasExpectedPackagePath(result.data)) { + logger.warn({ operation: 'plugin.registry' }, 'Dropping invalid plugin install record'); + } else { + records.push(result.data); + } } return records; } +/** + * The registry read mutations must use. Only `ENOENT` means a first-run empty registry: any other + * read error, malformed JSON, or invalid record fails closed, because a degraded read that + * participates in the next `writeRegistry` would silently drop every other installation record. + */ +function readRegistryStrict(): InstalledLinkCodePlugin[] { + const path = pluginRegistryPath(); + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('Plugin registry is not a JSON array'); + return parsed.map((value: unknown, index) => { + const result = InstalledLinkCodePluginSchema.safeParse(value); + if (!result.success || !hasExpectedPackagePath(result.data)) { + throw new Error(`Invalid plugin install record at index ${index}`, { + cause: result.success ? undefined : result.error, + }); + } + return result.data; + }); +} + +/** A record may only name its derived package dir; a corrupted path must never drive a removal. */ +function hasExpectedPackagePath(record: InstalledLinkCodePlugin): boolean { + return record.path === pluginPackageDir(record.id, record.version); +} + function currentRegistryRecords(): InstalledLinkCodePlugin[] { const latestById = new Map(); for (const record of readRegistry()) latestById.set(record.id, record); @@ -525,7 +784,7 @@ function currentRegistryRecords(): InstalledLinkCodePlugin[] { } function upsertRegistry(record: InstalledLinkCodePlugin): void { - const next = readRegistry().filter((entry) => entry.id !== record.id); + const next = readRegistryStrict().filter((entry) => entry.id !== record.id); next.push(record); writeRegistry(next); } From debfb282a6893b33f8512f80409847bc77ad0283 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:02:26 +0800 Subject: [PATCH 12/19] feat(workbench): secret-presence-aware plugin settings forms with daemon-mirrored catalog gate --- .../core/src/__tests__/plugin-market.test.ts | 7 +++-- packages/client/core/src/client.ts | 1 + .../core/src/client/pending-registry.ts | 5 +++- .../src/mock/data/linkcode-marketplace.ts | 5 ++-- .../workbench/src/mock/dev-mock-host.ts | 26 +++++++++++++++++-- .../plugins/__tests__/linkcode-config.test.ts | 5 +++- .../plugins/linkcode-config-dialog.tsx | 18 ++++++++++--- .../src/settings/plugins/linkcode-config.ts | 10 ++++--- .../src/settings/plugins/linkcode-tab.tsx | 9 ++++++- 9 files changed, 71 insertions(+), 15 deletions(-) diff --git a/packages/client/core/src/__tests__/plugin-market.test.ts b/packages/client/core/src/__tests__/plugin-market.test.ts index c665202b0..565c54fce 100644 --- a/packages/client/core/src/__tests__/plugin-market.test.ts +++ b/packages/client/core/src/__tests__/plugin-market.test.ts @@ -165,6 +165,7 @@ describe('LinkCodeClient plugin-market / plugin-config requests', () => { password: { type: 'password', secret: true, required: true }, }, values: { account: 'you@163.com' }, + configuredSecrets: ['password'] as string[], } as const; transport.receive({ kind: 'plugin-config.listed', @@ -181,14 +182,14 @@ describe('LinkCodeClient plugin-market / plugin-config requests', () => { const pending = client.setLinkCodePluginConfig({ pluginId: 'linkcode/mail', set: { preset: 'qq', readonly: true }, - remove: ['maxBodyChars'], + remove: ['api.key'], }); const request = lastRequest(transport); expect(request).toMatchObject({ kind: 'plugin-config.set', pluginId: 'linkcode/mail', set: { preset: 'qq', readonly: true }, - remove: ['maxBodyChars'], + remove: ['api.key'], }); transport.receive({ @@ -196,11 +197,13 @@ describe('LinkCodeClient plugin-market / plugin-config requests', () => { replyTo: request.clientReqId, pluginId: 'linkcode/mail', values: { account: 'you@163.com', preset: 'qq', readonly: true }, + configuredSecrets: ['password'], }); await expect(pending).resolves.toEqual({ pluginId: 'linkcode/mail', values: { account: 'you@163.com', preset: 'qq', readonly: true }, + configuredSecrets: ['password'], }); client.dispose(); }); diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 898c2c637..356b61aab 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -463,6 +463,7 @@ export class LinkCodeClient { this.pending.resolve('pluginConfigUpdate', p.replyTo, { pluginId: p.pluginId, values: p.values, + configuredSecrets: p.configuredSecrets, }); break; case 'config.probe-models.result': diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index dde4e53ee..71d9b07b5 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -100,18 +100,21 @@ export interface PluginMarketRefresh { } /** One row of `plugin-config.listed`: a plugin's settings field schemas plus its masked values — - * secret fields appear in `settings` but never in `values`. */ + * secret fields appear in `settings` but never in `values`; `configuredSecrets` carries their + * presence bits (absent from older daemons, which clients read as "configured"). */ export interface LinkCodePluginConfigView { id: LinkCodePluginId; version: LinkCodePluginVersion; settings: LinkCodePluginSettings; values: Record; + configuredSecrets?: string[]; } /** The `plugin-config.updated` payload: the plugin's post-patch masked values. */ export interface LinkCodePluginConfigUpdate { pluginId: LinkCodePluginId; values: Record; + configuredSecrets?: string[]; } export type RandomUUID = () => string; diff --git a/packages/client/workbench/src/mock/data/linkcode-marketplace.ts b/packages/client/workbench/src/mock/data/linkcode-marketplace.ts index fbfe4450b..42a68e608 100644 --- a/packages/client/workbench/src/mock/data/linkcode-marketplace.ts +++ b/packages/client/workbench/src/mock/data/linkcode-marketplace.ts @@ -16,8 +16,9 @@ export interface MockLinkCodeCatalogEntry { } /** Catalog the mock serves for `plugin-market.refresh`: a settings-bearing MCP plugin (the echo - * debug plugin's env surface, exercising every settings field type) and a skill-only plugin with - * nothing to configure. */ + * debug plugin's env surface, exercising every settings field type) and a skill-only plugin that + * the catalog boundary filters out — the mock mirrors the daemon, so it is never listed or + * installable. */ export const SEED_LINKCODE_RELEASES: MockLinkCodeCatalogEntry[] = [ { pluginId: 'linkcode/echo', diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 9b1945037..97f606b3e 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -37,6 +37,7 @@ import type { } from '@linkcode/schema'; import { AGENT_INPUT_CAPABILITIES, + isProjectablePluginRelease, managedAgentAssetId, managedAssetIdEquals, managedAssetKey, @@ -121,6 +122,12 @@ const MOCK_DEFAULT_EFFORTS: Readonly>> = 'grok-build': 'high', }; +/** Mirror of the daemon's catalog/install boundary: releases with no projectable component (the + * skill-only seed) are neither listed nor installable. */ +const PROJECTABLE_SEED_LINKCODE_RELEASES = SEED_LINKCODE_RELEASES.filter((entry) => + isProjectablePluginRelease(entry.release), +); + interface MockSession extends SessionInfo { /** Host-only replay state: keep it off `session.list` so the mock crosses the schema boundary. */ model?: string; @@ -525,13 +532,13 @@ export class DevMockHost { kind: 'plugin-market.refreshed', replyTo: p.clientReqId, marketplaceId: p.marketplaceId, - releases: SEED_LINKCODE_RELEASES, + releases: PROJECTABLE_SEED_LINKCODE_RELEASES, }); break; } case 'plugin-market.install': { await wait(CONTROL_LATENCY_MS); - const known = SEED_LINKCODE_RELEASES.some( + const known = PROJECTABLE_SEED_LINKCODE_RELEASES.some( (candidate) => candidate.pluginId === p.release.pluginId && candidate.release.manifest.version === p.release.version, @@ -603,6 +610,7 @@ export class DevMockHost { replyTo: p.clientReqId, pluginId: p.pluginId, values: maskLinkCodePluginValues(settings, installed.values), + configuredSecrets: configuredLinkCodeSecrets(settings, installed.values), }); break; } @@ -1801,6 +1809,7 @@ export class DevMockHost { version: string; settings: LinkCodePluginSettings; values: Record; + configuredSecrets: string[]; }> { const views = []; for (const [pluginId, installed] of this.linkCodeInstalled) { @@ -1811,6 +1820,7 @@ export class DevMockHost { version: installed.version, settings, values: maskLinkCodePluginValues(settings, installed.values), + configuredSecrets: configuredLinkCodeSecrets(settings, installed.values), }); } return views; @@ -1889,6 +1899,18 @@ function maskLinkCodePluginValues( return masked; } +/** Mirror of the daemon's presence projection: ids of secret fields holding a stored value. */ +function configuredLinkCodeSecrets( + settings: LinkCodePluginSettings, + values: Readonly>, +): string[] { + const ids: string[] = []; + for (const [fieldId, field] of Object.entries(settings)) { + if (field.secret === true && fieldId in values) ids.push(fieldId); + } + return ids; +} + /** Mirror of the daemon's masked projection: env/header values never reach the client. */ function maskCustomMcpServer(entry: CustomMcpServer): CustomMcpServerPublic { const { server } = entry; diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts index ff6d1ea40..09fb451b9 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts @@ -46,8 +46,11 @@ describe('validatePluginConfigField', () => { expect(validatePluginConfigField(SETTINGS.account, 'you@163.com')).toBe(true); }); - it('never rejects a blank secret — blank means keep the stored value', () => { + it('treats a blank secret as keep only when a value is already configured', () => { expect(validatePluginConfigField(SETTINGS.password, '')).toBe(true); + expect(validatePluginConfigField(SETTINGS.password, '', true)).toBe(true); + // A newly installed plugin has no stored secret to keep — blank is missing, not keep. + expect(validatePluginConfigField(SETTINGS.password, '', false)).toBe('required'); }); it('rejects a non-numeric number field, blank optional number passes', () => { diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx index d0126f904..801c8fb6b 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx @@ -43,6 +43,9 @@ export interface LinkCodePluginConfigDialogProps { settings: LinkCodePluginSettings; /** The masked read: non-secret values only; secret fields arrive absent and render blank. */ values: Readonly>; + /** Presence bits from the masked read: which secret fields hold a stored value. Undefined from an + * older daemon, which keeps the lenient "blank = keep" validation. */ + configuredSecrets?: readonly string[]; busy: boolean; onClose: () => void; onSubmit: (patch: LinkCodePluginConfigPatch) => void; @@ -54,6 +57,7 @@ export function LinkCodePluginConfigDialog({ title, settings, values, + configuredSecrets, busy, onClose, onSubmit, @@ -85,6 +89,9 @@ export function LinkCodePluginConfigDialog({ key={fieldId} fieldId={fieldId} field={field} + secretConfigured={ + configuredSecrets === undefined || configuredSecrets.includes(fieldId) + } control={control} register={register} busy={busy} @@ -108,12 +115,14 @@ export function LinkCodePluginConfigDialog({ function ConfigField({ fieldId, field, + secretConfigured, control, register, busy, }: { fieldId: string; field: LinkCodePluginSettingField; + secretConfigured: boolean; control: Control; register: UseFormRegister; busy: boolean; @@ -130,7 +139,9 @@ function ConfigField({ return (
- {label} + + {label} + {field.description === undefined ? null : ( {field.description} )} @@ -143,6 +154,7 @@ function ConfigField({ checked={switchField.value === true} disabled={busy} onCheckedChange={(checked) => switchField.onChange(checked)} + aria-labelledby={`${formKey}-label`} /> )} /> @@ -151,7 +163,7 @@ function ConfigField({ } const validate = (raw: string | boolean): true | string => { - const result = validatePluginConfigField(field, raw); + const result = validatePluginConfigField(field, raw, secretConfigured); return result === true ? true : t(`form.${result}`); }; @@ -189,7 +201,7 @@ function ConfigField({ type={ field.type === 'password' ? 'password' : field.type === 'number' ? 'number' : 'text' } - placeholder={field.secret ? t('form.secretPlaceholder') : undefined} + placeholder={secretConfigured && field.secret ? t('form.secretPlaceholder') : undefined} disabled={busy} /> )} diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config.ts b/packages/client/workbench/src/settings/plugins/linkcode-config.ts index 1a16dc9c1..ea2c65a8d 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-config.ts +++ b/packages/client/workbench/src/settings/plugins/linkcode-config.ts @@ -49,16 +49,20 @@ export function pluginConfigDefaults( return defaults; } -/** Validate one raw form value against its declared field; `true` passes. */ +/** Validate one raw form value against its declared field; `true` passes. `secretConfigured` tells + * a blank secret apart: "keep the stored value" when one exists, "missing required" when none does + * (a newly installed plugin has no old value to keep). Callers without presence information (older + * daemons) pass the default, preserving the previous lenient behavior. */ export function validatePluginConfigField( field: LinkCodePluginSettingField, raw: string | boolean, + secretConfigured = true, ): true | PluginConfigFieldError { if (field.type === 'boolean') return true; const value = typeof raw === 'string' ? raw : String(raw); if (value === '') { - // A blank secret is "keep the stored value", never an error. - return field.required === true && !field.secret ? 'required' : true; + if (field.required !== true) return true; + return secretConfigured && field.secret ? true : 'required'; } if (field.type === 'number' && Number.isNaN(Number(value))) return 'invalidNumber'; return true; diff --git a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx index eeee0b238..75d468d29 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx @@ -86,7 +86,13 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac await mutateConfigs( (current) => current?.map((view) => - view.id === result.pluginId ? { ...view, values: result.values } : view, + view.id === result.pluginId + ? { + ...view, + values: result.values, + configuredSecrets: result.configuredSecrets ?? view.configuredSecrets, + } + : view, ), { revalidate: false }, ); @@ -133,6 +139,7 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac title={editing.id} settings={editing.settings} values={editing.values} + configuredSecrets={editing.configuredSecrets} busy={save.isMutating} onClose={() => setConfiguring(null)} onSubmit={(patch) => { From 765738630e1918edf984978cec87c012d6775044 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:32:28 +0800 Subject: [PATCH 13/19] fix(plugin-store): purge inherited settings when reinstalling over a pending uninstall and drop secrets on a manifest declassify --- .../daemon/src/__tests__/plugin-store.test.ts | 73 +++++++++++++++--- apps/daemon/src/plugin-store/store.ts | 74 +++++++++---------- 2 files changed, 98 insertions(+), 49 deletions(-) diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts index 6b1a7ab1c..956bce4c2 100644 --- a/apps/daemon/src/__tests__/plugin-store.test.ts +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -696,13 +696,10 @@ describe('DaemonLinkCodePluginStore', () => { await store.install(release, 'linkcode-official'); - // non-secret → secret moved into the vault; secret → non-secret landed in config.json with its - // declared type restored from the vault's string form; the removed field is gone from both. - expect(loadPluginConfigValues('arcbox/latex')).toEqual({ - creds: 'c-value', - quota: 42, - verbose: true, - }); + // non-secret → secret moved into the vault; a secret → non-secret flip drops the value rather + // than migrating it into plaintext config.json, where the masked read would hand it back + // unmasked — re-entry costs the user one field. The dropped/removed fields vanish from both. + expect(loadPluginConfigValues('arcbox/latex')).toEqual({}); expect(vault.refs.get('plugin:arcbox/latex/plain')).toBe('p'); // A number secret that stays secret keeps its (stringified) vault value across the upgrade. expect(vault.refs.get('plugin:arcbox/latex/retained')).toBe('7'); @@ -712,10 +709,66 @@ describe('DaemonLinkCodePluginStore', () => { expect(vault.refs.get('plugin:arcbox/latex/legacy')).toBeUndefined(); expect(store.getSettings('arcbox/latex')).toEqual({ plain: 'p', - creds: 'c-value', - quota: 42, - verbose: true, retained: '7', }); }); + + it('purges settings inherited through a pending uninstall when reinstalling', async () => { + // The uninstall committed (registry entry gone, marker kept) but its cleanup failed; a + // reinstall must consume that marker instead of registering the id and letting the boot sweep + // discard it unread with the old values still in place. + const installed = record('0.2.0'); + writePackage(installed, settingsManifest('0.2.0')); + writeRegistry([installed]); + const baseVault = createInMemoryVault(); + let failReplaceAll = true; + const flakyVault = { + ...baseVault, + namespace(name: Parameters[0]) { + const secrets = baseVault.namespace(name); + if (name !== 'plugin') return secrets; + return { + ...secrets, + replaceAll(entries: ReadonlyMap) { + if (failReplaceAll) { + failReplaceAll = false; + throw new Error('vault write failed'); + } + secrets.replaceAll(entries); + }, + }; + }, + }; + const store = new DaemonLinkCodePluginStore(flakyVault); + await store.setSettings('arcbox/latex', { + set: { account: 'stale@example.com', authcode: 'stale-secret' }, + }); + // The uninstall commits but its vault prune fails: the marker survives alongside the + // unreaped secret while the plugin disappears from the registry. + await new DaemonLinkCodePluginStore(flakyVault).uninstall('arcbox/latex'); + + expect(existsSync(pluginUninstallTombstonePath('arcbox/latex'))).toBe(true); + expect(baseVault.refs.get('plugin:arcbox/latex/authcode')).toBe('stale-secret'); + expect(store.get('arcbox/latex')).toBeUndefined(); + + // Reinstall the same version while the marker is still pending. + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(settingsManifest('0.2.0'))); + }); + const release = { + manifest: settingsManifest('0.2.0'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + await new DaemonLinkCodePluginStore(baseVault).install(release, 'linkcode-official'); + + // The stale block was purged at install time and the marker consumed; the fresh install + // starts empty instead of inheriting the uninstalled plugin's values. + expect(loadPluginConfigValues('arcbox/latex')).toEqual({}); + expect(baseVault.refs.get('plugin:arcbox/latex/authcode')).toBeUndefined(); + expect(existsSync(pluginUninstallTombstonePath('arcbox/latex'))).toBe(false); + }); }); diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index ebd4a3b1b..a52ec860b 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -3,6 +3,7 @@ import type { Dirent } from 'node:fs'; import { chmodSync, closeSync, + existsSync, fsyncSync, lstatSync, mkdirSync, @@ -353,7 +354,28 @@ async function installExclusive( } // Reconcile stored settings against the new manifest only after the install commits; a failure // must not roll back a committed install, so it is logged and left for the next upgrade. - if (previousRecords.length > 0) { + // A leftover uninstall marker means the previous uninstall committed but its settings cleanup + // never ran: the registry had already dropped the id, so this looks like a fresh install. + // Purge here — after this install registers the id again, the boot sweep would discard the + // marker unread ("still registered") and the inherited values would survive an uninstall. + const tombstone = pluginUninstallTombstonePath(manifest.id); + const hadPendingUninstall = existsSync(tombstone); + if (hadPendingUninstall) { + try { + savePluginConfigValues(manifest.id, {}); + prunePluginSecrets(secrets, manifest.id); + logger.info( + { pluginId: manifest.id, operation: 'plugin.install.purge-pending-uninstall' }, + 'Purged settings left by a committed but unfinished uninstall', + ); + } catch (error) { + logger.warn( + { error, pluginId: manifest.id, operation: 'plugin.install.purge-pending-uninstall' }, + 'Failed to purge settings left by an unfinished uninstall', + ); + } + rmSync(tombstone, { force: true }); + } else if (previousRecords.length > 0) { try { reconcileSettingsForManifest(installedManifest, secrets); } catch (error) { @@ -640,10 +662,10 @@ function sweepUninstallTombstones(secrets: SecretStore): void { } /** - * Re-key stored setting values after an upgrade: values whose field vanished or no longer - * validates are dropped, and values cross the config/vault boundary when their field's `secret` - * classification changed — otherwise a non-secret → secret flip would leave the value in - * plaintext in config.json. + * Re-key stored setting values after an upgrade. Config-side values whose field vanished or no + * longer validates are dropped; a non-secret → secret flip moves the value into the vault, or it + * would sit in plaintext config.json. The reverse flip drops from the vault instead of migrating + * out — maskValues only honors the current manifest, so a migrated value would come back unmasked. */ function reconcileSettingsForManifest( manifest: LinkCodePluginManifest, @@ -662,22 +684,16 @@ function reconcileSettingsForManifest( } const prefix = `${manifest.id}/`; for (const key of secrets.keys()) { - if (!key.startsWith(prefix)) continue; - const field = declared[key.slice(prefix.length)]; - const value = secrets.get(key); - if (field === undefined) { + // A vanished field goes, obviously. A secret→public flip drops too, by the same logic that + // protects the opposite direction: migrating the old secret into plaintext config.json would + // serve it unmasked on the next masked read — re-entry costs the user one field. A still + // secret field keeps its raw stringified vault value untouched. + if ( + key.startsWith(prefix) && + (declared[key.slice(prefix.length)]?.secret !== true || secrets.get(key) === null) + ) { secretPatch.set(key, undefined); - continue; - } - // The vault stores every secret stringified; coerce back to the declared type so a `secret` - // classification change moves a usable value instead of dropping it. - const coerced = value === null ? undefined : coerceStoredSecretValue(field, value); - if (field.secret) { - if (coerced === undefined) secretPatch.set(key, undefined); - continue; } - if (coerced !== undefined) next[key.slice(prefix.length)] = coerced; - secretPatch.set(key, undefined); } // Vault before config: a failed config write then leaves an inert duplicate (re-reconciled on // the next upgrade) rather than a value lost between the two stores. @@ -685,26 +701,6 @@ function reconcileSettingsForManifest( savePluginConfigValues(manifest.id, next); } -/** The vault stores secrets as strings; restore one to the field's declared type (undefined when - * unrecoverable, e.g. a non-numeric string under a number field). */ -function coerceStoredSecretValue( - field: LinkCodePluginSettingField, - raw: string, -): PluginConfigValue | undefined { - switch (field.type) { - case 'number': { - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : undefined; - } - case 'boolean': - return raw === 'true' ? true : raw === 'false' ? false : undefined; - case 'enum': - return field.enum?.includes(raw) === true ? raw : undefined; - default: - return raw; - } -} - function applySecretPatch( secrets: SecretStore, patch: ReadonlyMap, From ea4d7e57cf73b76ec3926829ad0d79b25eb02f44 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:34:20 +0800 Subject: [PATCH 14/19] fix(wire): require configuredSecrets presence bits on plugin-config frames --- .../client/core/src/__tests__/plugin-market.test.ts | 1 + packages/client/core/src/client/pending-registry.ts | 6 +++--- .../__tests__/linkcode-config-dialog.test.tsx | 13 ++++++++++--- .../plugins/__tests__/linkcode-config.test.ts | 13 ++++++------- .../src/settings/plugins/__tests__/view.test.ts | 10 ++++++++-- .../src/settings/plugins/linkcode-config-dialog.tsx | 9 +++------ .../src/settings/plugins/linkcode-config.ts | 6 +++--- .../workbench/src/settings/plugins/linkcode-tab.tsx | 2 +- .../foundation/schema/src/wire/plugin-config.ts | 9 +++++---- .../tests/contract/wire/plugin-config.test.ts | 8 +++++--- 10 files changed, 45 insertions(+), 32 deletions(-) diff --git a/packages/client/core/src/__tests__/plugin-market.test.ts b/packages/client/core/src/__tests__/plugin-market.test.ts index 565c54fce..2c6c90c1c 100644 --- a/packages/client/core/src/__tests__/plugin-market.test.ts +++ b/packages/client/core/src/__tests__/plugin-market.test.ts @@ -165,6 +165,7 @@ describe('LinkCodeClient plugin-market / plugin-config requests', () => { password: { type: 'password', secret: true, required: true }, }, values: { account: 'you@163.com' }, + // Readonly tuple is assignable to the payload's string[] presence bits. configuredSecrets: ['password'] as string[], } as const; transport.receive({ diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index 71d9b07b5..c4fbb8ff5 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -101,20 +101,20 @@ export interface PluginMarketRefresh { /** One row of `plugin-config.listed`: a plugin's settings field schemas plus its masked values — * secret fields appear in `settings` but never in `values`; `configuredSecrets` carries their - * presence bits (absent from older daemons, which clients read as "configured"). */ + * presence bits. */ export interface LinkCodePluginConfigView { id: LinkCodePluginId; version: LinkCodePluginVersion; settings: LinkCodePluginSettings; values: Record; - configuredSecrets?: string[]; + configuredSecrets: string[]; } /** The `plugin-config.updated` payload: the plugin's post-patch masked values. */ export interface LinkCodePluginConfigUpdate { pluginId: LinkCodePluginId; values: Record; - configuredSecrets?: string[]; + configuredSecrets: string[]; } export type RandomUUID = () => string; diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx index 1e43257bc..ff15ad03d 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx @@ -45,6 +45,7 @@ function renderDialog( title="linkcode/mail" settings={SETTINGS} values={{}} + configuredSecrets={[]} busy={false} onClose={vi.fn()} onSubmit={onSubmit} @@ -56,7 +57,8 @@ function renderDialog( describe('LinkCodePluginConfigDialog', () => { it('renders one control per declared field, secrets masked', () => { - renderDialog(); + // The presence bit is what turns the placeholder into "leave blank to keep". + renderDialog({ configuredSecrets: ['password'] }); expect(screen.getByText('Account')).toBeDefined(); expect(screen.getByText('Authorization code')).toBeDefined(); expect(screen.getByText('Provider preset')).toBeDefined(); @@ -77,7 +79,11 @@ describe('LinkCodePluginConfigDialog', () => { }); it('submits a typed per-key patch, keeping blank secrets out of it', async () => { - const { onSubmit } = renderDialog({ values: { account: 'old@163.com' } }); + const { onSubmit } = renderDialog({ + values: { account: 'old@163.com' }, + // The stored-but-never-echoed secret counts as configured, so its blank means "keep". + configuredSecrets: ['password'], + }); fireEvent.change(screen.getByLabelText('Account'), { target: { value: 'new@163.com' } }); fireEvent.change(screen.getByLabelText('Max body characters'), { target: { value: '4000' } }); fireEvent.click(screen.getByRole('switch')); @@ -99,7 +105,8 @@ describe('LinkCodePluginConfigDialog', () => { it('blocks submit on a blank required field', async () => { const { onSubmit } = renderDialog(); fireEvent.click(screen.getByRole('button', { name: 'form.save' })); - await waitFor(() => expect(screen.getByText('form.required')).toBeDefined()); + // Both required fields (the string and the never-configured secret) surface their own error. + await waitFor(() => expect(screen.getAllByText('form.required').length).toBeGreaterThan(0)); expect(onSubmit).not.toHaveBeenCalled(); }); }); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts index 09fb451b9..546f116ab 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts @@ -42,25 +42,24 @@ describe('pluginConfigDefaults', () => { describe('validatePluginConfigField', () => { it('rejects a blank required non-secret field', () => { - expect(validatePluginConfigField(SETTINGS.account, '')).toBe('required'); - expect(validatePluginConfigField(SETTINGS.account, 'you@163.com')).toBe(true); + expect(validatePluginConfigField(SETTINGS.account, '', false)).toBe('required'); + expect(validatePluginConfigField(SETTINGS.account, 'you@163.com', false)).toBe(true); }); it('treats a blank secret as keep only when a value is already configured', () => { - expect(validatePluginConfigField(SETTINGS.password, '')).toBe(true); expect(validatePluginConfigField(SETTINGS.password, '', true)).toBe(true); // A newly installed plugin has no stored secret to keep — blank is missing, not keep. expect(validatePluginConfigField(SETTINGS.password, '', false)).toBe('required'); }); it('rejects a non-numeric number field, blank optional number passes', () => { - expect(validatePluginConfigField(SETTINGS['body.max'], 'abc')).toBe('invalidNumber'); - expect(validatePluginConfigField(SETTINGS['body.max'], '42')).toBe(true); - expect(validatePluginConfigField(SETTINGS['body.max'], '')).toBe(true); + expect(validatePluginConfigField(SETTINGS['body.max'], 'abc', false)).toBe('invalidNumber'); + expect(validatePluginConfigField(SETTINGS['body.max'], '42', false)).toBe(true); + expect(validatePluginConfigField(SETTINGS['body.max'], '', false)).toBe(true); }); it('always passes a boolean', () => { - expect(validatePluginConfigField(SETTINGS.readonly, false)).toBe(true); + expect(validatePluginConfigField(SETTINGS.readonly, false, true)).toBe(true); }); }); diff --git a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts index a76c1d139..0e8f2299a 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -451,6 +451,7 @@ describe('linkcodeInstalledRow', () => { version: '1.0.0', settings: { account: { type: 'string' } }, values: {}, + configuredSecrets: [], }), ).toEqual({ key: 'linkcode/mail', @@ -460,8 +461,13 @@ describe('linkcodeInstalledRow', () => { hasSettings: true, }); expect( - linkcodeInstalledRow({ id: 'linkcode/notes', version: '0.2.0', settings: {}, values: {} }) - .hasSettings, + linkcodeInstalledRow({ + id: 'linkcode/notes', + version: '0.2.0', + settings: {}, + values: {}, + configuredSecrets: [], + }).hasSettings, ).toBe(false); }); }); diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx index 801c8fb6b..e454222f5 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx @@ -43,9 +43,8 @@ export interface LinkCodePluginConfigDialogProps { settings: LinkCodePluginSettings; /** The masked read: non-secret values only; secret fields arrive absent and render blank. */ values: Readonly>; - /** Presence bits from the masked read: which secret fields hold a stored value. Undefined from an - * older daemon, which keeps the lenient "blank = keep" validation. */ - configuredSecrets?: readonly string[]; + /** Presence bits from the masked read: which secret fields hold a stored value. */ + configuredSecrets: readonly string[]; busy: boolean; onClose: () => void; onSubmit: (patch: LinkCodePluginConfigPatch) => void; @@ -89,9 +88,7 @@ export function LinkCodePluginConfigDialog({ key={fieldId} fieldId={fieldId} field={field} - secretConfigured={ - configuredSecrets === undefined || configuredSecrets.includes(fieldId) - } + secretConfigured={configuredSecrets.includes(fieldId)} control={control} register={register} busy={busy} diff --git a/packages/client/workbench/src/settings/plugins/linkcode-config.ts b/packages/client/workbench/src/settings/plugins/linkcode-config.ts index ea2c65a8d..6e977e932 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-config.ts +++ b/packages/client/workbench/src/settings/plugins/linkcode-config.ts @@ -51,12 +51,12 @@ export function pluginConfigDefaults( /** Validate one raw form value against its declared field; `true` passes. `secretConfigured` tells * a blank secret apart: "keep the stored value" when one exists, "missing required" when none does - * (a newly installed plugin has no old value to keep). Callers without presence information (older - * daemons) pass the default, preserving the previous lenient behavior. */ + * (a newly installed plugin has no old value to keep). The daemon's masked read always supplies + * the presence bits, so there is no lenient fallback to forget. */ export function validatePluginConfigField( field: LinkCodePluginSettingField, raw: string | boolean, - secretConfigured = true, + secretConfigured: boolean, ): true | PluginConfigFieldError { if (field.type === 'boolean') return true; const value = typeof raw === 'string' ? raw : String(raw); diff --git a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx index 75d468d29..e9a56b1cb 100644 --- a/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx +++ b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx @@ -90,7 +90,7 @@ export function LinkCodeMarketTab({ searchQuery }: LinkCodeMarketTabProps): Reac ? { ...view, values: result.values, - configuredSecrets: result.configuredSecrets ?? view.configuredSecrets, + configuredSecrets: result.configuredSecrets, } : view, ), diff --git a/packages/foundation/schema/src/wire/plugin-config.ts b/packages/foundation/schema/src/wire/plugin-config.ts index 7756409bb..7b27044f1 100644 --- a/packages/foundation/schema/src/wire/plugin-config.ts +++ b/packages/foundation/schema/src/wire/plugin-config.ts @@ -13,6 +13,8 @@ const PluginConfigValueSchema = z.union([z.string(), z.number(), z.boolean()]); * form without executing plugin code) plus masked values — secret fields are omitted, mirroring the * custom-MCP masked-edit contract; `configuredSecrets` exposes only which secret fields hold a * stored value, so the client can tell "blank = keep" from "blank = missing a required secret". + * Required on every frame that carries it: no shipped daemon predates this schema, and an absent + * field would silently reopen the blank-required-secret bug through lenient fallbacks. * Write is a per-key patch: typed values set, keys removed. */ export const pluginConfigWireVariants = [ z.object({ @@ -28,9 +30,8 @@ export const pluginConfigWireVariants = [ version: LinkCodePluginVersionSchema, settings: LinkCodePluginSettingsSchema, values: z.record(z.string().min(1), PluginConfigValueSchema), - /** Presence bits for secret fields (ids only, never values). Optional so an older daemon's - * reply still parses; absence means "unknown", which clients must read as configured. */ - configuredSecrets: z.array(z.string().min(1)).optional(), + /** Presence bits for secret fields (ids only, never values). */ + configuredSecrets: z.array(z.string().min(1)), }), ), }), @@ -46,6 +47,6 @@ export const pluginConfigWireVariants = [ replyTo: WireRequestIdSchema, pluginId: LinkCodePluginIdSchema, values: z.record(z.string().min(1), PluginConfigValueSchema), - configuredSecrets: z.array(z.string().min(1)).optional(), + configuredSecrets: z.array(z.string().min(1)), }), ] as const; diff --git a/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts b/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts index 172a8d1b7..b67ad108b 100644 --- a/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts +++ b/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts @@ -30,12 +30,14 @@ describe('plugin-config wire schema', () => { expect(reply.message.payload.plugins[0]?.values).toEqual({ account: 'you@163.com' }); }); - it('accepts pre-presence replies, so an older daemon still parses', () => { + it('rejects replies without the presence bits, so absence fails loudly', () => { + // The field has no shipped-daemon history to stay compatible with, and an absent field would + // silently reopen the blank-required-secret bug through lenient fallbacks. expect( parseWireMessage( envelope({ kind: 'plugin-config.listed', replyTo: 'request-1', plugins: [pluginView] }), ).ok, - ).toBe(true); + ).toBe(false); expect( parseWireMessage( envelope({ @@ -45,7 +47,7 @@ describe('plugin-config wire schema', () => { values: {}, }), ).ok, - ).toBe(true); + ).toBe(false); }); it('round-trips a per-key patch and its updated reply', () => { From c3b3be4145cfcc2ffb74a1c189720348e8a8f599 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:35:07 +0800 Subject: [PATCH 15/19] fix(engine): reject empty-string setting writes at the daemon config authority --- .../host/engine/src/__tests__/plugin-config.test.ts | 6 ++++++ packages/host/engine/src/plugin/linkcode-store.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/host/engine/src/__tests__/plugin-config.test.ts b/packages/host/engine/src/__tests__/plugin-config.test.ts index 4ec1bbb5e..29fe0a19a 100644 --- a/packages/host/engine/src/__tests__/plugin-config.test.ts +++ b/packages/host/engine/src/__tests__/plugin-config.test.ts @@ -77,6 +77,12 @@ describe('PluginConfigService', () => { ); expect((emptySecretError as RequestError).code).toBe('invalid_request'); + // Same authority for a required non-secret: '' would satisfy the membership check with no data. + const emptyValueError = await Effect.runPromise( + service.applyPatch('linkcode/mail', { set: { account: '' } }).pipe(Effect.flip), + ); + expect((emptyValueError as RequestError).code).toBe('invalid_request'); + expect(store.getSettings('linkcode/mail')).toMatchObject({ account: 'you@163.com', authcode: 's3cret', diff --git a/packages/host/engine/src/plugin/linkcode-store.ts b/packages/host/engine/src/plugin/linkcode-store.ts index 6f58d3249..febcf3e81 100644 --- a/packages/host/engine/src/plugin/linkcode-store.ts +++ b/packages/host/engine/src/plugin/linkcode-store.ts @@ -53,11 +53,14 @@ export function validatePluginConfigPatch( `Invalid value for plugin setting ${fieldId}: expected ${field.type}`, ); } - // The UI contract never sends a blank secret ("blank = keep"); an empty string would be - // stored in the vault and then read back as "configured". Reject it at the authority. - if (value === '' && field.secret === true) { + // '' passes the type check but carries no data, and the required check below only tests + // membership — so a non-UI caller could satisfy it with an empty string. Blank is "remove", + // never a value: reject at the authority (the UI already converts blanks to removals). + if (value === '') { throw new PluginConfigValidationError( - `Plugin setting ${fieldId} must not be an empty secret`, + field.secret === true + ? `Plugin setting ${fieldId} must not be an empty secret` + : `Plugin setting ${fieldId} must not be an empty value`, ); } } From f12c51a9a49c57458521b60f1001c8d0db068563 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 17:35:49 +0800 Subject: [PATCH 16/19] perf(engine): resolve codex native mcp names once per session start --- .../src/session/start-options-resolver.ts | 206 +++++++++--------- 1 file changed, 103 insertions(+), 103 deletions(-) diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index c969e002e..cc42fd9f5 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -45,6 +45,7 @@ export class SessionStartOptionsResolver { const { accountId } = defaults; const account = accountId === undefined ? {} : { accountId }; const { translator } = this; + const providerMcpServerNames = this.providerMcpServerNames.bind(this); const withCustomMcpServers = this.withCustomMcpServers.bind(this); const withSimulatorMcp = this.withSimulatorMcp.bind(this); const withPluginMcpServers = this.withPluginMcpServers.bind(this); @@ -69,8 +70,9 @@ export class SessionStartOptionsResolver { }), ); } - const custom = yield* withCustomMcpServers(defaults.options); - const pluginInjected = yield* withPluginMcpServers(custom.options, custom.warnings); + const nativeMcpNames = yield* providerMcpServerNames(defaults.options); + const custom = withCustomMcpServers(defaults.options, nativeMcpNames); + const pluginInjected = withPluginMcpServers(custom.options, custom.warnings, nativeMcpNames); const resolved = withSimulatorMcp(pluginInjected.options, sessionId); const upstream = translationUpstream(resolved); if (!upstream) return { options: resolved, ...account, warnings: pluginInjected.warnings }; @@ -118,64 +120,71 @@ export class SessionStartOptionsResolver { return names; } + /** + * Enabled provider-native MCP names for Codex's shared, un-namespaced space — resolved once per + * session start and shared by the custom-MCP and LinkCode-plugin folds, since each resolution is + * a full discovery round trip (`plugin/list` plus per-plugin detail reads). `null` means the + * preflight failed: an override cannot be ruled out, so consumers skip instead of injecting. + */ + private providerMcpServerNames(options: StartOptions): Effect.Effect | null> { + if (options.kind === 'codex' && this.plugins) { + return this.plugins + .enabledMcpServerNames('codex', { cwd: options.cwd }) + .pipe(Effect.match({ onSuccess: (names) => names, onFailure: () => null })); + } + return Effect.succeed(new Set()); + } + /** Fold enabled custom MCP servers into the session's server list, warning instead of * silently dropping: unsupported agent kinds and name collisions are user-visible facts. */ private withCustomMcpServers( options: StartOptions, - ): Effect.Effect<{ options: StartOptions; warnings: McpWarning[] }> { + nativeMcpNames: ReadonlySet | null, + ): { options: StartOptions; warnings: McpWarning[] } { const warnings: McpWarning[] = []; const enabled = this.customMcp?.listEnabled() ?? []; - if (enabled.length === 0) return Effect.succeed({ options, warnings }); + if (enabled.length === 0) return { options, warnings }; if (!MCP_CAPABLE_AGENT_KINDS.has(options.kind)) { for (const entry of enabled) { warnings.push({ serverName: entry.server.name, reason: 'agent-unsupported' }); } - return Effect.succeed({ options, warnings }); + return { options, warnings }; } - const pluginNames = - options.kind === 'codex' && this.plugins - ? this.plugins - .enabledMcpServerNames('codex', { cwd: options.cwd }) - .pipe(Effect.match({ onSuccess: (names) => names, onFailure: () => null })) - : Effect.succeed(new Set()); - return pluginNames.pipe( - Effect.map((names) => { - const servers = [...(options.mcpServers ?? [])]; - for (const entry of enabled) { - if (names === null) { - warnings.push({ - serverName: entry.server.name, - reason: 'provider-preflight-failed', - }); - continue; - } - if ( - options.kind === 'codex' && - entry.server.type === 'http' && - entry.server.headers !== undefined && - !isObjectEmpty(entry.server.headers) - ) { - warnings.push({ serverName: entry.server.name, reason: 'provider-unsupported' }); - continue; - } - if ( - names.has(entry.server.name) || - servers.some((server) => server.name === entry.server.name) - ) { - warnings.push({ serverName: entry.server.name, reason: 'name-conflict' }); - continue; - } - servers.push(entry.server); - } - return { - options: - servers.length === 0 && options.mcpServers === undefined - ? options - : { ...options, mcpServers: servers }, - warnings, - }; - }), - ); + const servers = [...(options.mcpServers ?? [])]; + for (const entry of enabled) { + const names = nativeMcpNames; + if (names === null) { + warnings.push({ + serverName: entry.server.name, + reason: 'provider-preflight-failed', + }); + continue; + } + if ( + options.kind === 'codex' && + entry.server.type === 'http' && + entry.server.headers !== undefined && + !isObjectEmpty(entry.server.headers) + ) { + warnings.push({ serverName: entry.server.name, reason: 'provider-unsupported' }); + continue; + } + if ( + names.has(entry.server.name) || + servers.some((server) => server.name === entry.server.name) + ) { + warnings.push({ serverName: entry.server.name, reason: 'name-conflict' }); + continue; + } + servers.push(entry.server); + } + return { + options: + servers.length === 0 && options.mcpServers === undefined + ? options + : { ...options, mcpServers: servers }, + warnings, + }; } /** Fold enabled LinkCode plugin mcp-server components into the session's server list, resolving @@ -186,11 +195,12 @@ export class SessionStartOptionsResolver { private withPluginMcpServers( options: StartOptions, warnings: McpWarning[], - ): Effect.Effect<{ options: StartOptions; warnings: McpWarning[] }> { + nativeMcpNames: ReadonlySet | null, + ): { options: StartOptions; warnings: McpWarning[] } { const store = this.linkCodePluginStore; - if (store === undefined) return Effect.succeed({ options, warnings }); + if (store === undefined) return { options, warnings }; const entries = store.list().filter((entry) => entry.installed.enabled); - if (entries.length === 0) return Effect.succeed({ options, warnings }); + if (entries.length === 0) return { options, warnings }; if (!MCP_CAPABLE_AGENT_KINDS.has(options.kind)) { for (const entry of entries) { for (const component of entry.manifest.components) { @@ -199,61 +209,51 @@ export class SessionStartOptionsResolver { } } } - return Effect.succeed({ options, warnings }); + return { options, warnings }; } - const pluginNames = - options.kind === 'codex' && this.plugins - ? this.plugins - .enabledMcpServerNames('codex', { cwd: options.cwd }) - .pipe(Effect.match({ onSuccess: (names) => names, onFailure: () => null })) - : Effect.succeed(new Set()); - return Effect.map(pluginNames, (names) => { - const servers = [...(options.mcpServers ?? [])]; - for (const entry of entries) { - const settings = store.getSettings(entry.installed.id); - for (const component of entry.manifest.components) { - if (component.kind !== 'mcp-server') continue; - if (names === null) { - // Without the native name set an override cannot be ruled out — skip, don't inject. - warnings.push({ serverName: component.name, reason: 'provider-preflight-failed' }); - continue; - } - if ( - names.has(component.name) || - servers.some((server) => server.name === component.name) - ) { - warnings.push({ serverName: component.name, reason: 'name-conflict' }); - continue; - } - const env: Record = {}; - if (component.env) { - for (const [envVar, settingId] of Object.entries(component.env)) { - if (settingId in settings) env[envVar] = String(settings[settingId]); - } + const names = nativeMcpNames; + const servers = [...(options.mcpServers ?? [])]; + for (const entry of entries) { + const settings = store.getSettings(entry.installed.id); + for (const component of entry.manifest.components) { + if (component.kind !== 'mcp-server') continue; + if (names === null) { + // Without the native name set an override cannot be ruled out — skip, don't inject. + warnings.push({ serverName: component.name, reason: 'provider-preflight-failed' }); + continue; + } + if (names.has(component.name) || servers.some((server) => server.name === component.name)) { + warnings.push({ serverName: component.name, reason: 'name-conflict' }); + continue; + } + const env: Record = {}; + if (component.env) { + for (const [envVar, settingId] of Object.entries(component.env)) { + if (settingId in settings) env[envVar] = String(settings[settingId]); } - // No missing-config advisory yet: shipped clients validate `reason` against the old enum - // and would drop the whole session.started frame, so emission waits for a tolerant floor. - const server: McpServer = { - type: 'stdio', - name: component.name, - command: component.command, - ...(component.entry && { - args: [resolvePath(entry.installed.path, component.entry), ...(component.args ?? [])], - }), - ...(!component.entry && component.args && { args: component.args }), - ...(!isObjectEmpty(env) && { env }), - }; - servers.push(server); } + // No missing-config advisory yet: shipped clients validate `reason` against the old enum + // and would drop the whole session.started frame, so emission waits for a tolerant floor. + const server: McpServer = { + type: 'stdio', + name: component.name, + command: component.command, + ...(component.entry && { + args: [resolvePath(entry.installed.path, component.entry), ...(component.args ?? [])], + }), + ...(!component.entry && component.args && { args: component.args }), + ...(!isObjectEmpty(env) && { env }), + }; + servers.push(server); } - return { - options: - servers.length === 0 && options.mcpServers === undefined - ? options - : { ...options, mcpServers: servers }, - warnings, - }; - }); + } + return { + options: + servers.length === 0 && options.mcpServers === undefined + ? options + : { ...options, mcpServers: servers }, + warnings, + }; } /** Append the session's simulator MCP endpoint for agents whose SDK can consume it. */ From c16e3ead96a5131f8b94e015b1eca1cd00467d34 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 18:23:06 +0800 Subject: [PATCH 17/19] perf(engine): skip codex native MCP discovery when nothing can inject --- .../src/__tests__/start-options-mcp.test.ts | 51 ++++++++++++++++++- .../src/session/start-options-resolver.ts | 29 +++++++---- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index b6f66422a..679f3747c 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -10,7 +10,7 @@ import type { import { PluginSchema } from '@linkcode/schema'; import { Effect } from 'effect'; import { noop } from 'foxts/noop'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CustomMcpServerService } from '../agent/custom-mcp-service'; import { InMemoryProviderConfigStore } from '../agent/provider-config'; import { InMemoryLinkCodePluginStore } from '../plugin/linkcode-store'; @@ -91,6 +91,55 @@ function pluginServiceWithFailedMcpPreflight(): PluginService { return new PluginService(failingMcpPreflightFactory); } +function spyingPluginService(): { plugins: PluginService; listNames: ReturnType } { + const listNames = vi.fn(() => Promise.resolve([])); + const factory: PluginProviderAdapterFactory = (provider) => ({ + provider, + list: () => Promise.resolve([]), + listEnabledMcpServerNames: listNames, + listStandaloneSkills: () => Promise.resolve([]), + }); + return { plugins: new PluginService(factory), listNames }; +} + +describe('Codex native MCP preflight', () => { + it('skips the discovery round trip when neither fold has anything to inject', async () => { + const { plugins, listNames } = spyingPluginService(); + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + undefined, + plugins, + new InMemoryLinkCodePluginStore(), + ); + + const { options } = await Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + expect(options.mcpServers).toBeUndefined(); + expect(listNames).not.toHaveBeenCalled(); + }); + + it('runs the discovery exactly once per start when a fold has work', async () => { + const { plugins, listNames } = spyingPluginService(); + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + undefined, + customService(customEntry('github')), + plugins, + new InMemoryLinkCodePluginStore(), + ); + + const { options } = await Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + expect(options.mcpServers).toEqual([customEntry('github').server]); + expect(listNames).toHaveBeenCalledTimes(1); + }); +}); + describe('simulator MCP injection at session start', () => { it('appends the session endpoint for MCP-capable agents', async () => { const resolver = new SessionStartOptionsResolver( diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index cc42fd9f5..ee4f71ac0 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -127,12 +127,18 @@ export class SessionStartOptionsResolver { * preflight failed: an override cannot be ruled out, so consumers skip instead of injecting. */ private providerMcpServerNames(options: StartOptions): Effect.Effect | null> { - if (options.kind === 'codex' && this.plugins) { - return this.plugins - .enabledMcpServerNames('codex', { cwd: options.cwd }) - .pipe(Effect.match({ onSuccess: (names) => names, onFailure: () => null })); + if (options.kind !== 'codex' || this.plugins === undefined) { + return Effect.succeed(new Set()); } - return Effect.succeed(new Set()); + // Discovery is a real round trip; when neither fold has anything to inject, a bare Codex + // session must not pay it. + const hasWork = + (this.customMcp?.listEnabled().length ?? 0) > 0 || + (this.linkCodePluginStore?.list().some((entry) => entry.installed.enabled) ?? false); + if (!hasWork) return Effect.succeed(new Set()); + return this.plugins + .enabledMcpServerNames('codex', { cwd: options.cwd }) + .pipe(Effect.match({ onSuccess: (names) => names, onFailure: () => null })); } /** Fold enabled custom MCP servers into the session's server list, warning instead of @@ -152,8 +158,7 @@ export class SessionStartOptionsResolver { } const servers = [...(options.mcpServers ?? [])]; for (const entry of enabled) { - const names = nativeMcpNames; - if (names === null) { + if (nativeMcpNames === null) { warnings.push({ serverName: entry.server.name, reason: 'provider-preflight-failed', @@ -170,7 +175,7 @@ export class SessionStartOptionsResolver { continue; } if ( - names.has(entry.server.name) || + nativeMcpNames.has(entry.server.name) || servers.some((server) => server.name === entry.server.name) ) { warnings.push({ serverName: entry.server.name, reason: 'name-conflict' }); @@ -211,18 +216,20 @@ export class SessionStartOptionsResolver { } return { options, warnings }; } - const names = nativeMcpNames; const servers = [...(options.mcpServers ?? [])]; for (const entry of entries) { const settings = store.getSettings(entry.installed.id); for (const component of entry.manifest.components) { if (component.kind !== 'mcp-server') continue; - if (names === null) { + if (nativeMcpNames === null) { // Without the native name set an override cannot be ruled out — skip, don't inject. warnings.push({ serverName: component.name, reason: 'provider-preflight-failed' }); continue; } - if (names.has(component.name) || servers.some((server) => server.name === component.name)) { + if ( + nativeMcpNames.has(component.name) || + servers.some((server) => server.name === component.name) + ) { warnings.push({ serverName: component.name, reason: 'name-conflict' }); continue; } From 7733052ac787b678559f1cdd79745ca4f5d9e7f0 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 18:23:22 +0800 Subject: [PATCH 18/19] fix(plugin-store): purge pending-uninstall settings before the registry commit --- apps/daemon/src/plugin-store/store.ts | 47 ++++++++++++++------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index a52ec860b..1593d6260 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -282,6 +282,26 @@ async function installExclusive( enabled: true, path: targetDir, }; + // A leftover uninstall marker means the registry had already dropped the id when that + // uninstall's settings cleanup failed. Purge before this install re-registers the id, or the + // boot sweep would discard the marker unread and the inherited values would survive. + const tombstone = pluginUninstallTombstonePath(manifest.id); + if (existsSync(tombstone)) { + try { + savePluginConfigValues(manifest.id, {}); + prunePluginSecrets(secrets, manifest.id); + rmSync(tombstone, { force: true }); + logger.info( + { pluginId: manifest.id, operation: 'plugin.install.purge-pending-uninstall' }, + 'Purged settings left by a committed but unfinished uninstall', + ); + } catch (error) { + logger.warn( + { error, pluginId: manifest.id, operation: 'plugin.install.purge-pending-uninstall' }, + 'Failed to purge settings left by an unfinished uninstall; the marker stays for the boot sweep', + ); + } + } let installedManifest: LinkCodePluginManifest; let retiredDir: string | undefined; let published = false; @@ -353,29 +373,10 @@ async function installExclusive( } } // Reconcile stored settings against the new manifest only after the install commits; a failure - // must not roll back a committed install, so it is logged and left for the next upgrade. - // A leftover uninstall marker means the previous uninstall committed but its settings cleanup - // never ran: the registry had already dropped the id, so this looks like a fresh install. - // Purge here — after this install registers the id again, the boot sweep would discard the - // marker unread ("still registered") and the inherited values would survive an uninstall. - const tombstone = pluginUninstallTombstonePath(manifest.id); - const hadPendingUninstall = existsSync(tombstone); - if (hadPendingUninstall) { - try { - savePluginConfigValues(manifest.id, {}); - prunePluginSecrets(secrets, manifest.id); - logger.info( - { pluginId: manifest.id, operation: 'plugin.install.purge-pending-uninstall' }, - 'Purged settings left by a committed but unfinished uninstall', - ); - } catch (error) { - logger.warn( - { error, pluginId: manifest.id, operation: 'plugin.install.purge-pending-uninstall' }, - 'Failed to purge settings left by an unfinished uninstall', - ); - } - rmSync(tombstone, { force: true }); - } else if (previousRecords.length > 0) { + // must not roll back a committed install, so it is logged and left for the next upgrade. The + // tombstone path above runs before the commit, so a pending-uninstall purge can never coexist + // with previous records here. + if (previousRecords.length > 0) { try { reconcileSettingsForManifest(installedManifest, secrets); } catch (error) { From 60867e469e9483b2046c3ab953aabc8a6b260043 Mon Sep 17 00:00:00 2001 From: adminliu-main Date: Thu, 27 Aug 2026 18:54:38 +0800 Subject: [PATCH 19/19] fix(plugin-store): throw on failed pending-uninstall purge during reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install-time pending-uninstall purge must fail the install when the vault write fails, rather than logging and proceeding. A failed purge followed by a committed install would leave the new install inheriting the uninstalled plugin's vault secrets, since the tombstone is discarded once the id is re-registered. Throw from the catch block instead of logging. At that point no staging, download, or registry write has happened, so the install simply fails, the marker stays, and the id remains unregistered — exactly the state the boot sweep retries. Also correct two comment inaccuracies: - The invariant comment at store.ts:377 now states the correct reason why tombstone and previousRecords can never coexist (constructor sweep discards markers for registered ids before any listener binds). - The test comment at plugin-store.test.ts:769 now reflects that the constructor sweep, not the install-time purge, performs the cleanup when a new store instance is constructed. Add a test that exercises the same-instance reinstall path (production shape) where the vault remains broken across uninstall and reinstall, verifying the install throws and leaves the state for boot-sweep retry. --- .../daemon/src/__tests__/plugin-store.test.ts | 58 ++++++++++++++++++- apps/daemon/src/plugin-store/store.ts | 11 ++-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/daemon/src/__tests__/plugin-store.test.ts b/apps/daemon/src/__tests__/plugin-store.test.ts index 956bce4c2..731085d5e 100644 --- a/apps/daemon/src/__tests__/plugin-store.test.ts +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -763,12 +763,66 @@ describe('DaemonLinkCodePluginStore', () => { format: 'tgz', }, } satisfies LinkCodePluginRelease; - await new DaemonLinkCodePluginStore(baseVault).install(release, 'linkcode-official'); + const store2 = new DaemonLinkCodePluginStore(baseVault); + await store2.install(release, 'linkcode-official'); - // The stale block was purged at install time and the marker consumed; the fresh install + // The constructor sweep consumed the marker and purged the stale block; the fresh install // starts empty instead of inheriting the uninstalled plugin's values. expect(loadPluginConfigValues('arcbox/latex')).toEqual({}); expect(baseVault.refs.get('plugin:arcbox/latex/authcode')).toBeUndefined(); expect(existsSync(pluginUninstallTombstonePath('arcbox/latex'))).toBe(false); }); + + it('aborts the reinstall when the install-time pending-uninstall purge fails', async () => { + // One store per process is the production shape, so a same-instance uninstall→reinstall never + // triggers the constructor sweep — the install-time purge is the only cleanup. If the vault is + // still broken it must throw, leaving the id unregistered and the marker for the boot sweep, + // rather than committing an install that inherits the uninstalled plugin's credentials. + const installed = record('0.2.0'); + writePackage(installed, settingsManifest('0.2.0')); + writeRegistry([installed]); + const baseVault = createInMemoryVault(); + const brokenVault = { + ...baseVault, + namespace(name: Parameters[0]) { + const secrets = baseVault.namespace(name); + if (name !== 'plugin') return secrets; + return { + ...secrets, + replaceAll() { + throw new Error('vault write failed'); + }, + }; + }, + }; + // Seed the secret through the working vault, then reuse a single broken-vault instance so the + // uninstall's prune and the reinstall's purge both fail on the same instance. + baseVault.namespace('plugin').replaceAll(new Map([['arcbox/latex/authcode', 'stale-secret']])); + const store = new DaemonLinkCodePluginStore(brokenVault); + await store.uninstall('arcbox/latex'); + + expect(existsSync(pluginUninstallTombstonePath('arcbox/latex'))).toBe(true); + expect(baseVault.refs.get('plugin:arcbox/latex/authcode')).toBe('stale-secret'); + + mocks.tarExtract.mockImplementation(({ cwd }: { cwd: string }) => { + writeFileSync(join(cwd, 'manifest.json'), JSON.stringify(settingsManifest('0.2.0'))); + }); + const release = { + manifest: settingsManifest('0.2.0'), + artifact: { + urls: ['https://plugins.example/arcbox-latex-0.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + } satisfies LinkCodePluginRelease; + + await expect(store.install(release, 'linkcode-official')).rejects.toThrow( + 'Failed to purge settings for arcbox/latex before reinstall', + ); + + // Unregistered, marker kept, and the stale secret survives — exactly the state the boot sweep retries. + expect(store.get('arcbox/latex')).toBeUndefined(); + expect(existsSync(pluginUninstallTombstonePath('arcbox/latex'))).toBe(true); + expect(baseVault.refs.get('plugin:arcbox/latex/authcode')).toBe('stale-secret'); + }); }); diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts index 1593d6260..9426c8511 100644 --- a/apps/daemon/src/plugin-store/store.ts +++ b/apps/daemon/src/plugin-store/store.ts @@ -296,10 +296,9 @@ async function installExclusive( 'Purged settings left by a committed but unfinished uninstall', ); } catch (error) { - logger.warn( - { error, pluginId: manifest.id, operation: 'plugin.install.purge-pending-uninstall' }, - 'Failed to purge settings left by an unfinished uninstall; the marker stays for the boot sweep', - ); + throw new Error(`Failed to purge settings for ${manifest.id} before reinstall`, { + cause: error, + }); } } let installedManifest: LinkCodePluginManifest; @@ -374,8 +373,8 @@ async function installExclusive( } // Reconcile stored settings against the new manifest only after the install commits; a failure // must not roll back a committed install, so it is logged and left for the next upgrade. The - // tombstone path above runs before the commit, so a pending-uninstall purge can never coexist - // with previous records here. + // constructor's sweepUninstallTombstones discards markers for registered ids before any listener + // binds, so a tombstone and previousRecords can never coexist here. if (previousRecords.length > 0) { try { reconcileSettingsForManifest(installedManifest, secrets);