diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 9cccb3634e..19b4acc25c 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -85,8 +85,14 @@ export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; // bodies, and the CDN install scripts read it for fresh installs. export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`; export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; -export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; -export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; +// The marketplace catalog location constants live in the shared +// agent-core-v2 plugin domain (kap-server consumes them from there). +// Deep-path import: this module is evaluated on every CLI invocation, so it +// must not pull in the engine root. +export { + KIMI_CODE_PLUGIN_MARKETPLACE_URL, + KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, +} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; // Official plugins whose usage bills against the user's plan quota. Installing // one of these shows a quota note after the install result. export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource']; diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 2ad4caa890..557698111a 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -1,77 +1,39 @@ -import { readFile, stat } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +/** + * `#/utils/plugin-marketplace` — CLI-side wrapper over the shared plugin + * marketplace client/parser (`@moonshot-ai/agent-core-v2`, + * `app/plugin/marketplace`). The shared module owns catalog reading, the + * lenient entry normalization, source resolution, and version derivation; + * this wrapper adds only the CLI's configured-source resolution (option → + * env → production default), the source-checkout fallback for offline dev, + * and the caller-supplied built-in capability entry injection. + */ -import { gt, valid } from 'semver'; +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + parsePluginMarketplace, + readPluginMarketplace, + withBuiltInEntries, + withLatestVersions, + type MarketplaceLocation, + type PluginMarketplace, + type PluginMarketplaceEntry, +} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, } from '#/constant/app'; -export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const; - -export type PluginMarketplaceTier = (typeof PLUGIN_MARKETPLACE_TIERS)[number]; - -export interface PluginMarketplaceEntry { - readonly id: string; - readonly displayName: string; - readonly source: string; - readonly tier?: PluginMarketplaceTier; - readonly version?: string; - readonly description?: string; - readonly homepage?: string; - readonly keywords?: readonly string[]; - /** - * Internal provenance flag for client-injected built-in rows. The catalog - * parser builds entries field-by-field and never sets it, so a custom - * catalog cannot forge it (unlike the `capability:` source string). - */ - readonly builtIn?: boolean; -} - -export interface PluginMarketplace { - readonly source: string; - readonly version?: string; - readonly plugins: readonly PluginMarketplaceEntry[]; -} - -export type PluginUpdateStatus = - | { readonly kind: 'not-installed' } - | { readonly kind: 'up-to-date'; readonly version?: string } - | { readonly kind: 'update'; readonly local: string; readonly latest: string }; - -/** - * Compare a marketplace entry's (latest) version against the locally installed - * version. Only reports `update` when both are valid semver and latest > local, - * so a stale or non-semver version never produces a spurious or downgrading prompt. - */ -export function computeUpdateStatus( - latest: string | undefined, - local: string | undefined, - installed: boolean, -): PluginUpdateStatus { - if (!installed) return { kind: 'not-installed' }; - if ( - latest !== undefined && - local !== undefined && - valid(latest) !== null && - valid(local) !== null && - gt(latest, local) - ) { - return { kind: 'update', local, latest }; - } - // Report only the actual installed version. When it is unknown, don't borrow the - // marketplace version — that would falsely claim "up to date" and hide future updates. - return { kind: 'up-to-date', version: local }; -} - -interface MarketplaceLocation { - readonly raw: string; - readonly kind: 'remote' | 'local'; - readonly resolved: string; -} +export { + computeUpdateStatus, + PLUGIN_MARKETPLACE_TIERS, + type PluginMarketplace, + type PluginMarketplaceEntry, + type PluginMarketplaceTier, + type MarketplaceUpdateStatus, +} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; export interface LoadPluginMarketplaceOptions { readonly workDir: string; @@ -89,352 +51,37 @@ export async function loadPluginMarketplace( options: LoadPluginMarketplaceOptions, ): Promise { const configuredSource = options.source ?? process.env[KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV]; - const location = resolveMarketplaceLocation( - configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL, - options.workDir, - ); + const source = configuredSource ?? KIMI_CODE_PLUGIN_MARKETPLACE_URL; const fetchImpl = options.fetchImpl ?? fetch; - let raw: string; + let read: { raw: string; location: MarketplaceLocation }; try { - raw = await readMarketplaceText(location, fetchImpl); + read = await readPluginMarketplace({ + source, + workDir: options.workDir, + fetchImpl, + sourceCheckoutLocation: + configuredSource === undefined ? getSourceCheckoutMarketplaceLocation : undefined, + }); } catch (error) { - const fallback = - configuredSource === undefined ? await getSourceCheckoutMarketplaceLocation() : undefined; - if (fallback === undefined) { - if (options.builtInEntries !== undefined) { - // The built-in entries do not come from the catalog — keep them - // visible when the catalog itself is unreachable. - return withBuiltInEntries({ source: location.resolved, plugins: [] }, options.builtInEntries); - } - throw error; + if (options.builtInEntries !== undefined) { + // The built-in entries do not come from the catalog — keep them + // visible when the catalog itself is unreachable. + return withBuiltInEntries({ source, plugins: [] }, options.builtInEntries); } - raw = await readMarketplaceText(fallback, fetchImpl); - const marketplace = await withLatestVersions(parsePluginMarketplace(raw, fallback), fetchImpl); - return options.builtInEntries !== undefined - ? withBuiltInEntries(marketplace, options.builtInEntries) - : marketplace; + throw error; } - const marketplace = await withLatestVersions(parsePluginMarketplace(raw, location), fetchImpl); + const marketplace = await withLatestVersions( + parsePluginMarketplace(read.raw, read.location), + fetchImpl, + ); return options.builtInEntries !== undefined ? withBuiltInEntries(marketplace, options.builtInEntries) : marketplace; } -/** - * Built-in capability entries (kimi-cu, kimi-webbridge) are injected by the - * client instead of being served by the marketplace catalog, so their - * visibility is bound to the client version — older clients never see them. - * Same-id catalog rows are MASKED, not merged: what these ids mean stays - * decided by the client release. The catalog may contribute only its version - * so the built-in row can use the normal update badge while keeping the - * capability install route and client-owned copy. - */ -function withBuiltInEntries( - marketplace: PluginMarketplace, - builtIns: readonly PluginMarketplaceEntry[], -): PluginMarketplace { - const builtInIds = new Set(builtIns.map((entry) => entry.id)); - const catalogById = new Map(marketplace.plugins.map((entry) => [entry.id, entry])); - const catalog = marketplace.plugins.filter((entry) => !builtInIds.has(entry.id)); - const enrichedBuiltIns = builtIns.map((entry) => { - const version = catalogById.get(entry.id)?.version; - return version === undefined ? entry : { ...entry, version }; - }); - return { ...marketplace, plugins: [...catalog, ...enrichedBuiltIns] }; -} - -async function withLatestVersions( - marketplace: PluginMarketplace, - fetchImpl: typeof fetch, -): Promise { - const plugins = await Promise.all( - marketplace.plugins.map(async (entry) => { - if (entry.version !== undefined) return entry; - const latest = await resolveLatestGithubRelease(entry.source, fetchImpl); - return latest === undefined ? entry : { ...entry, version: latest }; - }), - ); - return { ...marketplace, plugins }; -} - -export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (error) { - throw new Error(`Plugin marketplace is not valid JSON: ${formatParseError(error)}`, { - cause: error, - }); - } - - if (!isRecord(parsed)) { - throw new TypeError('Plugin marketplace must be an object.'); - } - const rawPlugins = parsed['plugins']; - if (!Array.isArray(rawPlugins)) { - throw new TypeError('Plugin marketplace must contain a "plugins" array.'); - } - - return { - source: location.resolved, - version: stringField(parsed, 'version'), - plugins: rawPlugins.map((entry, index) => parseMarketplaceEntry(entry, index, location)), - }; -} - -function resolveMarketplaceLocation(source: string, workDir: string): MarketplaceLocation { - const trimmed = source.trim(); - if (trimmed.length === 0) { - throw new Error(`${KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`); - } - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return { raw: trimmed, kind: 'remote', resolved: trimmed }; - } - if (trimmed.startsWith('file://')) { - const path = fileURLToPath(trimmed); - return { raw: trimmed, kind: 'local', resolved: path }; - } - return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) }; -} - async function getSourceCheckoutMarketplaceLocation(): Promise { - const sourceDir = dirname(fileURLToPath(import.meta.url)); - const marketplacePath = resolve(sourceDir, '../../../../plugins/marketplace.json'); + const marketplacePath = resolve(import.meta.dirname, '../../../../plugins/marketplace.json'); const info = await stat(marketplacePath).catch(() => undefined); if (info?.isFile() !== true) return undefined; return { raw: marketplacePath, kind: 'local', resolved: marketplacePath }; } - -async function readMarketplaceText( - location: MarketplaceLocation, - fetchImpl: typeof fetch, -): Promise { - if (location.kind === 'local') { - return readFile(location.resolved, 'utf8'); - } - const response = await fetchImpl(location.resolved); - if (!response.ok) { - throw new Error(`Plugin marketplace returned HTTP ${response.status}`); - } - return response.text(); -} - -function parseMarketplaceEntry( - value: unknown, - index: number, - location: MarketplaceLocation, -): PluginMarketplaceEntry { - if (!isRecord(value)) { - throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`); - } - const id = requiredString(value, 'id', index); - validateMarketplaceEntryType(value, id); - const source = stringField(value, 'source') ?? - stringField(value, 'url') ?? - stringField(value, 'downloadUrl'); - if (source === undefined) { - throw new Error(`Plugin marketplace entry ${id} must define "source".`); - } - const resolvedSource = resolveEntrySource(source, location); - return { - id, - displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id, - source: resolvedSource, - tier: parseMarketplaceTier(value, id), - version: stringField(value, 'version') ?? deriveVersionFromGithubSource(resolvedSource), - description: stringField(value, 'description') ?? stringField(value, 'shortDescription'), - homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'), - keywords: stringArrayField(value, 'keywords'), - }; -} - -function validateMarketplaceEntryType(value: Record, id: string): void { - const raw = value['type']; - if (raw === undefined) return; - if (typeof raw !== 'string') { - throw new TypeError(`Plugin marketplace entry ${id} "type" must be a string.`); - } - const type = raw.trim(); - if (type === 'plugin' || type === 'managed' || type === 'guide') return; - throw new Error( - `Plugin marketplace entry ${id} "type" must be "plugin". Legacy aliases "managed" and "guide" are also accepted.`, - ); -} - -function parseMarketplaceTier( - value: Record, - id: string, -): PluginMarketplaceTier | undefined { - const raw = value['tier']; - if (raw === undefined) return undefined; - if (typeof raw !== 'string') { - throw new TypeError(`Plugin marketplace entry ${id} "tier" must be a string.`); - } - const tier = raw.trim(); - if (tier.length === 0) return undefined; - if ((PLUGIN_MARKETPLACE_TIERS as readonly string[]).includes(tier)) { - return tier as PluginMarketplaceTier; - } - throw new Error( - `Plugin marketplace entry ${id} "tier" must be one of: ${PLUGIN_MARKETPLACE_TIERS.join(', ')}.`, - ); -} - -function resolveEntrySource(source: string, location: MarketplaceLocation): string { - const trimmed = source.trim(); - if ( - trimmed.startsWith('http://') || - trimmed.startsWith('https://') || - trimmed.startsWith('~/') || - trimmed === '~' || - isAbsolute(trimmed) - ) { - return trimmed; - } - if (trimmed.startsWith('file://')) return fileURLToPath(trimmed); - if (location.kind === 'remote') { - return new URL(trimmed, location.resolved).toString(); - } - return resolve(dirname(location.resolved), trimmed); -} - -/** - * Best-effort derivation of a semver version from a GitHub source URL that pins - * a specific ref. Lets a marketplace entry omit `version` when the source - * already encodes the release (for example `/releases/tag/v6.0.3`), keeping the - * source URL the single source of truth and avoiding drift between the two. - * - * Only refs shaped like semver (`v6.0.3`, `6.0.3`, `6.0.3-rc.1`) are accepted; - * bare repo URLs, branch names and commit SHAs yield `undefined`, so update - * detection degrades to "unknown" instead of comparing meaningless values. - */ -function deriveVersionFromGithubSource(source: string): string | undefined { - let url: URL; - try { - url = new URL(source); - } catch { - return undefined; - } - if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { - return undefined; - } - // Pathname shape: ///. Recognized tails: - // releases/tag/ - // tree/ - // commit/ - const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); - const ref = - kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; - if (ref === undefined) return undefined; - let decoded: string; - try { - decoded = decodeURIComponent(ref); - } catch { - decoded = ref; - } - const candidate = decoded.replace(/^v/i, ''); - return valid(candidate) !== null ? candidate : undefined; -} - -async function resolveLatestGithubRelease( - source: string, - fetchImpl: typeof fetch, -): Promise { - const repo = parseGithubRepo(source); - if (repo === undefined) return undefined; - try { - const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl); - if (tag === undefined) return undefined; - const candidate = tag.replace(/^v/i, ''); - return valid(candidate) !== null ? candidate : undefined; - } catch { - return undefined; - } -} - -function parseGithubRepo(source: string): { owner: string; repo: string } | undefined { - let url: URL; - try { - url = new URL(source); - } catch { - return undefined; - } - if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; - // Only bare repo URLs (//) qualify — URLs with a ref tail are - // already handled by deriveVersionFromGithubSource. - const segments = url.pathname.split('/').filter(Boolean); - if (segments.length !== 2) return undefined; - const [owner, repo] = segments; - return { owner: owner!, repo: repo! }; -} - -async function fetchLatestReleaseTag( - owner: string, - repo: string, - fetchImpl: typeof fetch, -): Promise { - // Avoid api.github.com: its anonymous quota is shared with the user's browser - // and other tools, and a first-time lookup failing because something else - // burned the budget is unacceptable. The /releases/latest UI route 302s to - // the tag and is not part of the API quota. - const url = `https://github.com/${owner}/${repo}/releases/latest`; - const resp = await fetchImpl(url, { redirect: 'manual' }); - if (resp.status === 404) return undefined; - if (resp.status !== 301 && resp.status !== 302) { - throw new Error( - `Could not look up latest release of ${owner}/${repo}: HTTP ${resp.status} (${url}).`, - ); - } - const location = resp.headers.get('location'); - if (location === null) return undefined; - const match = /\/releases\/tag\/([^/?#]+)/.exec(location); - const tag = match?.[1]; - if (tag === undefined) return undefined; - try { - return decodeURIComponent(tag); - } catch { - return tag; - } -} - -function resolveLocalPath(input: string, workDir: string): string { - if (input === '~') return homedir(); - if (input.startsWith('~/')) return join(homedir(), input.slice(2)); - return isAbsolute(input) ? input : resolve(workDir, input); -} - -function requiredString(value: Record, field: string, index: number): string { - const result = stringField(value, field); - if (result === undefined) { - throw new Error(`Plugin marketplace entry ${index + 1} must define "${field}".`); - } - return result; -} - -function stringField(value: Record, field: string): string | undefined { - const raw = value[field]; - if (typeof raw !== 'string') return undefined; - const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function stringArrayField( - value: Record, - field: string, -): readonly string[] | undefined { - const raw = value[field]; - if (!Array.isArray(raw)) return undefined; - const out = raw - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter((item) => item.length > 0); - return out.length > 0 ? out : undefined; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function formatParseError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index bfa1af3cee..3a65a6b35f 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -77,6 +77,7 @@ "pathe": "^2.0.3", "picomatch": "^4.0.4", "retry": "0.13.1", + "semver": "^7.7.4", "smol-toml": "^1.6.1", "socks": "^2.8.9", "tar": "^7.5.13", @@ -90,6 +91,7 @@ "@types/js-yaml": "^4.0.9", "@types/picomatch": "^4.0.3", "@types/retry": "0.12.0", + "@types/semver": "^7.7.0", "@types/sinon": "^21.0.1", "@types/tar": "^7.0.87", "@types/yauzl": "^2.10.3", diff --git a/packages/agent-core-v2/src/app/capability/capability.ts b/packages/agent-core-v2/src/app/capability/capability.ts index 15056fe0ae..68ce444ac1 100644 --- a/packages/agent-core-v2/src/app/capability/capability.ts +++ b/packages/agent-core-v2/src/app/capability/capability.ts @@ -4,16 +4,24 @@ * Manages the built-in product capabilities (`kimi-cu`, `kimi-webbridge`): * layered readiness detection and idempotent install orchestration. Entries * are hardcoded in a closed registry — install sources are fixed official - * CDN URLs, never client-supplied. + * CDN URLs, never client-supplied. Install progress transitions are published + * through `onDidChangeInstall` (start / step / settle / error). + * `describeCapabilities` answers the static registry without running any + * detection probes. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; -import type { CapabilityStatus } from './types'; +import type { CapabilityDescriptor, CapabilityInstallChange, CapabilityStatus } from './types'; export interface ICapabilityService { readonly _serviceBrand: undefined; + readonly onDidChangeInstall: Event; + + describeCapabilities(): readonly CapabilityDescriptor[]; + listCapabilities(): Promise; getCapability(id: string): Promise; diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts index 8c42563ac4..e58343e87e 100644 --- a/packages/agent-core-v2/src/app/capability/capabilityService.ts +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -2,17 +2,20 @@ * `capability` domain (L3) — `ICapabilityService` implementation. * * Holds the closed registry of built-in capability entries and serializes - * install runs per entry. Install progress lives in memory only and is - * polled by clients; a failed attempt leaves its error in the progress state - * until the next attempt starts and logs the failure through `log`. Listing - * degrades a single entry's failing detection to a failed step on that entry - * instead of rejecting the whole list. Bound at App scope. + * install runs per entry. Install progress lives in memory only; clients poll + * it or subscribe to `onDidChangeInstall` (fired on every transition), and a + * failed attempt leaves its error in the progress state until the next + * attempt starts and logs the failure through `log`. Listing degrades a + * single entry's failing detection to a failed step on that entry instead of + * rejecting the whole list. Bound at App scope. */ import { homedir } from 'node:os'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { Error2 } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -26,6 +29,8 @@ import { createKimiWebbridgeEntry } from './entries/kimiWebbridge'; import type { CapabilityEntry, CapabilityId, + CapabilityDescriptor, + CapabilityInstallChange, CapabilityInstallProgress, CapabilityReadiness, CapabilityStatus, @@ -33,13 +38,24 @@ import type { const IDLE_PROGRESS: CapabilityInstallProgress = { running: false }; -export class CapabilityService implements ICapabilityService { +export class CapabilityService extends Disposable implements ICapabilityService { declare readonly _serviceBrand: undefined; + private readonly onDidChangeInstallEmitter = this._register( + new Emitter(), + ); + readonly onDidChangeInstall: Event = + this.onDidChangeInstallEmitter.event; + private readonly entries: ReadonlyMap; private readonly installProgress = new Map(); private readonly runningInstalls = new Set(); + private setInstallProgress(id: CapabilityId, progress: CapabilityInstallProgress): void { + this.installProgress.set(id, progress); + this.onDidChangeInstallEmitter.fire({ id, install: progress }); + } + constructor( @IBootstrapService bootstrap: IBootstrapService, @IPluginService plugins: IPluginService, @@ -47,6 +63,7 @@ export class CapabilityService implements ICapabilityService { @ILogService private readonly log: ILogService, entriesOverride?: readonly CapabilityEntry[], ) { + super(); if (entriesOverride !== undefined) { this.entries = new Map(entriesOverride.map((entry) => [entry.id, entry])); } else { @@ -65,6 +82,16 @@ export class CapabilityService implements ICapabilityService { } } + describeCapabilities(): readonly CapabilityDescriptor[] { + return [...this.entries.values()].map((entry) => ({ + id: entry.id, + pluginId: entry.pluginId, + displayName: entry.displayName, + description: entry.description, + supported: entry.supported, + })); + } + listCapabilities(): Promise { return Promise.all([...this.entries.values()].map((entry) => this.statusOfSafe(entry))); } @@ -90,16 +117,16 @@ export class CapabilityService implements ICapabilityService { } this.runningInstalls.add(entry.id); - this.installProgress.set(entry.id, { running: true }); + this.setInstallProgress(entry.id, { running: true }); void (async () => { try { - await entry.install((step, percent) => { - this.installProgress.set( + const note = await entry.install((step, percent) => { + this.setInstallProgress( entry.id, percent === undefined ? { running: true, step } : { running: true, step, percent }, ); }); - this.installProgress.set(entry.id, { running: false }); + this.setInstallProgress(entry.id, { running: false, note }); } catch (error) { const step = this.installProgress.get(entry.id)?.step; this.log.warn('capability install failed', { @@ -107,7 +134,7 @@ export class CapabilityService implements ICapabilityService { step, error, }); - this.installProgress.set(entry.id, { + this.setInstallProgress(entry.id, { running: false, error: error instanceof Error ? error.message : String(error), }); diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts index af1c418ea9..0ff46330da 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts @@ -433,7 +433,7 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { } } - async function install(report: CapabilityInstallReporter): Promise { + async function install(report: CapabilityInstallReporter): Promise { if (!supported) { throw new Error(`kimi-cu is only supported on macOS (current: ${ctx.platform})`); } @@ -514,6 +514,7 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { { timeout: PERMISSIONS_TIMEOUT_MS }, ).catch(() => undefined); } + return undefined; } return { @@ -630,7 +631,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry }; } - async function install(report: CapabilityInstallReporter): Promise { + async function install(report: CapabilityInstallReporter): Promise { if (!supported) { throw new Error( `kimi-cu is only supported on macOS or Windows x64 (current: ${ctx.platform}/${ctx.arch})`, @@ -710,6 +711,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry ); } } + return undefined; } return { diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts index 5405a2deea..8016e5d365 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts @@ -206,7 +206,7 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit throw new Error(`WebBridge daemon did not come up on ${baseUrl} — check ~/.kimi-webbridge/logs`); } - async function install(report: CapabilityInstallReporter): Promise { + async function install(report: CapabilityInstallReporter): Promise { const asset = binaryAssetName(ctx.platform, ctx.arch); if (asset === undefined) { throw new Error(`kimi-webbridge is not supported on ${ctx.platform}/${ctx.arch}`); @@ -251,6 +251,9 @@ export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): Capabilit `Could not back up the standalone kimi-webbridge skill: ${error instanceof Error ? error.message : String(error)}`; } } + return standaloneSkillMigrationPending && standaloneSkillMigrationError === undefined + ? 'user-skill-migrated' + : undefined; } async function installBinary( diff --git a/packages/agent-core-v2/src/app/capability/types.ts b/packages/agent-core-v2/src/app/capability/types.ts index 7497a20636..ae2dfdee08 100644 --- a/packages/agent-core-v2/src/app/capability/types.ts +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -27,6 +27,7 @@ export interface CapabilityInstallProgress { readonly step?: string; readonly percent?: number; readonly error?: string; + readonly note?: string; } export interface CapabilityDetectResult { @@ -49,6 +50,19 @@ export interface CapabilityStatus { export type CapabilityInstallReporter = (step: string, percent?: number) => void; +export interface CapabilityDescriptor { + readonly id: CapabilityId; + readonly pluginId?: string; + readonly displayName: string; + readonly description: string; + readonly supported: boolean; +} + +export interface CapabilityInstallChange { + readonly id: CapabilityId; + readonly install: CapabilityInstallProgress; +} + export interface CapabilityEntry { readonly id: CapabilityId; readonly pluginId?: string; @@ -56,5 +70,5 @@ export interface CapabilityEntry { readonly description: string; readonly supported: boolean; detect(): Promise; - install(report: CapabilityInstallReporter): Promise; + install(report: CapabilityInstallReporter): Promise; } diff --git a/packages/agent-core-v2/src/app/plugin/marketplace.ts b/packages/agent-core-v2/src/app/plugin/marketplace.ts new file mode 100644 index 0000000000..4a74102f93 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/marketplace.ts @@ -0,0 +1,394 @@ +/** + * `plugin` domain — plugin marketplace catalog client and parser. + * + * Loads and normalizes the plugin marketplace catalog (`marketplace.json`) + * for every host (CLI panel, kap-server REST route). The catalog format is a + * public, hand-writable contract, so parsing is deliberately lenient: legacy + * field aliases (`url`/`downloadUrl`, `name`/`shortDescription`/`websiteURL`) + * are honored, blank strings read as missing, `keywords` keeps only non-blank + * strings, and `type`/`tier` validate against the accepted vocabulary + * (`plugin` plus legacy `managed`/`guide`; `official`/`curated`). Entry + * sources may be http(s), GitHub repo/ref URLs, `file://`, absolute paths, + * `~`-relative, or catalog-relative (`./official/*.zip`) — all resolve to a + * directly installable form here. Entries without a `version` get one from a + * GitHub ref tail (`releases/tag/`, `tree/`, `commit/` — + * semver-shaped refs only), or from the bare repo's latest release via the + * `/releases/latest` redirect (a UI route, deliberately never the + * rate-limited api.github.com). `computeUpdateStatus` reports an update only + * on strict semver latest > installed, and never borrows the catalog version + * for an unknown local one. `withBuiltInEntries` masks same-id catalog rows + * with client-injected built-in capability entries (taking only their + * version), so what those ids mean stays bound to the client release; the + * `builtIn` flag is set only by that path, never from catalog data. + * `readPluginMarketplace` returns the location actually read, because entry + * sources resolve against it — including the host-supplied source-checkout + * fallback, which hosts pass only when the catalog location is the built-in + * default (an explicitly configured catalog fails hard). No DI collaborators + * — pure functions over `fetch`/`fs`. + */ + +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { gt, valid } from 'semver'; + +export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = + 'https://code.kimi.com/kimi-code/plugins/marketplace.json'; +export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; + +export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const; + +export type PluginMarketplaceTier = (typeof PLUGIN_MARKETPLACE_TIERS)[number]; + +export interface PluginMarketplaceEntry { + readonly id: string; + readonly displayName: string; + readonly source: string; + readonly tier?: PluginMarketplaceTier; + readonly version?: string; + readonly description?: string; + readonly homepage?: string; + readonly keywords?: readonly string[]; + readonly builtIn?: boolean; +} + +export interface PluginMarketplace { + readonly source: string; + readonly version?: string; + readonly plugins: readonly PluginMarketplaceEntry[]; +} + +export type MarketplaceUpdateStatus = + | { readonly kind: 'not-installed' } + | { readonly kind: 'up-to-date'; readonly version?: string } + | { readonly kind: 'update'; readonly local: string; readonly latest: string }; + +export interface MarketplaceLocation { + readonly raw: string; + readonly kind: 'remote' | 'local'; + readonly resolved: string; +} + +export interface ReadPluginMarketplaceOptions { + readonly source: string; + readonly workDir: string; + readonly fetchImpl?: typeof fetch; + readonly sourceCheckoutLocation?: () => Promise; +} + +export function computeUpdateStatus( + latest: string | undefined, + local: string | undefined, + installed: boolean, +): MarketplaceUpdateStatus { + if (!installed) return { kind: 'not-installed' }; + if ( + latest !== undefined && + local !== undefined && + valid(latest) !== null && + valid(local) !== null && + gt(latest, local) + ) { + return { kind: 'update', local, latest }; + } + return { kind: 'up-to-date', version: local }; +} + +export function resolveMarketplaceLocation(source: string, workDir: string): MarketplaceLocation { + const trimmed = source.trim(); + if (trimmed.length === 0) { + throw new Error(`${KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`); + } + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return { raw: trimmed, kind: 'remote', resolved: trimmed }; + } + if (trimmed.startsWith('file://')) { + const path = fileURLToPath(trimmed); + return { raw: trimmed, kind: 'local', resolved: path }; + } + return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) }; +} + +export async function readPluginMarketplace( + options: ReadPluginMarketplaceOptions, +): Promise<{ raw: string; location: MarketplaceLocation }> { + const location = resolveMarketplaceLocation(options.source, options.workDir); + const fetchImpl = options.fetchImpl ?? fetch; + try { + return { raw: await readMarketplaceText(location, fetchImpl), location }; + } catch (error) { + const fallback = + options.sourceCheckoutLocation !== undefined + ? await options.sourceCheckoutLocation() + : undefined; + if (fallback === undefined) throw error; + return { raw: await readMarketplaceText(fallback, fetchImpl), location: fallback }; + } +} + +export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Plugin marketplace is not valid JSON: ${formatParseError(error)}`, { + cause: error, + }); + } + + if (!isRecord(parsed)) { + throw new TypeError('Plugin marketplace must be an object.'); + } + const rawPlugins = parsed['plugins']; + if (!Array.isArray(rawPlugins)) { + throw new TypeError('Plugin marketplace must contain a "plugins" array.'); + } + + return { + source: location.resolved, + version: stringField(parsed, 'version'), + plugins: rawPlugins.map((entry, index) => parseMarketplaceEntry(entry, index, location)), + }; +} + +export function withBuiltInEntries( + marketplace: PluginMarketplace, + builtIns: readonly PluginMarketplaceEntry[], +): PluginMarketplace { + const builtInIds = new Set(builtIns.map((entry) => entry.id)); + const catalogById = new Map(marketplace.plugins.map((entry) => [entry.id, entry])); + const catalog = marketplace.plugins.filter((entry) => !builtInIds.has(entry.id)); + const enrichedBuiltIns = builtIns.map((entry) => { + const version = catalogById.get(entry.id)?.version; + return version === undefined ? entry : { ...entry, version }; + }); + return { ...marketplace, plugins: [...catalog, ...enrichedBuiltIns] }; +} + +export async function withLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch, +): Promise { + const plugins = await Promise.all( + marketplace.plugins.map(async (entry) => { + if (entry.version !== undefined) return entry; + const latest = await resolveLatestGithubRelease(entry.source, fetchImpl); + return latest === undefined ? entry : { ...entry, version: latest }; + }), + ); + return { ...marketplace, plugins }; +} + +async function readMarketplaceText( + location: MarketplaceLocation, + fetchImpl: typeof fetch, +): Promise { + if (location.kind === 'local') { + return readFile(location.resolved, 'utf8'); + } + const response = await fetchImpl(location.resolved); + if (!response.ok) { + throw new Error(`Plugin marketplace returned HTTP ${response.status}`); + } + return response.text(); +} + +function parseMarketplaceEntry( + value: unknown, + index: number, + location: MarketplaceLocation, +): PluginMarketplaceEntry { + if (!isRecord(value)) { + throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`); + } + const id = requiredString(value, 'id', index); + validateMarketplaceEntryType(value, id); + const source = stringField(value, 'source') ?? + stringField(value, 'url') ?? + stringField(value, 'downloadUrl'); + if (source === undefined) { + throw new Error(`Plugin marketplace entry ${id} must define "source".`); + } + const resolvedSource = resolveEntrySource(source, location); + return { + id, + displayName: stringField(value, 'displayName') ?? stringField(value, 'name') ?? id, + source: resolvedSource, + tier: parseMarketplaceTier(value, id), + version: stringField(value, 'version') ?? deriveVersionFromGithubSource(resolvedSource), + description: stringField(value, 'description') ?? stringField(value, 'shortDescription'), + homepage: stringField(value, 'homepage') ?? stringField(value, 'websiteURL'), + keywords: stringArrayField(value, 'keywords'), + }; +} + +function validateMarketplaceEntryType(value: Record, id: string): void { + const raw = value['type']; + if (raw === undefined) return; + if (typeof raw !== 'string') { + throw new TypeError(`Plugin marketplace entry ${id} "type" must be a string.`); + } + const type = raw.trim(); + if (type === 'plugin' || type === 'managed' || type === 'guide') return; + throw new Error( + `Plugin marketplace entry ${id} "type" must be "plugin". Legacy aliases "managed" and "guide" are also accepted.`, + ); +} + +function parseMarketplaceTier( + value: Record, + id: string, +): PluginMarketplaceTier | undefined { + const raw = value['tier']; + if (raw === undefined) return undefined; + if (typeof raw !== 'string') { + throw new TypeError(`Plugin marketplace entry ${id} "tier" must be a string.`); + } + const tier = raw.trim(); + if (tier.length === 0) return undefined; + if ((PLUGIN_MARKETPLACE_TIERS as readonly string[]).includes(tier)) { + return tier as PluginMarketplaceTier; + } + throw new Error( + `Plugin marketplace entry ${id} "tier" must be one of: ${PLUGIN_MARKETPLACE_TIERS.join(', ')}.`, + ); +} + +function resolveEntrySource(source: string, location: MarketplaceLocation): string { + const trimmed = source.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + if (trimmed.startsWith('file://')) return fileURLToPath(trimmed); + if (trimmed === '~' || trimmed.startsWith('~/')) { + return resolveLocalPath(trimmed, ''); + } + if (isAbsolute(trimmed)) return trimmed; + if (location.kind === 'remote') { + return new URL(trimmed, location.resolved).toString(); + } + return resolve(dirname(location.resolved), trimmed); +} + +function deriveVersionFromGithubSource(source: string): string | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') { + return undefined; + } + const [, , kind, a, b] = url.pathname.split('/').filter(Boolean); + const ref = + kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined; + if (ref === undefined) return undefined; + let decoded: string; + try { + decoded = decodeURIComponent(ref); + } catch { + decoded = ref; + } + const candidate = decoded.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; +} + +async function resolveLatestGithubRelease( + source: string, + fetchImpl: typeof fetch, +): Promise { + const repo = parseGithubRepo(source); + if (repo === undefined) return undefined; + try { + const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl); + if (tag === undefined) return undefined; + const candidate = tag.replace(/^v/i, ''); + return valid(candidate) !== null ? candidate : undefined; + } catch { + return undefined; + } +} + +function parseGithubRepo(source: string): { owner: string; repo: string } | undefined { + let url: URL; + try { + url = new URL(source); + } catch { + return undefined; + } + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return undefined; + const [owner, repo] = segments; + return { owner: owner!, repo: repo! }; +} + +async function fetchLatestReleaseTag( + owner: string, + repo: string, + fetchImpl: typeof fetch, +): Promise { + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetchImpl(url, { redirect: 'manual' }); + if (resp.status === 404) return undefined; + if (resp.status !== 301 && resp.status !== 302) { + throw new Error( + `Could not look up latest release of ${owner}/${repo}: HTTP ${resp.status} (${url}).`, + ); + } + const location = resp.headers.get('location'); + if (location === null) return undefined; + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + const tag = match?.[1]; + if (tag === undefined) return undefined; + try { + return decodeURIComponent(tag); + } catch { + return tag; + } +} + +function resolveLocalPath(input: string, workDir: string): string { + if (input === '~') return homedir(); + if (input.startsWith('~/')) return join(homedir(), input.slice(2)); + return isAbsolute(input) ? input : resolve(workDir, input); +} + +function requiredString(value: Record, field: string, index: number): string { + const result = stringField(value, field); + if (result === undefined) { + throw new Error(`Plugin marketplace entry ${index + 1} must define "${field}".`); + } + return result; +} + +function stringField(value: Record, field: string): string | undefined { + const raw = value[field]; + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function stringArrayField( + value: Record, + field: string, +): readonly string[] | undefined { + const raw = value[field]; + if (!Array.isArray(raw)) return undefined; + const out = raw + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return out.length > 0 ? out : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function formatParseError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 0628c3bda0..0e94653a07 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -213,6 +213,7 @@ export * from '#/app/plugin/source'; export * from '#/app/plugin/github-resolver'; export * from '#/app/plugin/archive'; export * from '#/app/plugin/manager'; +export * from '#/app/plugin/marketplace'; export * from '#/app/plugin/plugin'; export * from '#/app/plugin/pluginService'; export * from '#/app/capability/capability'; diff --git a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts index 4deb646111..8d3d259277 100644 --- a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts +++ b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts @@ -23,7 +23,7 @@ function fakeEntry(overrides: { pluginId?: string; supported?: boolean; detect?: CapabilityDetectResult; - install?: (report: CapabilityInstallReporter) => Promise; + install?: (report: CapabilityInstallReporter) => Promise; }): CapabilityEntry { return { id: overrides.id, @@ -35,7 +35,7 @@ function fakeEntry(overrides: { Promise.resolve( overrides.detect ?? { steps: [{ id: 'plugin', state: 'ok' }] }, ), - install: overrides.install ?? (() => Promise.resolve()), + install: overrides.install ?? (() => Promise.resolve(undefined)), }; } @@ -92,7 +92,7 @@ describe('CapabilityService', () => { description: 'fake', supported: true, detect: () => Promise.reject(new Error('probe timed out')), - install: () => Promise.resolve(), + install: () => Promise.resolve(undefined), }; const service = fakeService([ broken, @@ -176,9 +176,9 @@ describe('CapabilityService', () => { id: 'kimi-cu', install: (report) => { report('download', 42); - return new Promise((resolve) => { + return new Promise((resolve) => { release = () => { - resolve(); + resolve(undefined); }; }); }, @@ -213,6 +213,59 @@ describe('CapabilityService', () => { expect.unreachable('install never settled'); }); + it('describes the registry without running detectors', async () => { + const service = fakeService([ + fakeEntry({ id: 'kimi-cu', supported: true }), + fakeEntry({ id: 'kimi-webbridge', supported: false }), + ]); + const descriptors = service.describeCapabilities(); + expect(descriptors.map((d) => d.id)).toEqual(['kimi-cu', 'kimi-webbridge']); + expect(descriptors.find((d) => d.id === 'kimi-webbridge')?.supported).toBe(false); + }); + + it('emits onDidChangeInstall on every progress transition', async () => { + const service = fakeService([ + fakeEntry({ + id: 'kimi-cu', + install: (report) => { + report('download', 42); + return Promise.resolve(undefined); + }, + }), + ]); + const seen: Array<{ id: string; install: { running: boolean; step?: string } }> = []; + service.onDidChangeInstall((change) => { + seen.push({ id: change.id, install: change.install }); + }); + + await service.installCapability('kimi-cu'); + for (let i = 0; i < 50; i += 1) { + const status = await service.getCapability('kimi-cu'); + if (!status.install.running) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(seen[0]).toEqual({ id: 'kimi-cu', install: { running: true } }); + expect(seen).toContainEqual({ id: 'kimi-cu', install: { running: true, step: 'download', percent: 42 } }); + expect(seen.at(-1)).toEqual({ id: 'kimi-cu', install: { running: false } }); + }); + + it('surfaces an install note from the entry through progress', async () => { + const service = fakeService([ + fakeEntry({ + id: 'kimi-cu', + install: () => Promise.resolve('user-skill-migrated'), + }), + ]); + await service.installCapability('kimi-cu'); + for (let i = 0; i < 50; i += 1) { + const status = await service.getCapability('kimi-cu'); + if (!status.install.running) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect((await service.getCapability('kimi-cu')).install.note).toBe('user-skill-migrated'); + }); + it('surfaces install errors through progress until the next attempt', async () => { let attempts = 0; const service = fakeService([ @@ -222,7 +275,7 @@ describe('CapabilityService', () => { attempts += 1; return attempts === 1 ? Promise.reject(new Error('boom')) - : Promise.resolve(); + : Promise.resolve(undefined); }, }), ]); diff --git a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts index 6ce01a74f4..1f7211ec30 100644 --- a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts +++ b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts @@ -239,11 +239,12 @@ describe('kimi-webbridge entry', () => { optional: true, }); const reports: string[] = []; - await entry.install((step) => reports.push(step)); + const note = await entry.install((step) => reports.push(step)); expect(plugins.installs).toEqual([ 'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip', ]); + expect(note).toBe('user-skill-migrated'); expect(reports).toContain('standalone-skill-migration'); await expect(access(path.join(kimiHome, 'skills', 'kimi-webbridge'))).rejects.toThrow(); await expect(access(path.join(userHome, '.agents', 'skills', 'kimi-webbridge'))).rejects.toThrow(); @@ -302,8 +303,9 @@ describe('kimi-webbridge entry', () => { makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }), ); - await entry.install(() => {}); + const note = await entry.install(() => {}); expect(host.calls).toEqual([]); + expect(note).toBeUndefined(); }); it('reinstalls the latest binary and plugin for a ready capability', async () => { diff --git a/packages/kap-server/package.json b/packages/kap-server/package.json index bf6ff7fab7..cb9035015f 100644 --- a/packages/kap-server/package.json +++ b/packages/kap-server/package.json @@ -34,6 +34,7 @@ "bcryptjs": "^2.4.3", "fastify": "^5.1.0", "pino": "^9.5.0", + "semver": "^7.7.4", "smol-toml": "^1.6.1", "ulid": "^3.0.1", "ws": "^8.18.0", @@ -41,6 +42,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/semver": "^7.7.0", "@types/ws": "^8.18.0", "tsx": "^4.21.0" } diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index 2fadbc3dd3..c26accddbc 100644 --- a/packages/kap-server/src/protocol/error-codes.ts +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -72,6 +72,10 @@ export const ErrorCode = { TOOL_CALL_NOT_FOUND: 40416, /** 目录(models.dev catalog)中不存在该条目 */ CATALOG_ENTRY_NOT_FOUND: 40417, + /** capability_id 不存在 */ + CAPABILITY_NOT_FOUND: 40418, + /** plugin_id 不存在 */ + PLUGIN_NOT_FOUND: 40419, /** session 有正在进行的 prompt,拒绝新请求 */ SESSION_BUSY: 40901, @@ -120,6 +124,10 @@ export const ErrorCode = { PAGE_TOKEN_MISMATCH: 40922, /** 会话标题生成不可用(flag 未开 / 无 managed OAuth 登录 / 还没有 prompt / 后端失败) */ SESSION_TITLE_UNAVAILABLE: 40923, + /** capability 正在安装中,拒绝并发安装 */ + CAPABILITY_INSTALL_IN_PROGRESS: 40924, + /** 当前平台/架构不支持该 capability */ + CAPABILITY_UNSUPPORTED: 40925, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 35ddf7efef..e5d5bb429b 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -628,6 +628,22 @@ export const configWarningEventSchema = z.object({ ), }); +export const pluginChangedEventSchema = z.object({ + type: z.literal('event.plugin.changed'), +}); + +export const capabilityChangedEventSchema = z.object({ + type: z.literal('event.capability.changed'), + capability_id: z.string(), + install: z.object({ + running: z.boolean(), + step: z.string().optional(), + percent: z.number().optional(), + error: z.string().optional(), + note: z.string().optional(), + }), +}); + export const diUnitChangedEventSchema = z.object({ type: z.literal('event.di.unit_changed'), scope: z.string().min(1), @@ -965,6 +981,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [ sessionWorkChangedEventSchema, sessionStatusChangedEventSchema, diUnitChangedEventSchema, + pluginChangedEventSchema, + capabilityChangedEventSchema, goalUpdatedEventSchema, skillActivatedEventSchema, pluginCommandActivatedEventSchema, diff --git a/packages/kap-server/src/protocol/rest-capability.ts b/packages/kap-server/src/protocol/rest-capability.ts new file mode 100644 index 0000000000..a68d53f3ea --- /dev/null +++ b/packages/kap-server/src/protocol/rest-capability.ts @@ -0,0 +1,47 @@ +/** + * GET /v1/capabilities + * GET /v1/capabilities/{capability_id} + * POST /v1/capabilities/{capability_id}:install + */ + +import { z } from 'zod'; + +export const capabilityStepSchema = z.object({ + id: z.string(), + state: z.enum(['ok', 'missing', 'failed']), + detail: z.string().optional(), + optional: z.boolean().optional(), +}); +export type CapabilityStepWire = z.infer; + +export const capabilityInstallProgressSchema = z.object({ + running: z.boolean(), + step: z.string().optional(), + percent: z.number().min(0).max(100).optional(), + error: z.string().optional(), + note: z.string().optional(), +}); +export type CapabilityInstallProgressWire = z.infer; + +export const capabilityStatusSchema = z.object({ + id: z.string(), + pluginId: z.string().optional(), + displayName: z.string(), + description: z.string(), + supported: z.boolean(), + state: z.enum(['not_installed', 'partial', 'ready', 'unsupported']), + version: z.string().optional(), + steps: z.array(capabilityStepSchema), + install: capabilityInstallProgressSchema, +}); +export type CapabilityStatusWire = z.infer; + +export const listCapabilitiesResponseSchema = z.object({ + capabilities: z.array(capabilityStatusSchema), +}); +export type ListCapabilitiesResponse = z.infer; + +export const capabilityIdParamSchema = z.object({ + capability_id: z.string().min(1), +}); +export type CapabilityIdParam = z.infer; diff --git a/packages/kap-server/src/protocol/rest-plugin.ts b/packages/kap-server/src/protocol/rest-plugin.ts new file mode 100644 index 0000000000..fa3003cfeb --- /dev/null +++ b/packages/kap-server/src/protocol/rest-plugin.ts @@ -0,0 +1,86 @@ +/** + * GET /v1/plugins + * GET /v1/plugins/marketplace + * POST /v1/plugins + * POST /v1/plugins/{plugin_id}:{enable,disable,remove} + */ + +import { z } from 'zod'; + +/** GitHub provenance for github-sourced plugins (domain PluginGithubMetadata). */ +export const pluginGithubMetadataSchema = z.object({ + owner: z.string(), + repo: z.string(), + ref: z.object({ + kind: z.enum(['branch', 'tag', 'sha']), + value: z.string(), + }), + installedSha: z.string().optional(), +}); + +export const pluginSummarySchema = z.object({ + id: z.string(), + displayName: z.string(), + version: z.string().optional(), + enabled: z.boolean(), + state: z.enum(['ok', 'error']), + skillCount: z.number(), + mcpServerCount: z.number(), + enabledMcpServerCount: z.number(), + hookCount: z.number(), + commandCount: z.number(), + hasErrors: z.boolean(), + source: z.enum(['local-path', 'zip-url', 'github']), + originalSource: z.string().optional(), + github: pluginGithubMetadataSchema.optional(), +}); +export type PluginSummaryWire = z.infer; + +export const listPluginsResponseSchema = z.object({ + plugins: z.array(pluginSummarySchema), +}); +export type ListPluginsResponse = z.infer; + +export const installPluginRequestSchema = z.object({ + /** local path, https zip URL, or GitHub repo URL — same semantics as the CLI. */ + source: z.string().min(1), +}); +export type InstallPluginRequest = z.infer; + +export const pluginMarketplaceEntrySchema = z.object({ + id: z.string(), + tier: z.enum(['official', 'curated', 'third-party']), + displayName: z.string(), + description: z.string().optional(), + homepage: z.string().optional(), + keywords: z.array(z.string()).optional(), + /** Catalog-declared version; absent for entries that track a moving source. */ + version: z.string().optional(), + source: z.string(), + /** Present when the plugin is installed locally (detected on demand). */ + installed: z + .object({ + version: z.string().optional(), + enabled: z.boolean(), + }) + .optional(), + /** True only when both versions are valid semver and catalog > installed. */ + updateAvailable: z.boolean().optional(), + /** + * Set when the entry is a built-in capability's wiring plugin — install it + * through `/capabilities/{capabilityId}:install` (binary runtime + wiring); + * a plain plugin install sets up the wiring layer only. + */ + capabilityId: z.string().optional(), +}); +export type PluginMarketplaceEntryWire = z.infer; + +export const pluginMarketplaceResponseSchema = z.object({ + entries: z.array(pluginMarketplaceEntrySchema), +}); +export type PluginMarketplaceResponse = z.infer; + +export const pluginIdParamSchema = z.object({ + tail: z.string().min(1), +}); +export type PluginIdParam = z.infer; diff --git a/packages/kap-server/src/routes/capabilities.ts b/packages/kap-server/src/routes/capabilities.ts new file mode 100644 index 0000000000..124999ab7d --- /dev/null +++ b/packages/kap-server/src/routes/capabilities.ts @@ -0,0 +1,177 @@ +/** + * `/capabilities` REST routes — built-in product capabilities (kimi-cu, + * kimi-webbridge): layered readiness detection + idempotent install. + * + * GET /capabilities data: {capabilities: CapabilityStatus[]} + * GET /capabilities/{capability_id} data: CapabilityStatus + * POST /capabilities/{capability_id}:install data: CapabilityStatus (install running) + * + * The route surface is a thin projection of the App-scope `ICapabilityService` + * (`agent-core-v2/app/capability`): the closed registry lives there, install + * sources are fixed official CDN URLs, and progress is polled through these + * reads (no WS events in v1). + * + * **Action suffix**: `:install` is the only action — the `POST` path uses the + * shared `parseActionSuffix` helper (bare ids are rejected). + * + * **Error mapping**: + * - unknown capability id → envelope `code: 40418 capability.not_found` + * - install on wrong platform → `40923 capability.unsupported` + * - install already running → `40922 capability.install_in_progress` + * - malformed `{tail}` → `40001 validation.failed` + * - other errors → `50001` via the global error handler + */ + +import { CapabilityErrors, ICapabilityService, isError2, type Scope } from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + capabilityIdParamSchema, + capabilityStatusSchema, + listCapabilitiesResponseSchema, +} from '../protocol/rest-capability'; +import { parseActionSuffix } from './action-suffix'; + +interface CapabilitiesRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const capabilityTailParamsSchema = z.object({ + tail: z.string().min(1), +}); + +export function registerCapabilitiesRoutes(app: CapabilitiesRouteHost, core: Scope): void { + // GET /capabilities ----------------------------------------------------- + const listRoute = defineRoute( + { + method: 'GET', + path: '/capabilities', + success: { data: listCapabilitiesResponseSchema }, + errors: {}, + description: 'List built-in capabilities with layered readiness status', + tags: ['capabilities'], + operationId: 'listCapabilities', + }, + async (req, reply) => { + const capabilities = await core.accessor.get(ICapabilityService).listCapabilities(); + reply.send(okEnvelope({ capabilities }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + // GET /capabilities/{capability_id} -------------------------------------- + const getRoute = defineRoute( + { + method: 'GET', + path: '/capabilities/{capability_id}', + params: capabilityIdParamSchema, + success: { data: capabilityStatusSchema }, + errors: { + [ErrorCode.CAPABILITY_NOT_FOUND]: {}, + }, + description: 'Get one capability readiness status', + tags: ['capabilities'], + operationId: 'getCapability', + }, + async (req, reply) => { + try { + const capability = await core.accessor + .get(ICapabilityService) + .getCapability(req.params.capability_id); + reply.send(okEnvelope(capability, req.id)); + } catch (error) { + reply.send(mapCapabilityError(error, req.id)); + } + }, + ); + app.get( + getRoute.path, + getRoute.options, + getRoute.handler as Parameters[2], + ); + + // POST /capabilities/{capability_id}:install ----------------------------- + const installRoute = defineRoute( + { + method: 'POST', + path: '/capabilities/{tail}', + params: capabilityTailParamsSchema, + success: { data: capabilityStatusSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.CAPABILITY_NOT_FOUND]: {}, + [ErrorCode.CAPABILITY_UNSUPPORTED]: {}, + [ErrorCode.CAPABILITY_INSTALL_IN_PROGRESS]: {}, + }, + description: 'Start an idempotent capability install (poll GET for progress)', + tags: ['capabilities'], + operationId: 'installCapability', + }, + async (req, reply) => { + const parsed = parseActionSuffix({ + tail: req.params.tail, + allowedActions: ['install'], + resourceLabel: 'capability', + }); + if (parsed.kind !== 'action') { + const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${req.params.tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + try { + const capability = await core.accessor + .get(ICapabilityService) + .installCapability(parsed.id); + reply.send(okEnvelope(capability, req.id)); + } catch (error) { + reply.send(mapCapabilityError(error, req.id)); + } + }, + ); + app.post( + installRoute.path, + installRoute.options, + installRoute.handler as Parameters[2], + ); +} + +const CAPABILITY_ERROR_MAP: Readonly> = { + [CapabilityErrors.codes.CAPABILITY_NOT_FOUND]: ErrorCode.CAPABILITY_NOT_FOUND, + [CapabilityErrors.codes.CAPABILITY_UNSUPPORTED]: ErrorCode.CAPABILITY_UNSUPPORTED, + [CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS]: ErrorCode.CAPABILITY_INSTALL_IN_PROGRESS, +}; + +function mapCapabilityError(error: unknown, requestId: string) { + const mapped = isError2(error) ? CAPABILITY_ERROR_MAP[error.code] : undefined; + if (mapped !== undefined && isError2(error)) { + return errEnvelope(mapped, error.message, requestId, error.stack); + } + return errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + requestId, + error instanceof Error ? error.stack : undefined, + ); +} diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts new file mode 100644 index 0000000000..63a7d79ea5 --- /dev/null +++ b/packages/kap-server/src/routes/plugins.ts @@ -0,0 +1,411 @@ +/** + * `/plugins` REST routes — plugin management and the marketplace catalog. + * + * GET /plugins data: {plugins: PluginSummary[]} + * GET /plugins/marketplace data: {entries: MarketplaceEntry[]} + * POST /plugins body {source} data: PluginSummary + * POST /plugins/{plugin_id}:enable|:disable|:remove + * + * Thin projection of the App-scope `IPluginService` (install/remove/enable + * are serialized there and fire `onDidReload`, which converges session skill + * catalogs and the capability shelf-install hook). The marketplace catalog is + * read on demand from the configured location (`pluginMarketplaceUrl` server + * option, env `KIMI_CODE_PLUGIN_MARKETPLACE_URL`, default the production + * catalog) through the shared `app/plugin/marketplace` client — catalog + * reading, the lenient entry normalization, source resolution, and version + * derivation all live there (one implementation, consumed by the CLI too). + * When the location is the built-in default, a failed read falls back to the + * source checkout's own catalog (offline dev); an explicitly configured + * catalog fails hard. The route merges the entries with the live install + * state — install status is always detected from the local records, never + * from the catalog — and marks capability wiring rows with `capabilityId` so + * clients route them through `/capabilities/{id}:install`. + * + * **Action suffix**: `:enable` / `:disable` / `:remove` via `parseActionSuffix` + * (bare ids rejected). + * + * **Error mapping**: + * - unknown plugin id → `40419 plugin.not_found` (from the domain code) + * - bad install source / path → `40001 validation.failed` / `40409 fs.path_not_found` + * - malformed `{tail}` / body → `40001 validation.failed` + * - catalog unreachable/invalid → `50001` with a plain-language message + * - other errors → `50001` via the global error handler + */ + +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + computeUpdateStatus, + ErrorCodes as DomainErrorCodes, + ICapabilityService, + IPluginService, + PluginErrors, + isError2, + parsePluginMarketplace, + readPluginMarketplace, + withLatestVersions, + type MarketplaceLocation, + type PluginMarketplace, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + installPluginRequestSchema, + listPluginsResponseSchema, + pluginMarketplaceResponseSchema, + pluginIdParamSchema, + pluginSummarySchema, + type PluginMarketplaceEntryWire, +} from '../protocol/rest-plugin'; +import { parseActionSuffix } from './action-suffix'; + +interface PluginsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const PLUGIN_ACTIONS = ['enable', 'disable', 'remove'] as const; + +/** + * Capability wiring plugin id → capability id, applied only to the DEFAULT + * catalog (a custom catalog may legitimately carry a same-id fork — the CLI + * likewise injects built-in rows only for the default catalog). The closed + * id set belongs to the client/engine contract (mirrored by the klient + * schema; the CLI names it inline). Marking these rows lets clients route + * them through `/capabilities/{id}:install` — a plain `POST /plugins` + * installs only the wiring layer, never the binary runtime. + */ +const CAPABILITY_ROW_IDS: Readonly< + Record +> = { + // kimi-cu's wiring plugin id is platform-specific ('kimi-cu-win' on + // Windows x64); the catalog row joins install state through either id. + 'kimi-cu': { capabilityId: 'kimi-cu', wiringPluginIds: ['kimi-cu', 'kimi-cu-win'] }, + 'kimi-cu-win': { capabilityId: 'kimi-cu', wiringPluginIds: ['kimi-cu', 'kimi-cu-win'] }, + 'kimi-webbridge': { capabilityId: 'kimi-webbridge', wiringPluginIds: ['kimi-webbridge'] }, +}; + +/** + * Wiring plugin ids in this platform's preference order — the canonical one + * first ('kimi-cu-win' on Windows x64), so a stale same-id record never + * shadows the capability's actual wiring plugin. + */ +function orderedWiringPluginIds(ids: readonly string[]): readonly string[] { + if (process.platform === 'win32' && process.arch === 'x64' && ids.includes('kimi-cu-win')) { + return ['kimi-cu-win', ...ids.filter((id) => id !== 'kimi-cu-win')]; + } + return ids; +} + +const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; + +function fetchWithTimeout(...args: Parameters): Promise { + const [input, init] = args; + return fetch(input, { ...init, signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) }); +} + +/** + * The repo checkout's own catalog — the fallback when the default location + * is unreachable (offline / source-checkout dev). Absent in bundled + * installs, where the fallback simply never fires. + */ +async function getSourceCheckoutLocation(): Promise { + const candidate = resolve(import.meta.dirname, '../../../../plugins/marketplace.json'); + const info = await stat(candidate).catch(() => undefined); + if (info?.isFile() !== true) return undefined; + return { raw: candidate, kind: 'local', resolved: candidate }; +} + +export interface PluginsRouteOptions { + /** Resolved catalog URL (server option / env already applied by start.ts). */ + readonly marketplaceUrl: string; + /** + * True when the catalog location is the built-in default (neither the + * server option nor the env var set) — only then does a failed remote read + * fall back to the source-checkout catalog and get capability markers + * (an explicitly configured catalog fails hard and stays unmarked). + */ + readonly marketplaceIsDefault?: boolean; + readonly fetchImpl?: typeof fetch; +} + +export function registerPluginsRoutes( + app: PluginsRouteHost, + core: Scope, + opts: PluginsRouteOptions, +): void { + // GET /plugins/marketplace — registered BEFORE /plugins/{tail} so the + // literal segment wins over the param route. + const marketplaceRoute = defineRoute( + { + method: 'GET', + path: '/plugins/marketplace', + success: { data: pluginMarketplaceResponseSchema }, + errors: {}, + description: 'List the plugin marketplace catalog merged with live install state', + tags: ['plugins'], + operationId: 'listPluginMarketplace', + }, + async (req, reply) => { + const fetchImpl = opts.fetchImpl ?? fetchWithTimeout; + let read: { raw: string; location: MarketplaceLocation }; + try { + read = await readPluginMarketplace({ + source: opts.marketplaceUrl, + workDir: process.cwd(), + fetchImpl, + sourceCheckoutLocation: + opts.marketplaceIsDefault === true ? getSourceCheckoutLocation : undefined, + }); + } catch (error) { + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `Plugin marketplace is unreachable: ${error instanceof Error ? error.message : String(error)}`, + req.id, + ), + ); + return; + } + let marketplace: PluginMarketplace; + try { + marketplace = parsePluginMarketplace(read.raw, read.location); + } catch (error) { + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `Plugin marketplace returned an invalid catalog: ${error instanceof Error ? error.message : String(error)}`, + req.id, + ), + ); + return; + } + // The default catalog is completed with the built-in capability rows + // it does not carry itself (e.g. kimi-cu) — the CLI injects the same + // rows client-side. Injected before the projection below so they get + // the same install-state join and capabilityId marker; the + // `capability:` source is a sentinel, never a plain-plugin source. + if (opts.marketplaceIsDefault === true) { + const presentIds = new Set(marketplace.plugins.map((entry) => entry.id)); + const missing = core.accessor + .get(ICapabilityService) + .describeCapabilities() + .filter((descriptor) => descriptor.supported && !presentIds.has(descriptor.id)) + .map((descriptor) => ({ + id: descriptor.id, + tier: 'official' as const, + displayName: descriptor.displayName, + description: descriptor.description, + source: `capability:${descriptor.id}`, + })); + if (missing.length > 0) { + marketplace = { ...marketplace, plugins: [...marketplace.plugins, ...missing] }; + } + } + marketplace = await withLatestVersions(marketplace, fetchImpl); + const installed = await core.accessor.get(IPluginService).listPlugins(); + const byId = new Map(installed.map((p) => [p.id, p])); + // Capability rows unsupported on this host are hidden entirely (the CLI + // does the same for its built-in rows) — never marked, never offered. + const supportedCapabilityIds = new Set( + core.accessor + .get(ICapabilityService) + .describeCapabilities() + .filter((descriptor) => descriptor.supported) + .map((descriptor) => descriptor.id), + ); + const entries: PluginMarketplaceEntryWire[] = []; + for (const entry of marketplace.plugins) { + const capabilityRow = + opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined; + if ( + capabilityRow !== undefined && + !supportedCapabilityIds.has(capabilityRow.capabilityId) + ) { + continue; + } + + // Capability rows join through the wiring plugin ids (platform order) + // BEFORE the bare catalog id — a stale same-id record must not win. + const record = + capabilityRow !== undefined + ? (orderedWiringPluginIds(capabilityRow.wiringPluginIds) + .map((id) => byId.get(id)) + .find((candidate) => candidate !== undefined) ?? byId.get(entry.id)) + : byId.get(entry.id); + const installedInfo = + record === undefined + ? undefined + : { enabled: record.enabled, version: record.version }; + const updateAvailable = + computeUpdateStatus(entry.version, record?.version, record !== undefined).kind === + 'update'; + entries.push({ + id: entry.id, + tier: entry.tier ?? 'third-party', + displayName: entry.displayName, + description: entry.description, + homepage: entry.homepage, + keywords: entry.keywords === undefined ? undefined : [...entry.keywords], + version: entry.version, + source: entry.source, + installed: installedInfo, + updateAvailable: updateAvailable ? true : undefined, + capabilityId: capabilityRow?.capabilityId, + }); + } + reply.send(okEnvelope({ entries }, req.id)); + }, + ); + app.get( + marketplaceRoute.path, + marketplaceRoute.options, + marketplaceRoute.handler as Parameters[2], + ); + + // GET /plugins ------------------------------------------------------------ + const listRoute = defineRoute( + { + method: 'GET', + path: '/plugins', + success: { data: listPluginsResponseSchema }, + errors: {}, + description: 'List installed plugins', + tags: ['plugins'], + operationId: 'listPlugins', + }, + async (req, reply) => { + const plugins = await core.accessor.get(IPluginService).listPlugins(); + reply.send(okEnvelope({ plugins }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + // POST /plugins {source} -------------------------------------------------- + const installRoute = defineRoute( + { + method: 'POST', + path: '/plugins', + body: installPluginRequestSchema, + success: { data: pluginSummarySchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + }, + description: 'Install a plugin from a local path, zip URL, or GitHub repo', + tags: ['plugins'], + operationId: 'installPlugin', + }, + async (req, reply) => { + try { + const plugin = await core.accessor.get(IPluginService).installPlugin(req.body); + reply.send(okEnvelope(plugin, req.id)); + } catch (error) { + reply.send(mapPluginError(error, req.id)); + } + }, + ); + app.post( + installRoute.path, + installRoute.options, + installRoute.handler as Parameters[2], + ); + + // POST /plugins/{plugin_id}:{enable|disable|remove} ------------------------ + const actionRoute = defineRoute( + { + method: 'POST', + path: '/plugins/{tail}', + params: pluginIdParamSchema, + success: { data: z.object({ ok: z.literal(true) }) }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PLUGIN_NOT_FOUND]: {}, + }, + description: 'Enable, disable, or remove an installed plugin', + tags: ['plugins'], + operationId: 'pluginAction', + }, + async (req, reply) => { + const parsed = parseActionSuffix({ + tail: req.params.tail, + allowedActions: PLUGIN_ACTIONS, + resourceLabel: 'plugin', + }); + if (parsed.kind !== 'action') { + const message = + parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${req.params.tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + const plugins = core.accessor.get(IPluginService); + try { + switch (parsed.action) { + case 'enable': + await plugins.setPluginEnabled({ id: parsed.id, enabled: true }); + break; + case 'disable': + await plugins.setPluginEnabled({ id: parsed.id, enabled: false }); + break; + case 'remove': + await plugins.removePlugin({ id: parsed.id }); + break; + } + reply.send(okEnvelope({ ok: true as const }, req.id)); + } catch (error) { + reply.send(mapPluginError(error, req.id)); + } + }, + ); + app.post( + actionRoute.path, + actionRoute.options, + actionRoute.handler as Parameters[2], + ); +} + +const PLUGIN_ERROR_MAP: Readonly> = { + [PluginErrors.codes.PLUGIN_NOT_FOUND]: ErrorCode.PLUGIN_NOT_FOUND, + // Client-fixable input mistakes (relative source, missing local path, an + // unloadable manifest at a valid location) keep their 4xx semantics + // instead of collapsing into a 50001. + [PluginErrors.codes.PLUGIN_LOAD_FAILED]: ErrorCode.VALIDATION_FAILED, + [DomainErrorCodes.VALIDATION_FAILED]: ErrorCode.VALIDATION_FAILED, + [DomainErrorCodes.FS_PATH_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, +}; + +function mapPluginError(error: unknown, requestId: string) { + const mapped = isError2(error) ? PLUGIN_ERROR_MAP[error.code] : undefined; + if (mapped !== undefined && isError2(error)) { + return errEnvelope(mapped, error.message, requestId, error.stack); + } + return errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + requestId, + error instanceof Error ? error.stack : undefined, + ); +} diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 1bb705a3a2..3a2231e1d1 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -20,6 +20,7 @@ import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBro import type { TranscriptService } from '../services/transcript/transcriptService'; import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; +import { registerCapabilitiesRoutes } from './capabilities'; import { registerConfigRoutes } from './config'; import { registerConnectionsRoutes } from './connections'; import { registerFilesRoutes } from './files'; @@ -31,6 +32,7 @@ import { registerDebugRoutes } from '../transport/registerDebugRoutes'; import { registerMetaRoute } from './meta'; import { registerModelCatalogRoutes } from './modelCatalog'; import { registerOAuthRoutes } from './oauth'; +import { registerPluginsRoutes } from './plugins'; import { registerPromptsRoutes } from './prompts'; import { registerQuestionsRoutes } from './questions'; import { registerSearchRoutes } from './search'; @@ -76,6 +78,10 @@ export interface RegisterApiV1RoutesOptions { readonly connectionRegistry: IConnectionRegistry; readonly broadcaster: SessionEventBroadcaster; readonly transcriptService: TranscriptService; + /** Catalog URL for the `/plugins/marketplace` route (resolved by start.ts). */ + readonly pluginMarketplaceUrl: string; + /** True when the catalog URL is the built-in default (no option/env set). */ + readonly pluginMarketplaceIsDefault: boolean; /** * Surface `dangerous_bypass_auth` in the `/meta` payload. Set by `start.ts` * from the `disableAuth` server option (the `--dangerous-bypass-auth` CLI @@ -132,6 +138,14 @@ export async function registerApiV1Routes( { hostIdentity: opts.hostIdentity }, ); registerSkillsRoutes(apiV1 as unknown as Parameters[0], core); + registerCapabilitiesRoutes( + apiV1 as unknown as Parameters[0], + core, + ); + registerPluginsRoutes(apiV1 as unknown as Parameters[0], core, { + marketplaceUrl: opts.pluginMarketplaceUrl, + marketplaceIsDefault: opts.pluginMarketplaceIsDefault, + }); registerMessagesRoutes( apiV1 as unknown as Parameters[0], core, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 91d5b74276..ad84e493f8 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -17,7 +17,10 @@ import { IProviderDiscoveryService, ISessionIndex, ISessionIndexMirror, + ICapabilityService, + IPluginService, IWorkspaceService, + KIMI_CODE_PLUGIN_MARKETPLACE_URL, logSeed, resolveConfigPath, resolveKimiHome, @@ -104,6 +107,12 @@ export interface ServerStartOptions { readonly host?: string; readonly port?: number; readonly homeDir?: string; + /** + * Plugin marketplace catalog URL for `GET /api/v1/plugins/marketplace`. + * Defaults to the `KIMI_CODE_PLUGIN_MARKETPLACE_URL` env var, then the + * production catalog. + */ + readonly pluginMarketplaceUrl?: string; readonly configPath?: string; /** * Override the instance-registry directory — used in tests that need the @@ -374,6 +383,8 @@ export async function startServer(opts: ServerStartOptions): Promise => { await app.close(); configWarningSubscription.dispose(); + pluginChangeSubscription.dispose(); + capabilityInstallSubscription.dispose(); authFailureLimiter?.dispose(); modelCatalogRefreshScheduler.dispose(); // Telemetry is best-effort and must never prevent core or instance cleanup. @@ -443,6 +454,22 @@ export async function startServer(opts: ServerStartOptions): Promise { + core.accessor.get(IEventService).publish({ type: 'event.plugin.changed', payload: {} }); + }); + const capabilityService = core.accessor.get(ICapabilityService); + const capabilityInstallSubscription = capabilityService.onDidChangeInstall((change) => { + core.accessor.get(IEventService).publish({ + type: 'event.capability.changed', + payload: { capability_id: change.id, install: change.install }, + }); + }); void configService.ready .then(() => { if (configService.diagnostics().some((diagnostic) => diagnostic.severity === 'warning')) { @@ -504,6 +531,16 @@ export async function startServer(opts: ServerStartOptions): Promise { void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); }, diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 49474daeab..dbf8c31aae 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -113,6 +113,30 @@ export interface ConfigWarningEvent { readonly warnings: readonly ConfigWarningItem[]; } +/** + * Plugin set mutation (install / enable / disable / remove from any client). + * Bare fan-out signal — clients re-read the plugins REST surface. + */ +export interface PluginChangedEvent { + readonly type: 'event.plugin.changed'; +} + +/** + * Capability install progress transition. Global fan-out; clients update the + * row live and re-read the capability once it settles (`running: false`). + */ +export interface CapabilityChangedEvent { + readonly type: 'event.capability.changed'; + readonly capability_id: string; + readonly install: { + readonly running: boolean; + readonly step?: string; + readonly percent?: number; + readonly error?: string; + readonly note?: string; + }; +} + /** * DI unit state transition of the engine's scope tree, produced by * agent-core-v2's `IDebugCascadeService` (the L5 debug surface feed). Global: @@ -210,6 +234,8 @@ export type AgentEvent = | SessionStatusChangedEvent | ConfigChangedEvent | ConfigWarningEvent + | PluginChangedEvent + | CapabilityChangedEvent | DiUnitChangedEvent | PromptSubmittedEvent | BackgroundTaskStartedEvent @@ -227,6 +253,10 @@ export const VOLATILE_EVENT_TYPES = [ 'shell.completed', 'agent.status.updated', 'event.di.unit_changed', + // Live-only install progress (per-chunk download ticks) — durable journaling + // would persist hundreds of stale frames per install. The settle frame is + // recoverable via a direct capability read, so the whole type stays volatile. + 'event.capability.changed', ] as const; export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number]; diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 38f151e7d9..bdadd1b408 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -904,6 +904,32 @@ export class SessionEventBroadcaster { ); return; } + if (event.type === 'event.plugin.changed') { + // Plugin set mutations (install/enable/disable/remove from ANY client) + // fan out so every host re-reads instead of caching stale rows. Bare + // signal by design — the payload is the services' own REST surfaces. + void this.dispatchGlobal({ + type: 'event.plugin.changed', + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.plugin.changed', error), + ); + return; + } + if (event.type === 'event.capability.changed') { + const payload = capabilityChangedPayload(event.payload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.capability.changed', + ...payload, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.capability.changed', error), + ); + return; + } if (event.type === 'event.config.warning') { const payload = configWarningPayload(event.payload); if (payload === undefined) return; @@ -1380,6 +1406,8 @@ function isGlobalEvent(type: string): boolean { type.startsWith('event.session.') || type.startsWith('event.workspace.') || type.startsWith('event.config.') || + type.startsWith('event.plugin.') || + type.startsWith('event.capability.') || type.startsWith('event.di.') ); } @@ -1691,6 +1719,35 @@ function sessionCreatedPayload( * entry rejects the whole batch — the publisher always sends the full current * warning set, so a partial frame would be a lie by omission. */ +interface CapabilityChangedPayload { + capability_id: string; + install: { + running: boolean; + step?: string; + percent?: number; + error?: string; + note?: string; + }; +} + +function capabilityChangedPayload(payload: unknown): CapabilityChangedPayload | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const id = (payload as { capability_id?: unknown }).capability_id; + if (typeof id !== 'string' || id.length === 0) return undefined; + const install = (payload as { install?: unknown }).install; + if (typeof install !== 'object' || install === null) return undefined; + const running = (install as { running?: unknown }).running; + if (typeof running !== 'boolean') return undefined; + const out: CapabilityChangedPayload['install'] = { running }; + for (const key of ['step', 'error', 'note'] as const) { + const value = (install as Record)[key]; + if (typeof value === 'string') out[key] = value; + } + const percent = (install as { percent?: unknown }).percent; + if (typeof percent === 'number') out.percent = percent; + return { capability_id: id, install: out }; +} + function configWarningPayload(payload: unknown): { warnings: ConfigWarningItem[] } | undefined { if (typeof payload !== 'object' || payload === null) return undefined; const warnings = (payload as { warnings?: unknown }).warnings; diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index b03321c439..a12395892b 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -40,6 +40,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/auth", ], + [ + "GET", + "/api/v1/capabilities", + ], + [ + "GET", + "/api/v1/capabilities/{capability_id}", + ], [ "GET", "/api/v1/catalog/providers", @@ -128,6 +136,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/oauth/userinfo", ], + [ + "GET", + "/api/v1/plugins", + ], + [ + "GET", + "/api/v1/plugins/marketplace", + ], [ "GET", "/api/v1/providers", @@ -260,6 +276,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "PATCH", "/api/v1/workspaces/{workspace_id}", ], + [ + "POST", + "/api/v1/capabilities/{tail}", + ], [ "POST", "/api/v1/config", @@ -316,6 +336,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/oauth/logout", ], + [ + "POST", + "/api/v1/plugins", + ], + [ + "POST", + "/api/v1/plugins/{tail}", + ], [ "POST", "/api/v1/providers", diff --git a/packages/kap-server/test/capabilities.test.ts b/packages/kap-server/test/capabilities.test.ts new file mode 100644 index 0000000000..09715db6e7 --- /dev/null +++ b/packages/kap-server/test/capabilities.test.ts @@ -0,0 +1,138 @@ +/** + * `/api/v1` capabilities routes — wire contract: + * - GET /api/v1/capabilities → envelope shape + both entries + * - GET /api/v1/capabilities/{unknown} → 40418 + * - POST /api/v1/capabilities/{unknown}:install → 40418 + * - POST /api/v1/capabilities/{id} (bare) → 40001 + * - POST /api/v1/capabilities/{id}:{bogus} → 40001 + * - POST /api/v1/capabilities/kimi-cu:install on an unsupported host → 40925 + * (skipped on macOS and Windows x64, where kimi-cu is supported) + * + * Real installs are never triggered from tests: the only `:install` calls + * target an unknown id or an unsupported platform. `GET` runs the entries' + * read-only detection against the isolated home dir. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + capabilityStatusSchema, + listCapabilitiesResponseSchema, +} from '../src/protocol/rest-capability'; +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 /api/v1 capabilities', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-capabilities-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: '{}', + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('lists both built-in capabilities with the documented shape', async () => { + const { body } = await getJson('/api/v1/capabilities'); + expect(body.code).toBe(0); + const parsed = listCapabilitiesResponseSchema.parse(body.data); + const ids = parsed.capabilities.map((c) => c.id).toSorted(); + expect(ids).toEqual(['kimi-cu', 'kimi-webbridge']); + for (const capability of parsed.capabilities) { + expect(capabilityStatusSchema.parse(capability)).toBeTruthy(); + expect(capability.install.running).toBe(false); + } + // Platform-gated entry: kimi-cu runs on macOS and Windows x64. + const kimiCu = parsed.capabilities.find((c) => c.id === 'kimi-cu'); + if (process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64')) { + expect(kimiCu?.supported).toBe(true); + } else { + expect(kimiCu?.supported).toBe(false); + expect(kimiCu?.state).toBe('unsupported'); + } + // The isolated home dir has no plugin records → the skill step is missing. + const webbridge = parsed.capabilities.find((c) => c.id === 'kimi-webbridge'); + expect(webbridge?.supported).toBe(true); + expect(webbridge?.steps.find((s) => s.id === 'skill')?.state).toBe('missing'); + // The browser extension is a soft gate (never blocks readiness). + expect(webbridge?.steps.find((s) => s.id === 'extension')?.optional).toBe(true); + }); + + it('gets a single capability and 40418s on an unknown id', async () => { + const { body } = await getJson('/api/v1/capabilities/kimi-webbridge'); + expect(body.code).toBe(0); + expect(capabilityStatusSchema.parse(body.data).id).toBe('kimi-webbridge'); + + const missing = await getJson('/api/v1/capabilities/nope'); + expect(missing.body.code).toBe(40418); + expect(missing.body.data).toBeNull(); + }); + + it('installs 40418 on an unknown id without side effects', async () => { + const { body } = await postJson('/api/v1/capabilities/nope:install'); + expect(body.code).toBe(40418); + }); + + it('rejects bare ids and unknown actions with 40001', async () => { + const bare = await postJson('/api/v1/capabilities/kimi-cu'); + expect(bare.body.code).toBe(40001); + const bogus = await postJson('/api/v1/capabilities/kimi-cu:uninstall'); + expect(bogus.body.code).toBe(40001); + }); + + // kimi-cu is supported on macOS and Windows x64 — only genuinely + // unsupported platforms (Linux, win32-arm64, …) get the 40924 rejection. + it.skipIf(process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'))( + 'rejects kimi-cu install on unsupported platforms with 40925', + async () => { + const { body } = await postJson('/api/v1/capabilities/kimi-cu:install'); + expect(body.code).toBe(40925); + }, + ); +}); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts new file mode 100644 index 0000000000..7d10ef179b --- /dev/null +++ b/packages/kap-server/test/plugins.test.ts @@ -0,0 +1,632 @@ +/** + * `/api/v1` plugins routes — wire contract: + * - GET /plugins → installed list (empty → 1 after install) + * - POST /plugins {source} → installs (local path), returns summary + * - POST /plugins/{id}:disable / :enable → toggles enabled + * - POST /plugins/{id}:remove → removes + * - POST bare id / bogus action → 40001 + * - POST unknown id :remove → 40419 + * - POST relative / nonexistent source → 40001 / 40409 (never 50001) + * - GET /plugins/marketplace → catalog merged with live install state + * - GET /plugins/marketplace unreachable → 50001 + * + * The marketplace catalog is served by a stubbed global fetch; installs use + * local-path sources in temp dirs (no network). + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WebSocket } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders, bearerToken } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +const CATALOG_URL = 'http://marketplace.test/marketplace.json'; + +const CATALOG = { + version: '1', + plugins: [ + { + id: 'demo-plugin', + tier: 'official', + displayName: 'Demo Plugin', + // A `v`-prefixed catalog version still drives the update check. + version: 'v2.0.0', + source: 'https://cdn.example.test/demo.zip', + }, + { + id: 'third-party-plugin', + displayName: 'Third Party', + source: 'https://github.com/example/third', + }, + { + // Catalog-relative source (the production CDN catalog's shape). + id: 'relative-plugin', + displayName: 'Relative', + source: './plugins/relative.zip', + }, + { + // Legacy `url` alias (accepted by the CLI parser); a blank `source` + // must not shadow the alias. + id: 'alias-plugin', + displayName: 'Alias', + source: ' ', + url: './plugins/alias.zip', + }, + { + // A blank tier reads as missing (third-party), not a validation error. + id: 'blank-tier-plugin', + displayName: 'Blank Tier', + tier: ' ', + source: 'https://example.test/bt.zip', + }, + { + // A non-string version reads as missing, so the GitHub release-tag + // source supplies it. + id: 'gh-plugin', + displayName: 'GH Plugin', + version: 2, + source: 'https://github.com/example/gh/releases/tag/v2.0.0', + }, + { + // A capability's wiring plugin — the response marks it so clients + // route the install through the capability surface. + id: 'kimi-webbridge', + displayName: 'Kimi WebBridge', + source: 'https://cdn.example.test/kimi-webbridge.zip', + }, + { + // kimi-cu joins install state through the platform wiring id too + // ('kimi-cu-win' on Windows x64). + id: 'kimi-cu', + displayName: 'Kimi Computer Use', + source: 'https://cdn.example.test/kimi-cu.zip', + }, + { + // CLI metadata aliases: name / shortDescription / websiteURL. + // The padded id trims before the install-state join. + id: ' meta-alias-plugin ', + name: 'Meta Alias', + shortDescription: 'Aliased metadata', + websiteURL: 'https://example.test/meta', + keywords: ['web', 3, ' ', 'tools'], + source: 'https://example.test/meta.zip', + }, + ], +}; + +describe('server-v2 /api/v1 plugins', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + const createdDirs: string[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-plugins-')); + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + return new Response(JSON.stringify(CATALOG), { status: 200 }); + } + // Latest-release lookups for bare GitHub repo sources. + if (url === 'https://github.com/example/third/releases/latest') { + return new Response(null, { + status: 302, + headers: { location: 'https://github.com/example/third/releases/tag/v3.1.0' }, + }); + } + if (typeof url === 'string' && url.includes('/releases/latest')) { + return new Response(null, { status: 404 }); + } + return realFetch(url as never, init); + }), + ); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + pluginMarketplaceUrl: CATALOG_URL, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + if (server !== undefined) { + await server.close(); + server = undefined; + } + for (const dir of createdDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function call( + method: 'GET' | 'POST', + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method, + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + // A JSON content-type with an empty body is rejected by Fastify. + body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined, + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function makePluginDir(id: string, version: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kimi-test-plugin-${id}-`)); + createdDirs.push(dir); + await writeFile( + join(dir, 'kimi.plugin.json'), + JSON.stringify({ name: id, version, description: 'test plugin' }), + ); + return dir; + } + + it('installs, lists, disables, enables, and removes a plugin', async () => { + const empty = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins'); + expect(empty.body.data.plugins).toEqual([]); + + const source = await makePluginDir('demo-plugin', '1.0.0'); + const installed = await call<{ id: string; version: string; enabled: boolean }>( + 'POST', + '/api/v1/plugins', + { source }, + ); + expect(installed.body.code).toBe(0); + expect(installed.body.data).toMatchObject({ id: 'demo-plugin', version: '1.0.0', enabled: true }); + + const list = await call<{ plugins: { id: string; enabled: boolean }[] }>( + 'GET', + '/api/v1/plugins', + ); + expect(list.body.data.plugins.map((p) => [p.id, p.enabled])).toEqual([['demo-plugin', true]]); + + const disabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:disable'); + expect(disabled.body.code).toBe(0); + const afterDisable = await call<{ plugins: { enabled: boolean }[] }>('GET', '/api/v1/plugins'); + expect(afterDisable.body.data.plugins[0]?.enabled).toBe(false); + + const enabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:enable'); + expect(enabled.body.code).toBe(0); + + const removed = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:remove'); + expect(removed.body.code).toBe(0); + const afterRemove = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins'); + expect(afterRemove.body.data.plugins).toEqual([]); + }); + + it('rejects bare ids, bogus actions, and unknown plugins', async () => { + const bare = await call('POST', '/api/v1/plugins/demo-plugin'); + expect(bare.body.code).toBe(40001); + const bogus = await call('POST', '/api/v1/plugins/demo-plugin:explode'); + expect(bogus.body.code).toBe(40001); + const unknown = await call('POST', '/api/v1/plugins/nope:remove'); + expect(unknown.body.code).toBe(40419); + const badSource = await call('POST', '/api/v1/plugins', { source: '' }); + expect(badSource.body.code).toBe(40001); + }); + + it('fans out event.plugin.changed over WS on install and remove', async () => { + const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/ws`, [ + `kimi-code.bearer.${bearerToken(server!)}`, + ]); + const types: string[] = []; + try { + await new Promise((resolve, reject) => { + ws.once('message', () => { + resolve(); + }); // server_hello + ws.once('error', reject); + }); + ws.on('message', (data: Buffer) => { + const frame = JSON.parse(data.toString('utf8')) as { type?: string }; + if (frame.type !== undefined) types.push(frame.type); + }); + + const source = await makePluginDir('demo-plugin', '1.0.0'); + await call('POST', '/api/v1/plugins', { source }); + await vi.waitFor(() => { + expect(types).toContain('event.plugin.changed'); + }); + + await call('POST', '/api/v1/plugins/demo-plugin:remove'); + await vi.waitFor(() => { + expect(types.filter((t) => t === 'event.plugin.changed').length).toBeGreaterThanOrEqual(2); + }); + } finally { + ws.close(); + } + }); + + it('maps client-fixable install input errors to 4xx, never 50001', async () => { + // Relative source: the domain rejects non-absolute local paths. + const relative = await call('POST', '/api/v1/plugins', { source: 'relative/dir' }); + expect(relative.body.code).toBe(40001); + // Absolute but nonexistent path. + const missing = await call('POST', '/api/v1/plugins', { + source: join(home!, 'no-such-plugin-dir'), + }); + expect(missing.body.code).toBe(40409); + // Existing directory without a valid manifest → plugin.load_failed. + const noManifest = await mkdtemp(join(tmpdir(), 'kimi-no-manifest-')); + createdDirs.push(noManifest); + const unloadable = await call('POST', '/api/v1/plugins', { source: noManifest }); + expect(unloadable.body.code).toBe(40001); + }); + + it('serves the marketplace catalog merged with live install state', async () => { + const before = await call<{ + entries: { + id: string; + tier: string; + displayName: string; + source: string; + version?: string; + capabilityId?: string; + description?: string; + homepage?: string; + keywords?: string[]; + installed?: { version?: string }; + }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(before.body.code).toBe(0); + expect(before.body.data.entries.map((e) => [e.id, e.tier])).toEqual([ + ['demo-plugin', 'official'], + ['third-party-plugin', 'third-party'], + ['relative-plugin', 'third-party'], + ['alias-plugin', 'third-party'], + ['blank-tier-plugin', 'third-party'], + ['gh-plugin', 'third-party'], + ['kimi-webbridge', 'third-party'], + ['kimi-cu', 'third-party'], + ['meta-alias-plugin', 'third-party'], + ]); + expect(before.body.data.entries[0]?.installed).toBeUndefined(); + // Catalog-relative sources resolve against the catalog URL. + const relative = before.body.data.entries.find((e) => e.id === 'relative-plugin'); + expect(relative?.source).toBe('http://marketplace.test/plugins/relative.zip'); + // The legacy `url` alias is accepted and resolved the same way. + const alias = before.body.data.entries.find((e) => e.id === 'alias-plugin'); + expect(alias?.source).toBe('http://marketplace.test/plugins/alias.zip'); + // Version derived from the GitHub release-tag source. + expect(before.body.data.entries.find((e) => e.id === 'gh-plugin')?.version).toBe('2.0.0'); + // Bare GitHub repo source: latest release tag resolved through the + // /releases/latest redirect. + expect(before.body.data.entries.find((e) => e.id === 'third-party-plugin')?.version).toBe( + '3.1.0', + ); + // A custom catalog never gets capability markers (same-id forks stay + // plain plugins) — markers only apply to the default catalog. + expect( + before.body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId, + ).toBeUndefined(); + // And no built-in injection either. + expect(before.body.data.entries.some((e) => e.source.startsWith('capability:'))).toBe(false); + // CLI metadata aliases map onto the wire fields. + const meta = before.body.data.entries.find((e) => e.id === 'meta-alias-plugin'); + expect(meta?.displayName).toBe('Meta Alias'); + expect(meta?.description).toBe('Aliased metadata'); + expect(meta?.homepage).toBe('https://example.test/meta'); + // Keywords filter to non-blank strings instead of failing the catalog. + expect(meta?.keywords).toEqual(['web', 'tools']); + + // Install an older version than the catalog → updateAvailable. + const source = await makePluginDir('demo-plugin', '1.0.0'); + await call('POST', '/api/v1/plugins', { source }); + + const after = await call<{ + entries: { + id: string; + installed?: { version?: string; enabled: boolean }; + updateAvailable?: boolean; + }[]; + }>('GET', '/api/v1/plugins/marketplace'); + const demo = after.body.data.entries.find((e) => e.id === 'demo-plugin'); + expect(demo?.installed).toEqual({ version: '1.0.0', enabled: true }); + expect(demo?.updateAvailable).toBe(true); + + // A version derived from the GitHub tag source drives updateAvailable too. + const ghSource = await makePluginDir('gh-plugin', '1.5.0'); + await call('POST', '/api/v1/plugins', { source: ghSource }); + const afterGh = await call<{ + entries: { id: string; updateAvailable?: boolean }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(afterGh.body.data.entries.find((e) => e.id === 'gh-plugin')?.updateAvailable).toBe(true); + }); + + it('rejects a catalog whose entry has no usable source', async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + return new Response( + JSON.stringify({ plugins: [{ id: 'bad', source: ' ' }] }), + { status: 200 }, + ); + } + return realFetch(url as never, init); + }), + ); + const { body } = await call('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(50001); + expect(body.msg).toContain('invalid catalog'); + }); + + it('rejects a catalog with an unsupported entry type', async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + return new Response( + JSON.stringify({ + plugins: [{ id: 'bad', type: 'integration', source: 'https://example.test/x.zip' }], + }), + { status: 200 }, + ); + } + return realFetch(url as never, init); + }), + ); + const { body } = await call('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(50001); + expect(body.msg).toContain('invalid catalog'); + }); + + it('treats the dev marketplace server as the default catalog', async () => { + // scripts/dev.mjs serves the repo catalog and marks itself; capability + // markers apply as if no env were set. + await server?.close(); + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_URL', CATALOG_URL); + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER', '1'); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + + const { body } = await call<{ entries: { id: string; capabilityId?: string }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + expect(body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId).toBe( + 'kimi-webbridge', + ); + + // kimi-cu row assertions: on unsupported platforms the row is hidden + // entirely (never marked, never offered). + const cuSupported = process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'); + const after0 = await call<{ + entries: { id: string; capabilityId?: string; installed?: { version?: string } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + if (!cuSupported) { + expect(after0.body.data.entries.find((e) => e.id === 'kimi-cu')).toBeUndefined(); + return; + } + + // A plugin installed under the Windows wiring id still marks the + // kimi-cu row installed (the join follows the capability's plugin ids). + const winSource = await makePluginDir('kimi-cu-win', '0.5.4'); + await call('POST', '/api/v1/plugins', { source: winSource }); + const after = await call<{ + entries: { id: string; capabilityId?: string; installed?: { version?: string } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + const cu = after.body.data.entries.find((e) => e.id === 'kimi-cu'); + expect(cu?.capabilityId).toBe('kimi-cu'); + expect(cu?.installed?.version).toBe('0.5.4'); + + // With BOTH records present, the platform-canonical wiring plugin wins + // (on macOS that is the bare kimi-cu id, so this stale record shows). + const staleSource = await makePluginDir('kimi-cu', '0.1.0'); + await call('POST', '/api/v1/plugins', { source: staleSource }); + const both = await call<{ + entries: { id: string; installed?: { version?: string } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + const expected = process.platform === 'win32' && process.arch === 'x64' ? '0.5.4' : '0.1.0'; + expect(both.body.data.entries.find((e) => e.id === 'kimi-cu')?.installed?.version).toBe( + expected, + ); + }); + + it('maps an unreachable marketplace to 50001', async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + throw new Error('network down'); + } + return realFetch(url as never, init); + }), + ); + const { body } = await call('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(50001); + expect(body.msg).toContain('unreachable'); + }); + + it('reads a local marketplace catalog from disk (plain path or file://)', async () => { + // Restart with a file-based catalog — the same env the CLI accepts. + await server?.close(); + const catalogDir = await mkdtemp(join(tmpdir(), 'kimi-local-catalog-')); + createdDirs.push(catalogDir); + const fileUrlPluginPath = join(catalogDir, 'plugins', 'file.zip'); + await writeFile( + join(catalogDir, 'marketplace.json'), + JSON.stringify({ + plugins: [ + { id: 'local-plugin', source: './zips/local.zip' }, + // Portable absolute file URL (drive-rooted on Windows). + { id: 'file-url-plugin', source: pathToFileURL(fileUrlPluginPath).href }, + ], + }), + ); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + pluginMarketplaceUrl: join(catalogDir, 'marketplace.json'), + }); + base = `http://127.0.0.1:${server.port}`; + + const { body } = await call<{ entries: { id: string; source: string }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + expect(body.data.entries).toEqual([ + { + id: 'local-plugin', + tier: 'third-party', + displayName: 'local-plugin', + // Relative sources resolve against the catalog file's directory. + source: join(catalogDir, 'zips', 'local.zip'), + }, + { + id: 'file-url-plugin', + tier: 'third-party', + displayName: 'file-url-plugin', + // file:// sources convert to plain absolute paths (installable). + source: fileUrlPluginPath, + }, + ]); + }); + + it('falls back to the source-checkout catalog when the remote is unreachable', async () => { + await server?.close(); + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (typeof url === 'string' && url.includes('/releases/latest')) { + return new Response(null, { status: 404 }); + } + if (url === 'https://code.kimi.com/kimi-code/plugins/marketplace.json') { + throw new Error('offline'); + } + return realFetch(url as never, init); + }), + ); + // No pluginMarketplaceUrl / env: the default production catalog is + // unreachable and the repo checkout's own catalog takes over (CLI parity). + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_URL', undefined as unknown as string); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + + const { body } = await call<{ + entries: { + id: string; + source: string; + tier?: string; + displayName?: string; + capabilityId?: string; + }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(0); + const datasource = body.data.entries.find((e) => e.id === 'kimi-datasource'); + // Relative sources resolve against the fallback file, not the failed URL. + expect(datasource?.source.startsWith('http')).toBe(false); + expect(datasource?.source.endsWith(join('plugins', 'official', 'kimi-datasource'))).toBe(true); + // The default catalog (even served from the checkout fallback) marks + // capability wiring rows. + const webbridge = body.data.entries.find((e) => e.id === 'kimi-webbridge'); + expect(webbridge?.capabilityId).toBe('kimi-webbridge'); + // Capabilities the catalog does not carry are injected as built-in rows + // where supported (kimi-cu is not in the checked-in catalog, and is + // supported on macOS / Windows x64 only). + const cuSupported = process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'); + const cu = body.data.entries.find((e) => e.id === 'kimi-cu'); + if (!cuSupported) { + expect(cu).toBeUndefined(); + return; + } + expect(cu?.tier).toBe('official'); + expect(cu?.capabilityId).toBe('kimi-cu'); + expect(cu?.source).toBe('capability:kimi-cu'); + expect(cu?.displayName).toBe('Kimi Computer Use'); + + // Injected rows join install state like catalog rows. + const cuSource = await makePluginDir('kimi-cu', '0.5.8'); + await call('POST', '/api/v1/plugins', { source: cuSource }); + const after = await call<{ + entries: { id: string; installed?: { version?: string; enabled: boolean } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(after.body.data.entries.find((e) => e.id === 'kimi-cu')?.installed).toEqual({ + version: '0.5.8', + enabled: true, + }); + }); + + it('expands ~ in local catalog paths like the CLI loader', async () => { + await server?.close(); + const fakeHome = await mkdtemp(join(tmpdir(), 'kimi-tilde-home-')); + createdDirs.push(fakeHome); + await writeFile( + join(fakeHome, 'marketplace.json'), + JSON.stringify({ + plugins: [ + { id: 'tilde-plugin', source: 'https://example.test/t.zip' }, + // Home-relative entry source expands against the stubbed HOME. + { id: 'tilde-entry-plugin', source: '~/plugins/t.zip' }, + ], + }), + ); + // os.homedir() reads HOME on POSIX and USERPROFILE on Windows. + vi.stubEnv('HOME', fakeHome); + vi.stubEnv('USERPROFILE', fakeHome); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home!, + logLevel: 'silent', + pluginMarketplaceUrl: '~/marketplace.json', + }); + base = `http://127.0.0.1:${server.port}`; + + const { body } = await call<{ entries: { id: string; source: string }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + expect(body.data.entries.map((e) => e.id)).toEqual(['tilde-plugin', 'tilde-entry-plugin']); + expect(body.data.entries[1]?.source).toBe(join(fakeHome, 'plugins', 't.zip')); + }); +}); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 88408732d4..0fae462e9b 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -1194,6 +1194,64 @@ describe('SessionEventBroadcaster', () => { expect(globalView.deliveries).toEqual(['immediate']); }); + it('fans out event.plugin.changed and event.capability.changed to global targets', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + eventBus.emit({ type: 'event.plugin.changed', payload: {} }); + eventBus.emit({ + type: 'event.capability.changed', + payload: { + capability_id: 'kimi-webbridge', + install: { running: true, step: 'download', percent: 42 }, + }, + }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(2)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.plugin.changed', + session_id: '__global__', + }); + expect(globalView.envelopes[1]).toMatchObject({ + type: 'event.capability.changed', + session_id: '__global__', + payload: { + capability_id: 'kimi-webbridge', + install: { running: true, step: 'download', percent: 42 }, + }, + }); + // Progress ticks are live-only (volatile, not journaled); the plugin + // change signal stays durable so a reconnecting client can replay it. + expect(globalView.envelopes[0]!.volatile).toBeUndefined(); + expect(globalView.envelopes[1]!.volatile).toBe(true); + }); + + it('drops malformed event.capability.changed payloads', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + eventBus.emit({ type: 'event.capability.changed', payload: null }); + eventBus.emit({ + type: 'event.capability.changed', + payload: { capability_id: 7, install: { running: true } }, + }); + eventBus.emit({ + type: 'event.capability.changed', + payload: { capability_id: 'kimi-cu' }, // no install object + }); + + eventBus.emit({ + type: 'event.capability.changed', + payload: { capability_id: 'kimi-cu', install: { running: false } }, + }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.capability.changed', + payload: { capability_id: 'kimi-cu', install: { running: false } }, + }); + }); + it('drops malformed event.config.warning payloads', async () => { const globalView = collectingTarget(); bc.addGlobalTarget(globalView.target); diff --git a/packages/klient/src/contract/global/capabilities.ts b/packages/klient/src/contract/global/capabilities.ts index 7575facab6..c34efd5c6d 100644 --- a/packages/klient/src/contract/global/capabilities.ts +++ b/packages/klient/src/contract/global/capabilities.ts @@ -19,6 +19,7 @@ export const capabilityInstallProgressSchema = z.object({ step: z.string().optional(), percent: z.number().optional(), error: z.string().optional(), + note: z.string().optional(), }); export const capabilityStatusSchema = z.object({ diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 6999f9ff33..7435a59fd5 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -105,7 +105,8 @@ describe('facade routing', () => { supported: true, state: 'partial', steps: [{ id: 'permissions', state: 'missing' }], - install: { running: false }, + // The completed-install note survives the contract parse (not stripped). + install: { running: false, note: 'user-skill-migrated' }, }; channel.result = [status]; diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index 61797a9e65..4e59030510 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -76,6 +76,8 @@ describe('Event public types', () => { case 'event.workspace.deleted': case 'event.config.changed': case 'event.model_catalog.changed': + case 'event.plugin.changed': + case 'event.capability.changed': case 'goal.updated': case 'skill.activated': case 'plugin_command.activated': diff --git a/packages/protocol/src/__tests__/snapshot.test.ts b/packages/protocol/src/__tests__/snapshot.test.ts index 3301d51768..3b23aad0fd 100644 --- a/packages/protocol/src/__tests__/snapshot.test.ts +++ b/packages/protocol/src/__tests__/snapshot.test.ts @@ -182,10 +182,11 @@ describe('events — volatile classification', () => { 'shell.started', 'shell.completed', 'agent.status.updated', + 'event.capability.changed', ]) { expect(isVolatileEventType(type)).toBe(true); } - expect(VOLATILE_EVENT_TYPES).toHaveLength(8); + expect(VOLATILE_EVENT_TYPES).toHaveLength(9); }); it('keeps timeline-bearing events durable', () => { diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index bea2fbe265..e466bcd983 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -591,6 +591,30 @@ export interface ModelCatalogChangedEvent { readonly failed: readonly ProviderRefreshFailure[]; } +/** + * Plugin set mutation (install / enable / disable / remove from any client). + * Bare global fan-out — clients re-read the plugins REST surface. + */ +export interface PluginChangedEvent { + readonly type: 'event.plugin.changed'; +} + +/** + * Capability install progress transition, fanned out globally. Clients update + * the row live and re-read the capability once it settles (`running: false`). + */ +export interface CapabilityChangedEvent { + readonly type: 'event.capability.changed'; + readonly capability_id: string; + readonly install: { + readonly running: boolean; + readonly step?: string; + readonly percent?: number; + readonly error?: string; + readonly note?: string; + }; +} + export interface GoalUpdatedEvent { readonly type: 'goal.updated'; readonly snapshot: GoalSnapshot | null; @@ -948,6 +972,8 @@ export type AgentEvent = | SessionStatusChangedEvent | ConfigChangedEvent | ModelCatalogChangedEvent + | PluginChangedEvent + | CapabilityChangedEvent | GoalUpdatedEvent | SkillActivatedEvent | PluginCommandActivatedEvent @@ -1522,6 +1548,22 @@ export const modelCatalogChangedEventSchema = z.object({ failed: z.array(providerRefreshFailureSchema), }) satisfies z.ZodType; +export const pluginChangedEventSchema = z.object({ + type: z.literal('event.plugin.changed'), +}) satisfies z.ZodType; + +export const capabilityChangedEventSchema = z.object({ + type: z.literal('event.capability.changed'), + capability_id: z.string().min(1), + install: z.object({ + running: z.boolean(), + step: z.string().optional(), + percent: z.number().optional(), + error: z.string().optional(), + note: z.string().optional(), + }), +}) satisfies z.ZodType; + export const goalUpdatedEventSchema = z.object({ type: z.literal('goal.updated'), snapshot: goalSnapshotSchema.nullable(), @@ -1846,6 +1888,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [ sessionWorkChangedEventSchema, sessionStatusChangedEventSchema, modelCatalogChangedEventSchema, + pluginChangedEventSchema, + capabilityChangedEventSchema, goalUpdatedEventSchema, skillActivatedEventSchema, pluginCommandActivatedEventSchema, @@ -1921,6 +1965,10 @@ export const VOLATILE_EVENT_TYPES = [ 'shell.started', 'shell.completed', 'agent.status.updated', + // Live-only capability install progress (per-chunk ticks); kap-server + // classifies it volatile (never journaled), so shared-protocol clients must + // not treat it as durable/replayable either. + 'event.capability.changed', ] as const satisfies readonly AgentEvent['type'][]; export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ce665033..60f464cf57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -685,6 +685,9 @@ importers: retry: specifier: 0.13.1 version: 0.13.1 + semver: + specifier: ^7.7.4 + version: 7.7.4 smol-toml: specifier: ^1.6.1 version: 1.6.1 @@ -719,6 +722,9 @@ importers: '@types/retry': specifier: 0.12.0 version: 0.12.0 + '@types/semver': + specifier: ^7.7.0 + version: 7.7.1 '@types/sinon': specifier: ^21.0.1 version: 21.0.1 @@ -783,6 +789,9 @@ importers: pino: specifier: ^9.5.0 version: 9.14.0 + semver: + specifier: ^7.7.4 + version: 7.7.4 smol-toml: specifier: ^1.6.1 version: 1.6.1 @@ -799,6 +808,9 @@ importers: '@types/bcryptjs': specifier: ^2.4.6 version: 2.4.6 + '@types/semver': + specifier: ^7.7.0 + version: 7.7.1 '@types/ws': specifier: ^8.18.0 version: 8.18.1