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/e2e/plugin-marketplace.e2e.ts b/apps/daemon/e2e/plugin-marketplace.e2e.ts new file mode 100644 index 000000000..ec941a69d --- /dev/null +++ b/apps/daemon/e2e/plugin-marketplace.e2e.ts @@ -0,0 +1,217 @@ +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/echo'; +const PLUGIN_VERSION = '0.1.0'; +const SECRET_TOKEN = 'e2e-secret-token'; + +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/echo', + ); + 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({ + marketplaceId: MARKETPLACE_ID, + pluginId: PLUGIN_ID, + version: PLUGIN_VERSION, + }); + assert.equal(installed.pluginId, PLUGIN_ID); + 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'); + + // 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.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: { greeting: '你好', token: SECRET_TOKEN, mode: 'shout' }, + }); + const configFile = JSON.parse(readFileSync(join(home, '.linkcode', 'config.json'), 'utf8')) as { + pluginConfigs?: Record>; + }; + 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 { + 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 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(SECRET_TOKEN), 'token stored in plaintext under os-keyring'); + } else { + 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(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); + 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 }); + + // 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 { + 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/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..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' } }]); @@ -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__/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 new file mode 100644 index 000000000..71c88f195 --- /dev/null +++ b/apps/daemon/src/__tests__/marketplace.test.ts @@ -0,0 +1,329 @@ +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'; + +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: 'mcp-server', name: 'latex', command: 'node', entry: 'dist/index.js' }, + ], + assets: [], + }, + artifact: { + urls: ['releases/arcbox-latex-1.2.0.tgz'], + integrity: 'sha256-7bZ8YaunaCifbaRByeb1I8+v9PiypXCFI+8pxUP46I4=', + format: 'tgz', + }, + }, + ], + }, + ], +}; + +/** 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 = '', + 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('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 () => { + // 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 service = new DaemonLinkCodeMarketplaceService(disabled, fetchIndex); + + await expect(service.refresh('linkcode-official')).rejects.toThrow('Marketplace is disabled'); + expect(fetchIndex).toHaveBeenCalledTimes(1); + 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"' })), + ); + 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(); + } + }); + + 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 new file mode 100644 index 000000000..731085d5e --- /dev/null +++ b/apps/daemon/src/__tests__/plugin-store.test.ts @@ -0,0 +1,828 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { + InstalledLinkCodePlugin, + LinkCodePluginManifest, + LinkCodePluginRelease, +} from '@linkcode/schema'; +import { wait } from 'foxts/wait'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +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'; + +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, +})); + +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 || destination === mocks.renameFailureDestination) { + 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; + +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(); + mocks.removeFailurePrefix = undefined; + mocks.renameFailureDestination = undefined; + mocks.renameFailureSource = undefined; +}); + +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('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('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')); + 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('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')); + 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'); + // 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([ + true, + false, + true, + ]); + + const store = new DaemonLinkCodePluginStore(createInMemoryVault()); + + expect([existsSync(legacy.path), existsSync(live.path), existsSync(retired)]).toEqual([ + 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'); + }); + + 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 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'), + }; + 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')); + 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', + }); + }); + + 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; 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'); + 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', + 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; + const store2 = new DaemonLinkCodePluginStore(baseVault); + await store2.install(release, 'linkcode-official'); + + // 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/config.ts b/apps/daemon/src/config.ts index 56ad8aa0d..daedcd76d 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,72 @@ 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)) { + const { [pluginId]: _removed, ...rest } = configs; + writeConfigFields(file, { pluginConfigs: rest }); + return; + } + 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..0ba01633f 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, @@ -283,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/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..4f08f8392 --- /dev/null +++ b/apps/daemon/src/marketplace/service.ts @@ -0,0 +1,274 @@ +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, + MarketplaceCatalogEntry, + MarketplaceRefreshResult, +} from '@linkcode/engine'; +import type { + LinkCodeMarketplaceConfigList, + LinkCodeMarketplaceIndexReader, + LinkCodeMarketplaceRefreshState, + LinkCodeMarketplaceReleaseIdentity, + LinkCodePluginRelease, +} from '@linkcode/schema'; +import { + isProjectablePluginRelease, + 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 { + 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); + 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) { + 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). + return { + releases: 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 (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, + ); + // 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: { + ...release.artifact, + urls: release.artifact.urls.map((url) => resolveMirrorUrl(url, config.source.url)), + }, + }; + } +} + +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; +} + +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'] { + 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 { + 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..78cf6c5d3 --- /dev/null +++ b/apps/daemon/src/plugin-store/paths.ts @@ -0,0 +1,46 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +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 { + return join(daemonStateDir(), '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.replaceAll(RE_PATH_SEP, '_')]; + return join(pluginsRoot(), ...safe, version); +} + +/** 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-'; + +/** 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 { + const parent = join(pluginPackageDir(pluginId, version), '..'); + mkdirSync(parent, { recursive: true }); + return join(parent, `${PLUGIN_STAGING_PREFIX}${process.pid}-${version}-${randomUUID()}`); +} diff --git a/apps/daemon/src/plugin-store/store.ts b/apps/daemon/src/plugin-store/store.ts new file mode 100644 index 000000000..9426c8511 --- /dev/null +++ b/apps/daemon/src/plugin-store/store.ts @@ -0,0 +1,841 @@ +import { randomUUID } from 'node:crypto'; +import type { Dirent } from 'node:fs'; +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + 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 { 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'; +import { logger } from '../logger'; +import type { SecretStore, SecretVault } from '../secrets'; +import { + makePluginTmpDir, + PLUGIN_RETIRED_INFIX, + PLUGIN_STAGING_PREFIX, + pluginPackageDir, + pluginRegistryPath, + pluginsRoot, + pluginUninstallTombstonePath, +} 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) { + sweepStagingDirs(); + sweepUninstallTombstones(pluginSecretStore(this.vault)); + } + + list(): InstalledLinkCodePluginEntry[] { + const entries: InstalledLinkCodePluginEntry[] = []; + for (const record of currentRegistryRecords()) { + 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) { + // 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; + } + + 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; + // 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 }; + const secretPatch = new Map(); + + if (patch.remove) { + for (const fieldId of patch.remove) { + if (!(fieldId in settings)) continue; + const field = settings[fieldId]; + if (field.secret) secretPatch.set(`${pluginId}/${fieldId}`, undefined); + 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.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; + } + return Promise.resolve(); + } + + /** 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 { + return this.serialize(release.manifest.id, () => + installExclusive(release, marketplaceId, pluginSecretStore(this.vault)), + ); + } + + uninstall(pluginId: string): Promise { + return this.serialize(pluginId, () => { + const records = readRegistryStrict(); + const matches = records.filter((entry) => entry.id === pluginId); + const tombstone = pluginUninstallTombstonePath(pluginId); + if (matches.length > 0) { + // 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); + } + // 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 }); + }); + } + + private serialize(pluginId: string, task: () => Promise | T): Promise { + const run = (this.installChains.get(pluginId) ?? Promise.resolve()) + .catch(noop) + .then(() => task()); + this.installChains.set(pluginId, run); + const settle = (): void => { + if (this.installChains.get(pluginId) === run) this.installChains.delete(pluginId); + }; + void run.catch(noop).finally(settle); + return run; + } +} + +async function installExclusive( + release: LinkCodePluginRelease, + marketplaceId: string, + secrets: SecretStore, +): 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 = 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'); + const record: InstalledLinkCodePlugin = { + id: manifest.id, + version: manifest.version, + marketplaceId, + integrity: artifact.integrity, + 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) { + throw new Error(`Failed to purge settings for ${manifest.id} before reinstall`, { + cause: error, + }); + } + } + let installedManifest: LinkCodePluginManifest; + let retiredDir: string | undefined; + let published = false; + mkdirSync(stagingDir, { recursive: true }); + try { + const downloadArtifact: ManagedAssetArtifact = { + urls: downloadUrls, + integrity: artifact.integrity, + size: artifact.size, + format: 'tgz', + }; + await downloadVerified(downloadArtifact, tgzPath, {}); + // 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( + `Extracted manifest does not match release ${manifest.id}@${manifest.version}`, + ); + } + installedManifest = onDisk; + 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); + 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 { + 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 }, + ); + } + 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) { + 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', + ); + } + } + // 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 + // 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); + } 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', + ); + return { installed: record, manifest: installedManifest }; +} + +/** Delete incomplete staging dirs; retain unproven backups until their exact version is reinstalled. */ +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', + ); + } +} + +/** 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); +} + +/** 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. 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, + 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()) { + // 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); + } + } + // 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); +} + +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; + 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 || !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); + // 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 = readRegistryStrict().filter((entry) => entry.id !== record.id); + 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 = LinkCodePluginManifestReaderSchema.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 { + // 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()) { + if (key.startsWith(prefix)) continue; + const value = secrets.get(key); + if (value !== null) surviving.set(key, value); + } + secrets.replaceAll(surviving); +} diff --git a/apps/daemon/src/secrets/vault.ts b/apps/daemon/src/secrets/vault.ts index 2f710caf0..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 @@ -51,7 +53,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'; @@ -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 05d58c9da..a1b18e378 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 — 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/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..2c6c90c1c --- /dev/null +++ b/packages/client/core/src/__tests__/plugin-market.test.ts @@ -0,0 +1,230 @@ +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' }, + // Readonly tuple is assignable to the payload's string[] presence bits. + configuredSecrets: ['password'] as string[], + } 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: ['api.key'], + }); + const request = lastRequest(transport); + expect(request).toMatchObject({ + kind: 'plugin-config.set', + pluginId: 'linkcode/mail', + set: { preset: 'qq', readonly: true }, + remove: ['api.key'], + }); + + transport.receive({ + kind: 'plugin-config.updated', + 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(); + }); + + 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..356b61aab 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,36 @@ 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, + configuredSecrets: p.configuredSecrets, + }); + break; case 'config.probe-models.result': this.pending.resolve('accountModels', p.replyTo, p.models); break; @@ -903,6 +949,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..c4fbb8ff5 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,41 @@ 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`; `configuredSecrets` carries their + * presence bits. */ +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; export function resolveRandomUUID(provider?: RandomUUID): RandomUUID { @@ -106,6 +147,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 +215,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..1446cc704 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 the cached catalog. */ + 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..dd92bdf42 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 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 { + 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..42a68e608 --- /dev/null +++ b/packages/client/workbench/src/mock/data/linkcode-marketplace.ts @@ -0,0 +1,118 @@ +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 echo + * 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', + release: { + manifest: { + manifestVersion: 1, + 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: 'echo', + description: 'Echo tool: returns the input text, optionally uppercased', + command: 'node', + entry: 'dist/index.js', + env: { + ECHO_GREETING: 'greeting', + ECHO_TOKEN: 'token', + ECHO_MODE: 'mode', + ECHO_MAX_CHARS: 'maxChars', + ECHO_PREVIEW: 'preview', + }, + }, + ], + settings: { + greeting: { + type: 'string', + label: 'Greeting', + description: 'Prefix prepended to every echoed text', + required: true, + }, + token: { + type: 'password', + label: 'Token', + description: 'Only exercised to prove secret fields land in the vault', + secret: true, + required: true, + }, + mode: { + type: 'enum', + label: 'Mode', + enum: ['plain', 'shout'], + default: 'plain', + }, + maxChars: { + type: 'number', + label: 'Max echo characters', + default: 1000, + }, + preview: { + type: 'boolean', + label: 'Preview', + description: 'Log every echo to stdout as well', + default: false, + }, + }, + assets: [], + }, + artifact: { + urls: ['plugins/echo-0.1.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..97f606b3e 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, @@ -36,6 +37,7 @@ import type { } from '@linkcode/schema'; import { AGENT_INPUT_CAPABILITIES, + isProjectablePluginRelease, managedAgentAssetId, managedAssetIdEquals, managedAssetKey, @@ -51,6 +53,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'; @@ -119,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; @@ -192,6 +201,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/echo', + { + marketplaceId: 'linkcode-official', + version: '0.1.0', + values: { + greeting: 'Hello', + token: 'mock-secret-token', + mode: 'plain', + maxChars: 1000, + preview: false, + }, + }, + ], + ]); private readonly permissions = new Map(); private readonly questions = new Map(); private history: AgentHistorySession[] = []; @@ -480,6 +514,106 @@ 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: PROJECTABLE_SEED_LINKCODE_RELEASES, + }); + break; + } + case 'plugin-market.install': { + await wait(CONTROL_LATENCY_MS); + const known = PROJECTABLE_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), + configuredSecrets: configuredLinkCodeSecrets(settings, installed.values), + }); + break; + } case 'workspace.list': await wait(CONTROL_LATENCY_MS); this.send({ @@ -1667,6 +1801,31 @@ export class DevMockHost { this.send({ kind: 'request.failed', replyTo, message, ...reporting }); } + /** 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; + settings: LinkCodePluginSettings; + values: Record; + configuredSecrets: string[]; + }> { + 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 ?? {}; + views.push({ + id: pluginId, + version: installed.version, + settings, + values: maskLinkCodePluginValues(settings, installed.values), + configuredSecrets: configuredLinkCodeSecrets(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 +1886,31 @@ 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 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-dialog.test.tsx b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx new file mode 100644 index 000000000..ff15ad03d --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config-dialog.test.tsx @@ -0,0 +1,112 @@ +// @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' }, + // 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 }, +}; + +function renderDialog( + overrides: Partial> = {}, +) { + const onSubmit = vi.fn(); + render( + , + ); + return { onSubmit }; +} + +describe('LinkCodePluginConfigDialog', () => { + it('renders one control per declared field, secrets masked', () => { + // 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(); + 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' }, + // 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')); + 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', + // Keyed by the real setting id, not the escaped form key the input was registered under. + 'body.max': 4000, + readonly: true, + }, + }); + }); + + it('blocks submit on a blank required field', async () => { + const { onSubmit } = renderDialog(); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + // 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 new file mode 100644 index 000000000..546f116ab --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/__tests__/linkcode-config.test.ts @@ -0,0 +1,160 @@ +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' }, + // 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 }, +}; + +describe('pluginConfigDefaults', () => { + it('prefers stored values, then manifest defaults, then type defaults', () => { + expect( + pluginConfigDefaults(SETTINGS, { + account: 'you@163.com', + 'body.max': 4000, + readonly: true, + }), + ).toEqual({ + account: 'you@163.com', + password: '', + preset: '163', + nickname: '', + body$max: '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, '', 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, '', 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', 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, true)).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: '', + body$max: '4000', + readonly: true, + }, + ); + expect(patch.set).toEqual({ + account: 'you@163.com', + password: 'secret', + preset: 'qq', + 'body.max': 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: '', + body$max: '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('stores a value equal to the manifest default as a removal, so upgrades can change it', () => { + const patch = buildPluginConfigPatch( + SETTINGS, + { preset: 'qq', 'body.max': 4000 }, + { + ...pluginConfigDefaults(SETTINGS, { preset: 'qq', 'body.max': 4000 }), + preset: '163', + body$max: '8000', + }, + ); + + expect(patch.set).toBeUndefined(); + 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', () => { + 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..0e8f2299a 100644 --- a/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/plugins/__tests__/view.test.ts @@ -1,8 +1,11 @@ -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, + linkcodeCatalogCards, + linkcodeInstalledRow, pluginCardView, pluginMcpServerRows, pluginProviderGroups, @@ -272,3 +275,232 @@ describe('pluginMcpServerRows', () => { ]); }); }); + +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', + [catalogEntry('1.0.0')], + new Map([['linkcode/mail', '1.0.0']]), + ); + + expect(card).toMatchObject({ + key: 'linkcode-official:linkcode/mail', + marketplaceId: 'linkcode-official', + pluginId: 'linkcode/mail', + 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, + }); + }); + + 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 }); + }); +}); + +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: {}, + configuredSecrets: [], + }), + ).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: {}, + configuredSecrets: [], + }).hasSettings, + ).toBe(false); + }); +}); + +describe('filterLinkCodeCatalogCards', () => { + it('filters by the precomputed haystack, blank query keeps all', () => { + const cards = linkcodeCatalogCards( + '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', + }, + }, + }, + ], + 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/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..e454222f5 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/linkcode-config-dialog.tsx @@ -0,0 +1,211 @@ +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, + pluginConfigFormKey, + 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>; + /** Presence bits from the masked read: which secret fields hold a stored value. */ + configuredSecrets: readonly string[]; + 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, + configuredSecrets, + 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, + secretConfigured, + control, + register, + busy, +}: { + fieldId: string; + field: LinkCodePluginSettingField; + secretConfigured: boolean; + control: Control; + register: UseFormRegister; + busy: boolean; +}): 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 + // propagate disabled — pass it explicitly. + return ( +
+
+ + {label} + + {field.description === undefined ? null : ( + {field.description} + )} +
+ ( + switchField.onChange(checked)} + aria-labelledby={`${formKey}-label`} + /> + )} + /> +
+ ); + } + + const validate = (raw: string | boolean): true | string => { + const result = validatePluginConfigField(field, raw, secretConfigured); + 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..6e977e932 --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/linkcode-config.ts @@ -0,0 +1,123 @@ +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 values use escaped keys and strings except booleans; secret fields start blank so an + * untouched secret keeps its stored value. */ +export type PluginConfigFormValues = Record; + +/** 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'; + +export function pluginConfigDefaults( + settings: LinkCodePluginSettings, + values: Readonly>, +): 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[formKey] = + 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[formKey] = ''; + continue; + } + // `in` over indexed access: the masked read may omit keys the index-signature type claims exist. + if (fieldId in values) { + defaults[formKey] = String(values[fieldId]); + continue; + } + defaults[formKey] = field.default === undefined ? '' : String(field.default); + } + return defaults; +} + +/** 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). 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: boolean, +): true | PluginConfigFieldError { + if (field.type === 'boolean') return true; + const value = typeof raw === 'string' ? raw : String(raw); + if (value === '') { + if (field.required !== true) return true; + return secretConfigured && field.secret ? true : 'required'; + } + 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). + * - 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, + values: Readonly>, + form: PluginConfigFormValues, +): { set?: Record; remove?: string[] } { + const set: Record = {}; + const remove: string[] = []; + for (const [fieldId, field] of Object.entries(settings)) { + // 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) { + if (fieldId in values) remove.push(fieldId); + } else { + set[fieldId] = typed; + } + continue; + } + const value = typeof raw === 'string' ? raw : String(raw); + if (value === '') { + if (!field.secret && fieldId in values) remove.push(fieldId); + continue; + } + 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 }), + ...(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..e9a56b1cb --- /dev/null +++ b/packages/client/workbench/src/settings/plugins/linkcode-tab.tsx @@ -0,0 +1,191 @@ +import type { LinkCodeMarketplaceConfig, LinkCodePluginId } from '@linkcode/schema'; +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 { useRef, 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, linkcodeCatalogCards, 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); + // 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); + 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 => { + 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 => { + 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 || 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, + configuredSecrets: result.configuredSecrets, + } + : view, + ), + { revalidate: false }, + ); + setConfiguring((current) => (current === editing.id ? null : current)); + } finally { + savePendingRef.current = false; + } + }; + + return ( +
+

{t('hint')}

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

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

+
+ ) : ( + enabledMarketplaces.map((marketplace) => ( + { + void onInstall(card).catch(noop); + }} + /> + )) + )} + {editing === undefined ? null : ( + setConfiguring(null)} + onSubmit={(patch) => { + void onSubmitConfig(patch).catch(noop); + }} + /> + )} +
+ ); +} + +function MarketplaceCatalog({ + marketplace, + installedVersions, + searchQuery, + busy, + onInstall, +}: { + marketplace: LinkCodeMarketplaceConfig; + installedVersions: ReadonlyMap; + searchQuery: string; + busy: boolean; + onInstall: (card: LinkCodeCatalogCardView) => void; +}): React.ReactNode { + const { data, isLoading, isValidating, mutate } = usePluginMarketCatalog(marketplace.id); + + const cards = + data === undefined + ? undefined + : filterLinkCodeCatalogCards( + linkcodeCatalogCards(marketplace.id, data.releases, installedVersions), + searchQuery, + ); + + const onRefresh = (): void => { + void mutate().catch(noop); + }; + + return ( + + ); +} diff --git a/packages/client/workbench/src/settings/plugins/plugins-settings.tsx b/packages/client/workbench/src/settings/plugins/plugins-settings.tsx index fbe7a7444..124d2dc79 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'; @@ -73,7 +74,6 @@ export function PluginsSettingsPanel(): React.ReactNode { scope: row.standaloneScope, enabled, }); - if (updated === undefined) return; void mutate( (current) => current && { @@ -93,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('、') }), @@ -111,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 ( @@ -159,6 +159,7 @@ export function PluginsSettingsPanel(): React.ReactNode { /> } mcpTab={} + linkcodeTab={} skillsTab={ 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 { + key: view.id, + pluginId: view.id, + title: linkcodePluginTitle(view.id), + version: view.version, + hasSettings: !isObjectEmpty(view.settings), + }; +} + +export function filterLinkCodeCatalogCards( + cards: readonly LinkCodeCatalogCardView[], + query: string, +): LinkCodeCatalogCardView[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...cards]; + return cards.filter((card) => card.searchText.includes(needle)); +} 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/__tests__/plugin.test.ts b/packages/foundation/schema/src/model/__tests__/plugin.test.ts index 795b9a435..c945417ab 100644 --- a/packages/foundation/schema/src/model/__tests__/plugin.test.ts +++ b/packages/foundation/schema/src/model/__tests__/plugin.test.ts @@ -267,6 +267,121 @@ 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('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) => { + expect( + LinkCodePluginManifestSchema.safeParse({ + ...mailManifest, + components: [{ ...mailManifest.components[0], entry }], + }).success, + ).toBe(false); + }, + ); + + 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({ + ...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("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({ + 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/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 f4ab15267..af8bd0554 100644 --- a/packages/foundation/schema/src/model/linkcode-marketplace.ts +++ b/packages/foundation/schema/src/model/linkcode-marketplace.ts @@ -8,10 +8,26 @@ 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). */ +export 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 (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/model/linkcode-plugin.ts b/packages/foundation/schema/src/model/linkcode-plugin.ts index 4155f625c..344da8685 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+$/; @@ -106,6 +109,146 @@ 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'], + }); + } + if (field.type === 'password' && field.secret !== true) { + ctx.addIssue({ + code: 'custom', + message: 'A password setting must be secret', + 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, +); +export type LinkCodePluginSettings = z.infer; + +const linkCodePluginMcpServerFields = { + kind: z.literal('mcp-server'), + name: z + .string() + .refine( + (value) => 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 + * 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 +259,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 +284,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 +311,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 +export const LinkCodePluginManifestReaderSchema = z .object(linkCodePluginManifestFields) - .superRefine(rejectDuplicateComponents); + .superRefine(rejectDuplicateComponents) + .superRefine(rejectUnresolvedEnvBindings); export const LinkCodePluginArchiveFormatSchema = z.enum(['tgz', 'zip']); export type LinkCodePluginArchiveFormat = z.infer; @@ -186,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 35c1b5ec4..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 = 78 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/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..7b27044f1 --- /dev/null +++ b/packages/foundation/schema/src/wire/plugin-config.ts @@ -0,0 +1,52 @@ +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; `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({ + 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), + /** Presence bits for secret fields (ids only, never values). */ + configuredSecrets: z.array(z.string().min(1)), + }), + ), + }), + 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), + configuredSecrets: z.array(z.string().min(1)), + }), +] 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/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/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..b67ad108b --- /dev/null +++ b/packages/foundation/schema/tests/contract/wire/plugin-config.test.ts @@ -0,0 +1,79 @@ +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('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(false); + expect( + parseWireMessage( + envelope({ + kind: 'plugin-config.updated', + replyTo: 'request-1', + pluginId: 'linkcode/mail', + values: {}, + }), + ).ok, + ).toBe(false); + }); + + 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([]); + }); +}); 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-config.test.ts b/packages/host/engine/src/__tests__/plugin-config.test.ts new file mode 100644 index 000000000..29fe0a19a --- /dev/null +++ b/packages/host/engine/src/__tests__/plugin-config.test.ts @@ -0,0 +1,99 @@ +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'); + + // 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', + }); + }); + + 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__/plugin-market.test.ts b/packages/host/engine/src/__tests__/plugin-market.test.ts new file mode 100644 index 000000000..29a1ff071 --- /dev/null +++ b/packages/host/engine/src/__tests__/plugin-market.test.ts @@ -0,0 +1,397 @@ +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('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(); + 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('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')); + 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..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,9 +10,10 @@ 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'; import { PluginService } from '../plugin/service'; import { SessionStartOptionsResolver } from '../session/start-options-resolver'; import type { SimulatorMcpProvider } from '../simulator/mcp'; @@ -90,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( @@ -169,25 +219,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' })] }); @@ -247,16 +299,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([]); } @@ -353,3 +408,285 @@ 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([]); + }); + + 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([]); + }); + + 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/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..ddcd500bf 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -9,6 +9,19 @@ 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 { PluginConfigValidationError, validatePluginConfigPatch } 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..10a53b02d --- /dev/null +++ b/packages/host/engine/src/plugin/config-request-handler.ts @@ -0,0 +1,69 @@ +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, + configuredSecrets: [...view.configuredSecrets], + })); + 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 masked = this.config.maskedView(payload.pluginId); + this.transport.send( + createWireMessage({ + kind: 'plugin-config.updated', + replyTo: payload.clientReqId, + pluginId: payload.pluginId, + values: masked.values, + configuredSecrets: [...masked.configuredSecrets], + }), + ); + }), + ), + ), + ); + 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..9a87baff4 --- /dev/null +++ b/packages/host/engine/src/plugin/config-service.ts @@ -0,0 +1,120 @@ +import type { LinkCodePluginSettings } from '@linkcode/schema'; +import { Effect } from 'effect'; +import { OperationError, RequestError } from '../failure'; +import type { + InstalledLinkCodePluginEntry, + 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; `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[]; +} + +/** + * 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() + .map((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) => + 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. */ + maskedView(pluginId: string): Pick { + const entry = this.store.get(pluginId); + if (entry === undefined) return { values: {}, configuredSecrets: [] }; + const merged = this.store.getSettings(pluginId); + return { + values: maskValues(entry, merged), + configuredSecrets: configuredSecrets(entry, merged), + }; + } +} + +function viewFor( + entry: InstalledLinkCodePluginEntry, + merged: Record, +): PluginConfigView { + return { + id: entry.installed.id, + 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, +): 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..febcf3e81 --- /dev/null +++ b/packages/host/engine/src/plugin/linkcode-store.ts @@ -0,0 +1,171 @@ +import type { + InstalledLinkCodePlugin, + 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 { + 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[]; +} + +/** 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}`, + ); + } + // '' 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( + field.secret === true + ? `Plugin setting ${fieldId} must not be an empty secret` + : `Plugin setting ${fieldId} must not be an empty value`, + ); + } + } + } + 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 + * 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 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; + 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 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 { + // 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(); + 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..2a2217139 --- /dev/null +++ b/packages/host/engine/src/plugin/market-request-handler.ts @@ -0,0 +1,202 @@ +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', + }), + ); + } + const config = marketplace.list().find((entry) => entry.id === marketplaceId); + if (config === undefined) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown marketplace: ${marketplaceId}`, + }), + ); + } + if (!config.enabled) { + return Effect.fail( + new RequestError({ + code: 'forbidden', + message: `Marketplace is disabled: ${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 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( + 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..ee4f71ac0 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( @@ -42,8 +45,10 @@ 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); return Effect.gen(function* () { if (defaults.unavailable) { // Starting anyway would point the agent at an endpoint it cannot speak, which surfaces @@ -65,10 +70,12 @@ export class SessionStartOptionsResolver { }), ); } - const custom = yield* withCustomMcpServers(defaults.options); - const resolved = withSimulatorMcp(custom.options, sessionId); + 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: custom.warnings }; + if (!upstream) return { options: resolved, ...account, warnings: pluginInjected.warnings }; if (!translator) { return yield* Effect.fail( new RequestError({ @@ -102,67 +109,158 @@ 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; } + /** + * 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 === undefined) { + 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 * 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; + const servers = [...(options.mcpServers ?? [])]; + for (const entry of enabled) { + if (nativeMcpNames === 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 ( + nativeMcpNames.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 + * each component's `env` mapping against the plugin's stored settings. Same warning contract as + * 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[], + nativeMcpNames: ReadonlySet | null, + ): { 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' }); } - if ( - names.has(entry.server.name) || - servers.some((server) => server.name === entry.server.name) - ) { - warnings.push({ serverName: entry.server.name, reason: 'name-conflict' }); - continue; + } + } + 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 (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 ( + nativeMcpNames.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]); } - servers.push(entry.server); } - return { - options: - servers.length === 0 && options.mcpServers === undefined - ? options - : { ...options, mcpServers: servers }, - warnings, + // 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, + }; } /** Append the session's simulator MCP endpoint for agents whose SDK can consume it. */ 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/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 09c5e4028..33f2c205c 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}', @@ -824,6 +825,7 @@ export const en = { notInstalled: 'Not installed', cancel: 'Cancel', install: 'Install', + update: 'Update', uninstall: 'Uninstall', uninstallTitle: 'Uninstall “{title}”?', uninstallHint: @@ -875,6 +877,28 @@ 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.', + 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: { + save: 'Save', + cancel: 'Cancel', + required: 'Required', + invalidNumber: 'Enter a number', + secretPlaceholder: 'Leave blank to keep unchanged', + selectPlaceholder: 'Select…', + }, + }, mcp: { customTitle: 'Custom MCP servers', customHint: diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index ff6a38b10..a3ca0ab8a 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}', @@ -809,6 +810,7 @@ export const zhCN = { notInstalled: '未安装', cancel: '取消', install: '安装', + update: '更新', uninstall: '卸载', uninstallTitle: '卸载「{title}」?', uninstallHint: '将删除本机上的插件文件与配置;条目会回到「市场」,重新安装即可。', @@ -859,6 +861,28 @@ export const zhCN = { empty: '还没有发现任何技能。', noSearchResults: '没有匹配的技能。', }, + linkcode: { + hint: 'LinkCode 自有插件市场:目录由本机 daemon 刷新;插件声明的配置项在安装后于此填写,密钥只保存在本机,不会回传。', + installedTitle: '已安装', + installedEmpty: '还没有安装任何 LinkCode 插件;从下方市场目录挑一个。', + noMarketplaces: '还没有配置任何插件市场。', + allMarketplacesDisabled: '已配置的插件市场都已停用。', + catalogEmpty: '这个市场暂时没有可用的插件。', + refresh: '刷新目录', + installed: '已安装', + installedNewer: '已安装更新的版本', + switchVersion: '切换到此版本', + configure: '设置', + settingsTitle: '「{title}」设置', + form: { + save: '保存', + cancel: '取消', + required: '必填项', + invalidNumber: '请输入数字', + secretPlaceholder: '留空保持不变', + selectPlaceholder: '请选择…', + }, + }, mcp: { customTitle: '自定义 MCP 服务', customHint: 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..de0efa78c --- /dev/null +++ b/packages/presentation/ui/src/shell/plugins/linkcode-catalog.tsx @@ -0,0 +1,241 @@ +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'); + // 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 ( + +
+
+ {card.title} + v{card.version} + {card.installed ? {t('linkcode.installed')} : null} + {card.installedNewer ? ( + {t('linkcode.installedNewer')} + ) : 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..fe2cdb8d9 100644 --- a/packages/presentation/ui/src/shell/plugins/types.ts +++ b/packages/presentation/ui/src/shell/plugins/types.ts @@ -80,3 +80,34 @@ export interface CustomMcpServerRow { enabled: boolean; secretKeys: string[]; } + +/** One entry of a LinkCode marketplace catalog (the daemon-refreshed index). */ +export interface LinkCodeCatalogCardView { + /** `${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; +} + +/** 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..e989c93d4 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 @@ -8311,10 +8314,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'} @@ -18979,8 +18978,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.2.0: {} - ip-address@10.3.1: {} ipaddr.js@1.9.1: {} @@ -21663,7 +21660,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/scripts/dev-marketplace.mts b/scripts/dev-marketplace.mts new file mode 100644 index 000000000..d01d56753 --- /dev/null +++ b/scripts/dev-marketplace.mts @@ -0,0 +1,222 @@ +/** + * 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 + * 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 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. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +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 PLUGIN_ID = 'linkcode/echo'; +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: 'Echo(市场调试)', + description: '合成调试插件:回显文本,覆盖 string / password(secret) / enum 三种设置形态。', + keywords: ['echo', 'debug', 'marketplace'], + components: [ + { + kind: 'mcp-server', + name: 'echo', + description: 'Echo tool (returns the input text, optionally uppercased)', + command: 'node', + entry: 'dist/index.js', + env: { + ECHO_GREETING: 'greeting', + ECHO_TOKEN: 'token', + ECHO_MODE: 'mode', + }, + }, + ], + settings: { + greeting: { + type: 'string', + label: '问候语', + description: '回显内容的前缀', + required: true, + }, + token: { + type: 'password', + label: '令牌', + description: '仅用于验证 secret 字段走 vault 而不落 config.json', + secret: true, + required: true, + }, + mode: { + type: 'enum', + label: '模式', + description: 'shout 会把回显内容转成大写', + enum: ['plain', 'shout'], + default: 'plain', + }, + }, + assets: [], +}; + +// Minimal newline-delimited-JSON stdio MCP server with zero dependencies. +const MCP_PAYLOAD = String.raw`#!/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`); + writeFileSync(join(staging, 'dist', 'index.js'), MCP_PAYLOAD); + + 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']); + 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(); +}