From 0eb9a324947fd7c3f28ff435e0c12f9788523038 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 01:51:30 +0800 Subject: [PATCH 01/46] feat(agent-core-v2): surface a machine-key note from capability installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CapabilityEntry.install now resolves an optional note exposed through CapabilityInstallProgress.note (wire-visible). The webbridge entry returns 'user-skill-migrated' when it migrates a pre-existing standalone skill copy onto the plugin-managed one — clients can localize the migration instead of the skill silently disappearing from the user's directory. --- .../src/app/capability/capabilityService.ts | 4 +-- .../src/app/capability/entries/kimiCu.ts | 6 ++-- .../app/capability/entries/kimiWebbridge.ts | 5 +++- .../agent-core-v2/src/app/capability/types.ts | 13 ++++++++- .../app/capability/capabilityService.test.ts | 28 +++++++++++++++---- .../test/app/capability/kimiWebbridge.test.ts | 6 ++-- 6 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts index 8c42563ac4..7938598e14 100644 --- a/packages/agent-core-v2/src/app/capability/capabilityService.ts +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -93,13 +93,13 @@ export class CapabilityService implements ICapabilityService { this.installProgress.set(entry.id, { running: true }); void (async () => { try { - await entry.install((step, percent) => { + const note = await entry.install((step, percent) => { this.installProgress.set( entry.id, percent === undefined ? { running: true, step } : { running: true, step, percent }, ); }); - this.installProgress.set(entry.id, { running: false }); + this.installProgress.set(entry.id, { running: false, note }); } catch (error) { const step = this.installProgress.get(entry.id)?.step; this.log.warn('capability install failed', { 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..037f7ea33a 100644 --- a/packages/agent-core-v2/src/app/capability/types.ts +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -27,6 +27,13 @@ export interface CapabilityInstallProgress { readonly step?: string; readonly percent?: number; readonly error?: string; + /** + * Machine-key note from the last completed install (e.g. + * 'user-skill-migrated' — a pre-existing user-source skill was replaced by + * the plugin-managed copy). Clients localize it; cleared on the next + * attempt. + */ + readonly note?: string; } export interface CapabilityDetectResult { @@ -56,5 +63,9 @@ export interface CapabilityEntry { readonly description: string; readonly supported: boolean; detect(): Promise; - install(report: CapabilityInstallReporter): Promise; + /** + * Resolves with an optional machine-key note surfaced through + * `CapabilityInstallProgress.note` (e.g. 'user-skill-migrated'). + */ + install(report: CapabilityInstallReporter): Promise; } 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..a52158f3b2 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,22 @@ describe('CapabilityService', () => { expect.unreachable('install never settled'); }); + 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 +238,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 () => { From 23812879b408019ebc56f7e058352ae36d0e85f6 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 01:51:41 +0800 Subject: [PATCH 02/46] feat(kap-server): add plugin management and capability REST routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the App-scope plugin and capability services over the wire so non-CLI hosts (desktop, web) can manage plugins and built-in capabilities end to end: - GET /api/v1/plugins, POST /api/v1/plugins {source}, POST /api/v1/plugins/{id}:{enable,disable,remove} - GET /api/v1/plugins/marketplace — catalog (pluginMarketplaceUrl server option / KIMI_CODE_PLUGIN_MARKETPLACE_URL env / production default) merged on demand with live install state; updateAvailable only on strict semver catalog > installed (no semver dependency) - GET /api/v1/capabilities, GET /api/v1/capabilities/{id}, POST /api/v1/capabilities/{id}:install with client-polled progress - New wire codes 40418 capability.not_found, 40419 plugin.not_found, 40923 capability.install_in_progress, 40924 capability.unsupported Mutations flow through IPluginService, so they serialize with other install paths and fire onDidReload (session skill catalogs and the capability shelf-install hook converge). --- .../kap-server/src/protocol/error-codes.ts | 8 + .../src/protocol/rest-capability.ts | 47 +++ .../kap-server/src/protocol/rest-plugin.ts | 68 ++++ .../kap-server/src/routes/capabilities.ts | 177 +++++++++++ packages/kap-server/src/routes/plugins.ts | 297 ++++++++++++++++++ .../src/routes/registerApiV1Routes.ts | 11 + packages/kap-server/src/start.ts | 13 + .../apiSurface.snapshot.test.ts.snap | 28 ++ packages/kap-server/test/capabilities.test.ts | 135 ++++++++ packages/kap-server/test/plugins.test.ts | 207 ++++++++++++ 10 files changed, 991 insertions(+) create mode 100644 packages/kap-server/src/protocol/rest-capability.ts create mode 100644 packages/kap-server/src/protocol/rest-plugin.ts create mode 100644 packages/kap-server/src/routes/capabilities.ts create mode 100644 packages/kap-server/src/routes/plugins.ts create mode 100644 packages/kap-server/test/capabilities.test.ts create mode 100644 packages/kap-server/test/plugins.test.ts diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index 2f05e68575..1fce434947 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, @@ -118,6 +122,10 @@ export const ErrorCode = { PROVIDER_ALREADY_EXISTS: 40921, /** page_token 损坏 / 版本不符 / 与当前查询条件不匹配,需从首页重新拉取 */ PAGE_TOKEN_MISMATCH: 40922, + /** capability 正在安装中,拒绝并发安装 */ + CAPABILITY_INSTALL_IN_PROGRESS: 40923, + /** 当前平台/架构不支持该 capability */ + CAPABILITY_UNSUPPORTED: 40924, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, 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..b7f4a2ce00 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-plugin.ts @@ -0,0 +1,68 @@ +/** + * GET /v1/plugins + * GET /v1/plugins/marketplace + * POST /v1/plugins + * POST /v1/plugins/{plugin_id}:{enable,disable,remove} + */ + +import { z } from 'zod'; + +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(), +}); +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(), +}); +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..50c153ff03 --- /dev/null +++ b/packages/kap-server/src/routes/plugins.ts @@ -0,0 +1,297 @@ +/** + * `/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 + * fetched on demand from the configured URL (`pluginMarketplaceUrl` server + * option, env `KIMI_CODE_PLUGIN_MARKETPLACE_URL`, default the production + * catalog) and merged with the live install state — install status is always + * detected from the local records, never from the catalog. + * + * **Action suffix**: `:enable` / `:disable` / `:remove` via `parseActionSuffix` + * (bare ids rejected). + * + * **Error mapping**: + * - unknown plugin id → `40419 plugin.not_found` (from the domain code) + * - malformed `{tail}` / body → `40001 validation.failed` + * - catalog unreachable/invalid → `50001` with a plain-language message + * - other errors → `50001` via the global error handler + */ + +import { IPluginService, PluginErrors, 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 { + 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; + +const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; + +const rawMarketplaceSchema = z.object({ + plugins: z.array( + z.object({ + id: z.string().min(1), + tier: z.enum(['official', 'curated']).optional(), + displayName: z.string().optional(), + description: z.string().optional(), + homepage: z.string().optional(), + keywords: z.array(z.string()).optional(), + version: z.string().optional(), + source: z.string().min(1), + }), + ), +}); + +/** Strict `x.y.z` numeric comparison (no prerelease); avoids a semver dep. */ +function semverGt(a: string, b: string): boolean { + const parse = (v: string): number[] | undefined => { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v); + return m === null ? undefined : [Number(m[1]), Number(m[2]), Number(m[3])]; + }; + const pa = parse(a); + const pb = parse(b); + if (pa === undefined || pb === undefined) return false; + for (let i = 0; i < 3; i += 1) { + if (pa[i]! > pb[i]!) return true; + if (pa[i]! < pb[i]!) return false; + } + return false; +} + +export interface PluginsRouteOptions { + /** Resolved catalog URL (server option / env already applied by start.ts). */ + readonly marketplaceUrl: string; + 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 ?? fetch; + let raw: unknown; + try { + const resp = await fetchImpl(opts.marketplaceUrl, { + signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + raw = await resp.json(); + } catch (error) { + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `Plugin marketplace is unreachable: ${error instanceof Error ? error.message : String(error)}`, + req.id, + ), + ); + return; + } + const parsed = rawMarketplaceSchema.safeParse(raw); + if (!parsed.success) { + reply.send( + errEnvelope(ErrorCode.INTERNAL_ERROR, 'Plugin marketplace returned an invalid catalog', req.id), + ); + return; + } + const installed = await core.accessor.get(IPluginService).listPlugins(); + const byId = new Map(installed.map((p) => [p.id, p])); + const entries: PluginMarketplaceEntryWire[] = parsed.data.plugins.map((entry) => { + const record = byId.get(entry.id); + const installedInfo = + record === undefined + ? undefined + : { + enabled: record.enabled, + ...(record.version !== undefined ? { version: record.version } : {}), + }; + const updateAvailable = + entry.version !== undefined && + record?.version !== undefined && + semverGt(entry.version, record.version); + return { + id: entry.id, + tier: entry.tier ?? 'third-party', + displayName: entry.displayName ?? entry.id, + ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(entry.homepage !== undefined ? { homepage: entry.homepage } : {}), + ...(entry.keywords !== undefined ? { keywords: entry.keywords } : {}), + ...(entry.version !== undefined ? { version: entry.version } : {}), + source: entry.source, + ...(installedInfo !== undefined ? { installed: installedInfo } : {}), + ...(updateAvailable ? { updateAvailable: true } : {}), + }; + }); + 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]: {}, + }, + 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], + ); +} + +function mapPluginError(error: unknown, requestId: string) { + if (isError2(error) && error.code === PluginErrors.codes.PLUGIN_NOT_FOUND) { + return errEnvelope(ErrorCode.PLUGIN_NOT_FOUND, 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..e0d2d8b122 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,8 @@ 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; /** * Surface `dangerous_bypass_auth` in the `/meta` payload. Set by `start.ts` * from the `disableAuth` server option (the `--dangerous-bypass-auth` CLI @@ -132,6 +136,13 @@ 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, + }); 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..5ee680c519 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -100,10 +100,19 @@ export interface ServerHostIdentity extends KimiHostIdentity { readonly replyStyleGuide?: string; } +/** Default plugin marketplace catalog (overridable per server option or env). */ +const DEFAULT_PLUGIN_MARKETPLACE_URL = 'https://code.kimi.com/kimi-code/plugins/marketplace.json'; + 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 @@ -504,6 +513,10 @@ export async function startServer(opts: ServerStartOptions): Promise { void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); }, 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 3bc3a036e6..0453c8faab 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..1fa9220460 --- /dev/null +++ b/packages/kap-server/test/capabilities.test.ts @@ -0,0 +1,135 @@ +/** + * `/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 a non-macOS host → 40923 + * + * 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 is macOS-only. + const kimiCu = parsed.capabilities.find((c) => c.id === 'kimi-cu'); + if (process.platform === 'darwin') { + 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); + }); + + it.skipIf(process.platform === 'darwin')( + 'rejects kimi-cu install on non-macOS with 40923', + async () => { + const { body } = await postJson('/api/v1/capabilities/kimi-cu:install'); + expect(body.code).toBe(40923); + }, + ); +}); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts new file mode 100644 index 0000000000..a4a810d7af --- /dev/null +++ b/packages/kap-server/test/plugins.test.ts @@ -0,0 +1,207 @@ +/** + * `/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 + * - 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +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; +} + +const CATALOG_URL = 'http://marketplace.test/marketplace.json'; + +const CATALOG = { + version: '1', + plugins: [ + { + id: 'demo-plugin', + tier: 'official', + displayName: 'Demo Plugin', + version: '2.0.0', + source: 'https://cdn.example.test/demo.zip', + }, + { + id: 'third-party-plugin', + displayName: 'Third Party', + source: 'https://github.com/example/third', + }, + ], +}; + +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 }); + } + 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(); + 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. + ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}), + } 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('serves the marketplace catalog merged with live install state', async () => { + const before = await call<{ + entries: { id: string; tier: 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'], + ]); + expect(before.body.data.entries[0]?.installed).toBeUndefined(); + + // 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); + }); + + 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'); + }); +}); From daf02e7b70cb435e10b43919690e0fe983d34a5c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 02:17:58 +0800 Subject: [PATCH 03/46] fix(kap-server): map plugin input errors to 4xx and correct the unsupported test code - mapPluginError now translates the domain's validation.failed (40001) and fs.path_not_found (40409) instead of collapsing client-fixable input mistakes (relative source, nonexistent local path) into a 50001 internal error - the non-macOS capability install test expected 40923, which this branch assigns to capability.install_in_progress; the unsupported code is 40924 (macOS runners skip the case, which is why it only fails on Linux/Windows CI) --- packages/kap-server/src/routes/plugins.ts | 23 ++++++++++++++++--- packages/kap-server/test/capabilities.test.ts | 6 ++--- packages/kap-server/test/plugins.test.ts | 12 ++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 50c153ff03..41da65478b 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -19,12 +19,19 @@ * * **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 { IPluginService, PluginErrors, isError2, type Scope } from '@moonshot-ai/agent-core-v2'; +import { + ErrorCodes as DomainErrorCodes, + IPluginService, + PluginErrors, + isError2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; @@ -211,6 +218,7 @@ export function registerPluginsRoutes( 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'], @@ -284,9 +292,18 @@ export function registerPluginsRoutes( ); } +const PLUGIN_ERROR_MAP: Readonly> = { + [PluginErrors.codes.PLUGIN_NOT_FOUND]: ErrorCode.PLUGIN_NOT_FOUND, + // Client-fixable input mistakes (relative source, missing local path) keep + // their 4xx semantics instead of collapsing into a 50001. + [DomainErrorCodes.VALIDATION_FAILED]: ErrorCode.VALIDATION_FAILED, + [DomainErrorCodes.FS_PATH_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, +}; + function mapPluginError(error: unknown, requestId: string) { - if (isError2(error) && error.code === PluginErrors.codes.PLUGIN_NOT_FOUND) { - return errEnvelope(ErrorCode.PLUGIN_NOT_FOUND, error.message, requestId, error.stack); + 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, diff --git a/packages/kap-server/test/capabilities.test.ts b/packages/kap-server/test/capabilities.test.ts index 1fa9220460..0d266ccc4e 100644 --- a/packages/kap-server/test/capabilities.test.ts +++ b/packages/kap-server/test/capabilities.test.ts @@ -5,7 +5,7 @@ * - 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 a non-macOS host → 40923 + * - POST /api/v1/capabilities/kimi-cu:install on a non-macOS host → 40924 * * Real installs are never triggered from tests: the only `:install` calls * target an unknown id or an unsupported platform. `GET` runs the entries' @@ -126,10 +126,10 @@ describe('server-v2 /api/v1 capabilities', () => { }); it.skipIf(process.platform === 'darwin')( - 'rejects kimi-cu install on non-macOS with 40923', + 'rejects kimi-cu install on non-macOS with 40924', async () => { const { body } = await postJson('/api/v1/capabilities/kimi-cu:install'); - expect(body.code).toBe(40923); + expect(body.code).toBe(40924); }, ); }); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index a4a810d7af..a520ed2c33 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -6,6 +6,7 @@ * - 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 * @@ -162,6 +163,17 @@ describe('server-v2 /api/v1 plugins', () => { expect(badSource.body.code).toBe(40001); }); + 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); + }); + it('serves the marketplace catalog merged with live install state', async () => { const before = await call<{ entries: { id: string; tier: string; installed?: { version?: string } }[]; From 8adff36d75effb9925a90ffd4c189eb84fe29e8c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 02:29:03 +0800 Subject: [PATCH 04/46] fix(kap-server): resolve catalog-relative marketplace sources and widen the unsupported-test skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The production CDN catalog carries sources relative to the catalog URL (./official/*.zip); clients handing them back to POST /plugins would hit the local-path normalizer's 40001. Resolve entry sources against the configured catalog URL so every returned source is directly installable. - The 40924 install-rejection test only skipped macOS, but kimi-cu is also supported on Windows x64 — running it there would start the real installer. Skip on every supported platform. --- packages/kap-server/src/routes/plugins.ts | 22 +++++++++++++++++-- packages/kap-server/test/capabilities.test.ts | 9 +++++--- packages/kap-server/test/plugins.test.ts | 12 +++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 41da65478b..502dfd1ddd 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -12,7 +12,9 @@ * fetched on demand from the configured URL (`pluginMarketplaceUrl` server * option, env `KIMI_CODE_PLUGIN_MARKETPLACE_URL`, default the production * catalog) and merged with the live install state — install status is always - * detected from the local records, never from the catalog. + * detected from the local records, never from the catalog. Catalog-relative + * sources (`./official/*.zip`) are resolved against the catalog URL so the + * returned `source` is directly installable. * * **Action suffix**: `:enable` / `:disable` / `:remove` via `parseActionSuffix` * (bare ids rejected). @@ -101,6 +103,22 @@ function semverGt(a: string, b: string): boolean { return false; } +/** + * Catalog sources may be relative to the catalog URL (the production CDN + * catalog uses `./official/*.zip`). Clients hand `source` back to + * `POST /plugins`, whose normalizer rejects non-absolute paths — resolve + * against the catalog URL so every returned source is directly installable. + */ +function resolveEntrySource(source: string, marketplaceUrl: string): string { + if (/^https?:\/\//.test(source)) return source; + if (!/^https?:\/\//.test(marketplaceUrl)) return source; + try { + return new URL(source, marketplaceUrl).href; + } catch { + return source; + } +} + export interface PluginsRouteOptions { /** Resolved catalog URL (server option / env already applied by start.ts). */ readonly marketplaceUrl: string; @@ -173,7 +191,7 @@ export function registerPluginsRoutes( ...(entry.homepage !== undefined ? { homepage: entry.homepage } : {}), ...(entry.keywords !== undefined ? { keywords: entry.keywords } : {}), ...(entry.version !== undefined ? { version: entry.version } : {}), - source: entry.source, + source: resolveEntrySource(entry.source, opts.marketplaceUrl), ...(installedInfo !== undefined ? { installed: installedInfo } : {}), ...(updateAvailable ? { updateAvailable: true } : {}), }; diff --git a/packages/kap-server/test/capabilities.test.ts b/packages/kap-server/test/capabilities.test.ts index 0d266ccc4e..5ff19ef79c 100644 --- a/packages/kap-server/test/capabilities.test.ts +++ b/packages/kap-server/test/capabilities.test.ts @@ -5,7 +5,8 @@ * - 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 a non-macOS host → 40924 + * - POST /api/v1/capabilities/kimi-cu:install on an unsupported host → 40924 + * (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' @@ -125,8 +126,10 @@ describe('server-v2 /api/v1 capabilities', () => { expect(bogus.body.code).toBe(40001); }); - it.skipIf(process.platform === 'darwin')( - 'rejects kimi-cu install on non-macOS with 40924', + // 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 40924', async () => { const { body } = await postJson('/api/v1/capabilities/kimi-cu:install'); expect(body.code).toBe(40924); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index a520ed2c33..6ec623fdac 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -48,6 +48,12 @@ const CATALOG = { 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', + }, ], }; @@ -176,14 +182,18 @@ describe('server-v2 /api/v1 plugins', () => { it('serves the marketplace catalog merged with live install state', async () => { const before = await call<{ - entries: { id: string; tier: string; installed?: { version?: string } }[]; + entries: { id: string; tier: string; source: 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'], ]); 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'); // Install an older version than the catalog → updateAvailable. const source = await makePluginDir('demo-plugin', '1.0.0'); From 145cddb96605d59562e1ae55b053426ed33ee955 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 02:40:18 +0800 Subject: [PATCH 05/46] fix(kap-server): accept the legacy url/downloadUrl marketplace source aliases Custom catalogs that the CLI already accepts can carry an entry's source under url or downloadUrl instead of source; the route's strict schema rejected the whole catalog with 50001. Normalize the aliases before validation (same precedence as the CLI parser) so those catalogs keep working through /api/v1/plugins/marketplace. --- packages/kap-server/src/routes/plugins.ts | 36 +++++++++++++++-------- packages/kap-server/test/plugins.test.ts | 10 +++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 502dfd1ddd..15eb451b13 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -72,19 +72,31 @@ const PLUGIN_ACTIONS = ['enable', 'disable', 'remove'] as const; const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; +// Custom catalogs accepted by the CLI may carry the source under the legacy +// `url` / `downloadUrl` aliases — normalize before validating so a catalog +// that works in the CLI works here too. +const rawMarketplaceEntrySchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const record = value as Record; + if (record['source'] !== undefined) return value; + const alias = record['url'] ?? record['downloadUrl']; + return typeof alias === 'string' ? { ...record, source: alias } : value; + }, + z.object({ + id: z.string().min(1), + tier: z.enum(['official', 'curated']).optional(), + displayName: z.string().optional(), + description: z.string().optional(), + homepage: z.string().optional(), + keywords: z.array(z.string()).optional(), + version: z.string().optional(), + source: z.string().min(1), + }), +); + const rawMarketplaceSchema = z.object({ - plugins: z.array( - z.object({ - id: z.string().min(1), - tier: z.enum(['official', 'curated']).optional(), - displayName: z.string().optional(), - description: z.string().optional(), - homepage: z.string().optional(), - keywords: z.array(z.string()).optional(), - version: z.string().optional(), - source: z.string().min(1), - }), - ), + plugins: z.array(rawMarketplaceEntrySchema), }); /** Strict `x.y.z` numeric comparison (no prerelease); avoids a semver dep. */ diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 6ec623fdac..3720c45641 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -54,6 +54,12 @@ const CATALOG = { displayName: 'Relative', source: './plugins/relative.zip', }, + { + // Legacy `url` alias (accepted by the CLI parser). + id: 'alias-plugin', + displayName: 'Alias', + url: './plugins/alias.zip', + }, ], }; @@ -189,11 +195,15 @@ describe('server-v2 /api/v1 plugins', () => { ['demo-plugin', 'official'], ['third-party-plugin', 'third-party'], ['relative-plugin', 'third-party'], + ['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'); // Install an older version than the catalog → updateAvailable. const source = await makePluginDir('demo-plugin', '1.0.0'); From 1f9a5744a5edefc3b4a54bf1370065d700c08d49 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 02:54:22 +0800 Subject: [PATCH 06/46] fix(kap-server): support local marketplace catalogs and drop conditional spreads - KIMI_CODE_PLUGIN_MARKETPLACE_URL accepts a plain path or file:// catalog in the CLI loader; the route only fetched over HTTP, so local catalogs 50001'd for desktop/web hosts. Read local catalogs from disk and resolve their relative sources against the catalog's directory. - Replace the marketplace mapping's conditional spreads with direct possibly-undefined properties per the repo rule. --- packages/kap-server/src/routes/plugins.ts | 80 +++++++++++++++-------- packages/kap-server/test/plugins.test.ts | 35 ++++++++++ 2 files changed, 86 insertions(+), 29 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 15eb451b13..8a28611e91 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -9,12 +9,13 @@ * 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 - * fetched on demand from the configured URL (`pluginMarketplaceUrl` server + * read on demand from the configured location (`pluginMarketplaceUrl` server * option, env `KIMI_CODE_PLUGIN_MARKETPLACE_URL`, default the production - * catalog) and merged with the live install state — install status is always - * detected from the local records, never from the catalog. Catalog-relative - * sources (`./official/*.zip`) are resolved against the catalog URL so the - * returned `source` is directly installable. + * catalog; plain paths and `file://` URLs read from disk like the CLI loader) + * and merged with the live install state — install status is always detected + * from the local records, never from the catalog. Catalog-relative sources + * (`./official/*.zip`) resolve against the catalog location so the returned + * `source` is directly installable. * * **Action suffix**: `:enable` / `:disable` / `:remove` via `parseActionSuffix` * (bare ids rejected). @@ -34,6 +35,10 @@ import { isError2, type Scope, } from '@moonshot-ai/agent-core-v2'; +import { readFile } from 'node:fs/promises'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; @@ -116,19 +121,44 @@ function semverGt(a: string, b: string): boolean { } /** - * Catalog sources may be relative to the catalog URL (the production CDN + * Catalog sources may be relative to the catalog location (the production CDN * catalog uses `./official/*.zip`). Clients hand `source` back to * `POST /plugins`, whose normalizer rejects non-absolute paths — resolve - * against the catalog URL so every returned source is directly installable. + * against the catalog URL (or, for a local catalog, its directory) so every + * returned source is directly installable. */ function resolveEntrySource(source: string, marketplaceUrl: string): string { - if (/^https?:\/\//.test(source)) return source; - if (!/^https?:\/\//.test(marketplaceUrl)) return source; - try { - return new URL(source, marketplaceUrl).href; - } catch { - return source; + if (/^https?:\/\//.test(source) || isAbsolute(source)) return source; + if (/^https?:\/\//.test(marketplaceUrl)) { + try { + return new URL(source, marketplaceUrl).href; + } catch { + return source; + } + } + const catalogPath = marketplaceUrl.startsWith('file://') + ? fileURLToPath(marketplaceUrl) + : marketplaceUrl; + return resolve(dirname(catalogPath), source); +} + +/** + * Read the raw marketplace catalog JSON. Remote catalogs go through fetch; + * local catalogs (plain path or `file://`, both accepted by the CLI loader) + * are read from disk so the same custom catalog works for desktop/web hosts. + */ +async function readMarketplaceCatalog(opts: PluginsRouteOptions): Promise { + const location = opts.marketplaceUrl; + if (!/^https?:\/\//.test(location)) { + const catalogPath = location.startsWith('file://') ? fileURLToPath(location) : location; + return JSON.parse(await readFile(catalogPath, 'utf8')); } + const fetchImpl = opts.fetchImpl ?? fetch; + const resp = await fetchImpl(location, { + signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); } export interface PluginsRouteOptions { @@ -155,14 +185,9 @@ export function registerPluginsRoutes( operationId: 'listPluginMarketplace', }, async (req, reply) => { - const fetchImpl = opts.fetchImpl ?? fetch; let raw: unknown; try { - const resp = await fetchImpl(opts.marketplaceUrl, { - signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS), - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - raw = await resp.json(); + raw = await readMarketplaceCatalog(opts); } catch (error) { reply.send( errEnvelope( @@ -187,10 +212,7 @@ export function registerPluginsRoutes( const installedInfo = record === undefined ? undefined - : { - enabled: record.enabled, - ...(record.version !== undefined ? { version: record.version } : {}), - }; + : { enabled: record.enabled, version: record.version }; const updateAvailable = entry.version !== undefined && record?.version !== undefined && @@ -199,13 +221,13 @@ export function registerPluginsRoutes( id: entry.id, tier: entry.tier ?? 'third-party', displayName: entry.displayName ?? entry.id, - ...(entry.description !== undefined ? { description: entry.description } : {}), - ...(entry.homepage !== undefined ? { homepage: entry.homepage } : {}), - ...(entry.keywords !== undefined ? { keywords: entry.keywords } : {}), - ...(entry.version !== undefined ? { version: entry.version } : {}), + description: entry.description, + homepage: entry.homepage, + keywords: entry.keywords, + version: entry.version, source: resolveEntrySource(entry.source, opts.marketplaceUrl), - ...(installedInfo !== undefined ? { installed: installedInfo } : {}), - ...(updateAvailable ? { updateAvailable: true } : {}), + installed: installedInfo, + updateAvailable: updateAvailable ? true : undefined, }; }); reply.send(okEnvelope({ entries }, req.id)); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 3720c45641..9eaa840eb0 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -236,4 +236,39 @@ describe('server-v2 /api/v1 plugins', () => { 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); + await writeFile( + join(catalogDir, 'marketplace.json'), + JSON.stringify({ plugins: [{ id: 'local-plugin', source: './zips/local.zip' }] }), + ); + 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'), + }, + ]); + }); }); From b4762f4c7dbdebcad22179170dc104c64462e989 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 03:07:24 +0800 Subject: [PATCH 07/46] fix: surface capability install notes through klient and convert file:// entry sources - The klient capabilities contract omitted install.note, so zod parsing stripped it and facade callers (node-sdk, TUI) never saw 'user-skill-migrated'. Add the field and pin it in the facade test fixture. - A marketplace entry source given as a file:// URL fell through to the relative-branch and came back as a garbage path; convert with fileURLToPath so the advertised source stays installable. --- packages/kap-server/src/routes/plugins.ts | 6 +++++- packages/kap-server/test/plugins.test.ts | 14 +++++++++++++- .../klient/src/contract/global/capabilities.ts | 1 + packages/klient/test/facade.test.ts | 3 ++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 8a28611e91..17d770ec2f 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -128,7 +128,11 @@ function semverGt(a: string, b: string): boolean { * returned source is directly installable. */ function resolveEntrySource(source: string, marketplaceUrl: string): string { - if (/^https?:\/\//.test(source) || isAbsolute(source)) return source; + if (/^https?:\/\//.test(source)) return source; + // `file://` entry sources convert to filesystem paths up front — the + // install normalizer only accepts http(s) or absolute local paths. + if (source.startsWith('file://')) return fileURLToPath(source); + if (isAbsolute(source)) return source; if (/^https?:\/\//.test(marketplaceUrl)) { try { return new URL(source, marketplaceUrl).href; diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 9eaa840eb0..71e8b13a4b 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -244,7 +244,12 @@ describe('server-v2 /api/v1 plugins', () => { createdDirs.push(catalogDir); await writeFile( join(catalogDir, 'marketplace.json'), - JSON.stringify({ plugins: [{ id: 'local-plugin', source: './zips/local.zip' }] }), + JSON.stringify({ + plugins: [ + { id: 'local-plugin', source: './zips/local.zip' }, + { id: 'file-url-plugin', source: 'file:///abs/plugins/file.zip' }, + ], + }), ); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, @@ -269,6 +274,13 @@ describe('server-v2 /api/v1 plugins', () => { // 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: '/abs/plugins/file.zip', + }, ]); }); }); 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 8c76e8cf9a..b34d59178e 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]; From d964617856b79e9d65b439802703b5b7b50f72e3 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 03:14:07 +0800 Subject: [PATCH 08/46] test(kap-server): keep the new route tests portable to Windows x64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The capabilities list assertion treated every non-macOS host as unsupported, but kimi-cu is supported on Windows x64 — derive the expectation from the same platform predicate. - file:///abs/... is not a valid absolute file URL on Windows (no drive root); build the fixture with pathToFileURL from a temp path instead. --- packages/kap-server/test/capabilities.test.ts | 4 ++-- packages/kap-server/test/plugins.test.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/kap-server/test/capabilities.test.ts b/packages/kap-server/test/capabilities.test.ts index 5ff19ef79c..30ab905d74 100644 --- a/packages/kap-server/test/capabilities.test.ts +++ b/packages/kap-server/test/capabilities.test.ts @@ -88,9 +88,9 @@ describe('server-v2 /api/v1 capabilities', () => { expect(capabilityStatusSchema.parse(capability)).toBeTruthy(); expect(capability.install.running).toBe(false); } - // Platform-gated entry: kimi-cu is macOS-only. + // 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') { + if (process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64')) { expect(kimiCu?.supported).toBe(true); } else { expect(kimiCu?.supported).toBe(false); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 71e8b13a4b..4cbb966b57 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -17,6 +17,7 @@ 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'; @@ -242,12 +243,14 @@ describe('server-v2 /api/v1 plugins', () => { 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' }, - { id: 'file-url-plugin', source: 'file:///abs/plugins/file.zip' }, + // Portable absolute file URL (drive-rooted on Windows). + { id: 'file-url-plugin', source: pathToFileURL(fileUrlPluginPath).href }, ], }), ); @@ -279,7 +282,7 @@ describe('server-v2 /api/v1 plugins', () => { tier: 'third-party', displayName: 'file-url-plugin', // file:// sources convert to plain absolute paths (installable). - source: '/abs/plugins/file.zip', + source: fileUrlPluginPath, }, ]); }); From 6528c8a8363cda155a0dc0e0672714bce7bd7339 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 03:25:45 +0800 Subject: [PATCH 09/46] refactor: align the capability note and test helper with repo conventions - agent-core-v2 keeps explanatory docs in the top-of-file block only; the note contract already lives in the capability types header, so drop the two member-level doc blocks. - The plugins route test helper sets the optional fetch body directly instead of via a conditional spread. --- packages/agent-core-v2/src/app/capability/types.ts | 10 ---------- packages/kap-server/test/plugins.test.ts | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/app/capability/types.ts b/packages/agent-core-v2/src/app/capability/types.ts index 037f7ea33a..b647289f02 100644 --- a/packages/agent-core-v2/src/app/capability/types.ts +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -27,12 +27,6 @@ export interface CapabilityInstallProgress { readonly step?: string; readonly percent?: number; readonly error?: string; - /** - * Machine-key note from the last completed install (e.g. - * 'user-skill-migrated' — a pre-existing user-source skill was replaced by - * the plugin-managed copy). Clients localize it; cleared on the next - * attempt. - */ readonly note?: string; } @@ -63,9 +57,5 @@ export interface CapabilityEntry { readonly description: string; readonly supported: boolean; detect(): Promise; - /** - * Resolves with an optional machine-key note surfaced through - * `CapabilityInstallProgress.note` (e.g. 'user-skill-migrated'). - */ install(report: CapabilityInstallReporter): Promise; } diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 4cbb966b57..7e915866d8 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -117,7 +117,7 @@ describe('server-v2 /api/v1 plugins', () => { method, headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), // A JSON content-type with an empty body is rejected by Fastify. - ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}), + body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined, } as never); return { status: res.status, body: (await res.json()) as Envelope }; } From 02328499f40b55f2422e9671e1c2acf585dc09de Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 03:37:13 +0800 Subject: [PATCH 10/46] fix(kap-server): expand ~ in local marketplace catalog paths The CLI loader expands ~/ against the home directory; the route read the path literally, so KIMI_CODE_PLUGIN_MARKETPLACE_URL=~/catalog.json 50001'd for desktop/web hosts while working in the CLI. Share one localCatalogPath helper (file:// conversion + tilde expansion) between the catalog read and the relative-source resolver. --- packages/kap-server/src/routes/plugins.ts | 22 ++++++++++++------ packages/kap-server/test/plugins.test.ts | 28 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 17d770ec2f..a5921b9d7a 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -36,7 +36,8 @@ import { type Scope, } from '@moonshot-ai/agent-core-v2'; import { readFile } from 'node:fs/promises'; -import { dirname, isAbsolute, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { z } from 'zod'; @@ -140,10 +141,18 @@ function resolveEntrySource(source: string, marketplaceUrl: string): string { return source; } } - const catalogPath = marketplaceUrl.startsWith('file://') - ? fileURLToPath(marketplaceUrl) - : marketplaceUrl; - return resolve(dirname(catalogPath), source); + return resolve(dirname(localCatalogPath(marketplaceUrl)), source); +} + +/** + * Local catalog location → filesystem path: `file://` conversion plus the + * same `~` / `~/` home expansion the CLI loader applies. + */ +function localCatalogPath(location: string): string { + const raw = location.startsWith('file://') ? fileURLToPath(location) : location; + if (raw === '~') return homedir(); + if (raw.startsWith('~/')) return join(homedir(), raw.slice(2)); + return raw; } /** @@ -154,8 +163,7 @@ function resolveEntrySource(source: string, marketplaceUrl: string): string { async function readMarketplaceCatalog(opts: PluginsRouteOptions): Promise { const location = opts.marketplaceUrl; if (!/^https?:\/\//.test(location)) { - const catalogPath = location.startsWith('file://') ? fileURLToPath(location) : location; - return JSON.parse(await readFile(catalogPath, 'utf8')); + return JSON.parse(await readFile(localCatalogPath(location), 'utf8')); } const fetchImpl = opts.fetchImpl ?? fetch; const resp = await fetchImpl(location, { diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 7e915866d8..6bc262d654 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -95,6 +95,7 @@ describe('server-v2 /api/v1 plugins', () => { afterEach(async () => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); if (server !== undefined) { await server.close(); server = undefined; @@ -286,4 +287,31 @@ describe('server-v2 /api/v1 plugins', () => { }, ]); }); + + 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' }] }), + ); + vi.stubEnv('HOME', 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 }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + expect(body.data.entries.map((e) => e.id)).toEqual(['tilde-plugin']); + }); }); From 89bd5595d0f9e8040df5e2877b0a6c3be20636e6 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 03:48:35 +0800 Subject: [PATCH 11/46] fix(kap-server): expand home-relative marketplace entry sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catalog entry with source '~/...' fell through to the catalog-relative branch and came back as /~/... — unresolvable by POST /plugins. Expand ~ via the shared helper before the absolute/relative decision. --- packages/kap-server/src/routes/plugins.ts | 26 ++++++++++++++--------- packages/kap-server/test/plugins.test.ts | 13 +++++++++--- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index a5921b9d7a..11705f5c71 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -133,26 +133,32 @@ function resolveEntrySource(source: string, marketplaceUrl: string): string { // `file://` entry sources convert to filesystem paths up front — the // install normalizer only accepts http(s) or absolute local paths. if (source.startsWith('file://')) return fileURLToPath(source); - if (isAbsolute(source)) return source; + // Home-relative entry sources expand before any absolute/relative decision. + const expanded = expandHome(source); + if (isAbsolute(expanded)) return expanded; if (/^https?:\/\//.test(marketplaceUrl)) { try { - return new URL(source, marketplaceUrl).href; + return new URL(expanded, marketplaceUrl).href; } catch { - return source; + return expanded; } } - return resolve(dirname(localCatalogPath(marketplaceUrl)), source); + return resolve(dirname(localCatalogPath(marketplaceUrl)), expanded); +} + +/** `~` / `~/` home expansion, same as the CLI loader's resolveLocalPath. */ +function expandHome(input: string): string { + if (input === '~') return homedir(); + if (input.startsWith('~/')) return join(homedir(), input.slice(2)); + return input; } /** - * Local catalog location → filesystem path: `file://` conversion plus the - * same `~` / `~/` home expansion the CLI loader applies. + * Local catalog location → filesystem path: `file://` conversion plus home + * expansion. */ function localCatalogPath(location: string): string { - const raw = location.startsWith('file://') ? fileURLToPath(location) : location; - if (raw === '~') return homedir(); - if (raw.startsWith('~/')) return join(homedir(), raw.slice(2)); - return raw; + return expandHome(location.startsWith('file://') ? fileURLToPath(location) : location); } /** diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 6bc262d654..7e23c0def4 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -294,7 +294,13 @@ describe('server-v2 /api/v1 plugins', () => { createdDirs.push(fakeHome); await writeFile( join(fakeHome, 'marketplace.json'), - JSON.stringify({ plugins: [{ id: 'tilde-plugin', source: 'https://example.test/t.zip' }] }), + 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' }, + ], + }), ); vi.stubEnv('HOME', fakeHome); server = await startServer({ @@ -307,11 +313,12 @@ describe('server-v2 /api/v1 plugins', () => { }); base = `http://127.0.0.1:${server.port}`; - const { body } = await call<{ entries: { id: string }[] }>( + 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']); + 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')); }); }); From 10fdf34a94d534b776a17c6327c319c244786c4d Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 03:59:57 +0800 Subject: [PATCH 12/46] fix(kap-server): match CLI field semantics for source aliases and stub the Windows home - A blank or non-string source no longer shadows the url/downloadUrl aliases; the first valid (non-blank, trimmed) of source/url/downloadUrl wins, mirroring the CLI parser's stringField. - The tilde test also stubs USERPROFILE so os.homedir() resolves to the fixture home on Windows runners. --- packages/kap-server/src/routes/plugins.ts | 9 ++++++--- packages/kap-server/test/plugins.test.ts | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 11705f5c71..ec9a70fd6d 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -85,9 +85,12 @@ const rawMarketplaceEntrySchema = z.preprocess( (value) => { if (typeof value !== 'object' || value === null) return value; const record = value as Record; - if (record['source'] !== undefined) return value; - const alias = record['url'] ?? record['downloadUrl']; - return typeof alias === 'string' ? { ...record, source: alias } : value; + // CLI stringField semantics: non-string or blank counts as missing, and + // the first valid of source / url / downloadUrl wins (trimmed). + const pick = (v: unknown) => + typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined; + const source = pick(record['source']) ?? pick(record['url']) ?? pick(record['downloadUrl']); + return source === undefined ? value : { ...record, source }; }, z.object({ id: z.string().min(1), diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 7e23c0def4..8cb02e26da 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -56,9 +56,11 @@ const CATALOG = { source: './plugins/relative.zip', }, { - // Legacy `url` alias (accepted by the CLI parser). + // 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', }, ], @@ -302,7 +304,9 @@ describe('server-v2 /api/v1 plugins', () => { ], }), ); + // 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', From a39c400c91186f63bd5fae6df58dda0199df0c9c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 04:11:44 +0800 Subject: [PATCH 13/46] fix(kap-server): read a blank marketplace tier as missing The CLI parser trims tier and treats a blank as absent (third-party); the route's enum rejected the whole catalog with 50001. Normalize the tier alongside the source aliases in the same preprocess. --- packages/kap-server/src/routes/plugins.ts | 8 +++++++- packages/kap-server/test/plugins.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index ec9a70fd6d..cc722d2e5b 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -89,8 +89,14 @@ const rawMarketplaceEntrySchema = z.preprocess( // the first valid of source / url / downloadUrl wins (trimmed). const pick = (v: unknown) => typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined; + // A blank tier means "missing" (third-party), same as the CLI parser. + const tier = pick(record['tier']); const source = pick(record['source']) ?? pick(record['url']) ?? pick(record['downloadUrl']); - return source === undefined ? value : { ...record, source }; + const normalized: Record = { ...record }; + if (tier === undefined) delete normalized['tier']; + else normalized['tier'] = tier; + if (source !== undefined) normalized['source'] = source; + return normalized; }, z.object({ id: z.string().min(1), diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 8cb02e26da..6e2cd0b1b5 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -63,6 +63,13 @@ const CATALOG = { 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', + }, ], }; @@ -200,6 +207,7 @@ describe('server-v2 /api/v1 plugins', () => { ['third-party-plugin', 'third-party'], ['relative-plugin', 'third-party'], ['alias-plugin', 'third-party'], + ['blank-tier-plugin', 'third-party'], ]); expect(before.body.data.entries[0]?.installed).toBeUndefined(); // Catalog-relative sources resolve against the catalog URL. From 0ffd3c2e953c20f5868a9569301bc31fb5f395a5 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 04:23:05 +0800 Subject: [PATCH 14/46] fix(kap-server): derive marketplace versions from GitHub release sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries that omit version but encode it in a GitHub release/tag (or tree/commit) source never surfaced updateAvailable. Derive the version from the resolved source — same URL shapes as the CLI parser, validated with the route's strict x.y.z rule (no semver dependency). --- packages/kap-server/src/routes/plugins.ts | 38 ++++++++++++++++++++--- packages/kap-server/test/plugins.test.ts | 25 ++++++++++++++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index cc722d2e5b..dd6ef7bc79 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -162,6 +162,32 @@ function expandHome(input: string): string { return input; } +/** + * Derive a version from a GitHub release/tree/commit source (same shapes as + * the CLI parser; strict `x.y.z`, no prerelease — mirrors `semverGt`). + */ +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 /^(\d+)\.(\d+)\.(\d+)$/.test(candidate) ? candidate : undefined; +} + /** * Local catalog location → filesystem path: `file://` conversion plus home * expansion. @@ -240,10 +266,14 @@ export function registerPluginsRoutes( record === undefined ? undefined : { enabled: record.enabled, version: record.version }; + const source = resolveEntrySource(entry.source, opts.marketplaceUrl); + // Entries may omit `version` and encode it in a GitHub release/tag + // source — derive it so update checks still fire (CLI parity). + const version = entry.version ?? deriveVersionFromGithubSource(source); const updateAvailable = - entry.version !== undefined && + version !== undefined && record?.version !== undefined && - semverGt(entry.version, record.version); + semverGt(version, record.version); return { id: entry.id, tier: entry.tier ?? 'third-party', @@ -251,8 +281,8 @@ export function registerPluginsRoutes( description: entry.description, homepage: entry.homepage, keywords: entry.keywords, - version: entry.version, - source: resolveEntrySource(entry.source, opts.marketplaceUrl), + version, + source, installed: installedInfo, updateAvailable: updateAvailable ? true : undefined, }; diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 6e2cd0b1b5..33f83ff499 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -70,6 +70,12 @@ const CATALOG = { tier: ' ', source: 'https://example.test/bt.zip', }, + { + // No version field; the GitHub release-tag source encodes it. + id: 'gh-plugin', + displayName: 'GH Plugin', + source: 'https://github.com/example/gh/releases/tag/v2.0.0', + }, ], }; @@ -199,7 +205,13 @@ describe('server-v2 /api/v1 plugins', () => { it('serves the marketplace catalog merged with live install state', async () => { const before = await call<{ - entries: { id: string; tier: string; source: string; installed?: { version?: string } }[]; + entries: { + id: string; + tier: string; + source: string; + version?: 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([ @@ -208,6 +220,7 @@ describe('server-v2 /api/v1 plugins', () => { ['relative-plugin', 'third-party'], ['alias-plugin', 'third-party'], ['blank-tier-plugin', 'third-party'], + ['gh-plugin', 'third-party'], ]); expect(before.body.data.entries[0]?.installed).toBeUndefined(); // Catalog-relative sources resolve against the catalog URL. @@ -216,6 +229,8 @@ describe('server-v2 /api/v1 plugins', () => { // 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'); // Install an older version than the catalog → updateAvailable. const source = await makePluginDir('demo-plugin', '1.0.0'); @@ -231,6 +246,14 @@ describe('server-v2 /api/v1 plugins', () => { 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('maps an unreachable marketplace to 50001', async () => { From e9589cca74bc7aeea76e4b9ce88b88c8813d562f Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 04:33:40 +0800 Subject: [PATCH 15/46] fix(kap-server): fail catalog validation on a source with no usable value A whitespace-only source with no valid alias passed z.string().min(1) untrimmed and resolved against the catalog URL into nonsense. Drop the key during normalization so the schema reports the entry as missing its source (same outcome as the CLI's 'must define source'). --- packages/kap-server/src/routes/plugins.ts | 3 +++ packages/kap-server/test/plugins.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index dd6ef7bc79..0a5fb52dfb 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -95,7 +95,10 @@ const rawMarketplaceEntrySchema = z.preprocess( const normalized: Record = { ...record }; if (tier === undefined) delete normalized['tier']; else normalized['tier'] = tier; + // A source with no valid value or alias must fail validation (not slip + // through as whitespace): drop the key so the schema reports it missing. if (source !== undefined) normalized['source'] = source; + else delete normalized['source']; return normalized; }, z.object({ diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 33f83ff499..b975b22683 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -256,6 +256,25 @@ describe('server-v2 /api/v1 plugins', () => { 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('maps an unreachable marketplace to 50001', async () => { const realFetch = globalThis.fetch; vi.stubGlobal( From a4e54c108d695d6f03c67309bbf26b89a1b6f0f9 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 04:47:25 +0800 Subject: [PATCH 16/46] fix(kap-server): resolve latest versions for bare GitHub marketplace entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A catalog row whose source is a bare GitHub repo (the production curated rows are shaped this way) kept version undefined, so updateAvailable never fired for exactly the entries most likely to update. Resolve the latest release tag through the /releases/latest redirect — the UI route, not the rate-limited API — same as the CLI, degrading to no version on any failure. --- packages/kap-server/src/routes/plugins.ts | 66 +++++++++++++++++++++-- packages/kap-server/test/plugins.test.ts | 15 ++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 0a5fb52dfb..869e5b8fff 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -191,6 +191,51 @@ function deriveVersionFromGithubSource(source: string): string | undefined { return /^(\d+)\.(\d+)\.(\d+)$/.test(candidate) ? candidate : undefined; } +/** + * Bare-repo GitHub sources carry no version — resolve the latest release tag + * through the `/releases/latest` redirect (a UI route, not the rate-limited + * API), same as the CLI. Lookups never fail the listing: any error degrades + * to no version. + */ +async function resolveLatestGithubRelease( + source: string, + fetchImpl: typeof fetch, +): Promise { + 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 — ref tails are already + // handled by deriveVersionFromGithubSource. + const segments = url.pathname.split('/').filter(Boolean); + if (segments.length !== 2) return undefined; + const [owner, repo] = segments; + try { + const resp = await fetchImpl(`https://github.com/${owner}/${repo}/releases/latest`, { + redirect: 'manual', + signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS), + }); + if (resp.status !== 301 && resp.status !== 302) return undefined; + const location = resp.headers.get('location'); + if (location === null) return undefined; + const tag = /\/releases\/tag\/([^/?#]+)/.exec(location)?.[1]; + if (tag === undefined) return undefined; + let decoded: string; + try { + decoded = decodeURIComponent(tag); + } catch { + decoded = tag; + } + const candidate = decoded.replace(/^v/i, ''); + return /^(\d+)\.(\d+)\.(\d+)$/.test(candidate) ? candidate : undefined; + } catch { + return undefined; + } +} + /** * Local catalog location → filesystem path: `file://` conversion plus home * expansion. @@ -261,18 +306,29 @@ export function registerPluginsRoutes( ); return; } + const fetchImpl = opts.fetchImpl ?? fetch; + // Resolve sources and versions up front (parallel; latest-release + // lookups for bare GitHub repos ride the shared per-call timeout). + const resolved = await Promise.all( + parsed.data.plugins.map(async (entry) => { + const source = resolveEntrySource(entry.source, opts.marketplaceUrl); + // Entries may omit `version`: derive it from a GitHub ref tail, or + // look up the latest release of a bare repo source (CLI parity). + const version = + entry.version ?? + deriveVersionFromGithubSource(source) ?? + (await resolveLatestGithubRelease(source, fetchImpl)); + return { entry, source, version }; + }), + ); const installed = await core.accessor.get(IPluginService).listPlugins(); const byId = new Map(installed.map((p) => [p.id, p])); - const entries: PluginMarketplaceEntryWire[] = parsed.data.plugins.map((entry) => { + const entries: PluginMarketplaceEntryWire[] = resolved.map(({ entry, source, version }) => { const record = byId.get(entry.id); const installedInfo = record === undefined ? undefined : { enabled: record.enabled, version: record.version }; - const source = resolveEntrySource(entry.source, opts.marketplaceUrl); - // Entries may omit `version` and encode it in a GitHub release/tag - // source — derive it so update checks still fire (CLI parity). - const version = entry.version ?? deriveVersionFromGithubSource(source); const updateAvailable = version !== undefined && record?.version !== undefined && diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index b975b22683..4588192a13 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -94,6 +94,16 @@ describe('server-v2 /api/v1 plugins', () => { 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); }), ); @@ -231,6 +241,11 @@ describe('server-v2 /api/v1 plugins', () => { 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', + ); // Install an older version than the catalog → updateAvailable. const source = await makePluginDir('demo-plugin', '1.0.0'); From bf37c2f6317363333f91c4af9bf8eae09dc464db Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 04:48:06 +0800 Subject: [PATCH 17/46] docs(kap-server): note the marketplace version resolution in the plugins route header --- packages/kap-server/src/routes/plugins.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 869e5b8fff..877c58ee47 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -15,7 +15,9 @@ * and merged with the live install state — install status is always detected * from the local records, never from the catalog. Catalog-relative sources * (`./official/*.zip`) resolve against the catalog location so the returned - * `source` is directly installable. + * `source` is directly installable. Entries without a `version` get one from + * a GitHub ref tail or the bare repo's latest release (CLI parity), which is + * what drives `updateAvailable`. * * **Action suffix**: `:enable` / `:disable` / `:remove` via `parseActionSuffix` * (bare ids rejected). From 7762fa18bbb0c319a2a6d1d7e6a340d2856214ea Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 05:03:13 +0800 Subject: [PATCH 18/46] feat(kap-server): mark capability wiring rows in the marketplace response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client following only /plugins/marketplace + POST /plugins would install a capability's wiring plugin without its binary runtime, with no wire-level way to tell. Entries whose id matches a capability's wiring plugin now carry capabilityId, so clients route them through /capabilities/{id}:install — the client-side routing pattern the CLI established (the upstream design that replaced the server-side hook). --- packages/kap-server/src/protocol/rest-plugin.ts | 6 ++++++ packages/kap-server/src/routes/plugins.ts | 14 ++++++++++++++ packages/kap-server/test/plugins.test.ts | 14 ++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/packages/kap-server/src/protocol/rest-plugin.ts b/packages/kap-server/src/protocol/rest-plugin.ts index b7f4a2ce00..d10e76bae8 100644 --- a/packages/kap-server/src/protocol/rest-plugin.ts +++ b/packages/kap-server/src/protocol/rest-plugin.ts @@ -54,6 +54,12 @@ export const pluginMarketplaceEntrySchema = z.object({ .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; diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 877c58ee47..d331e991e8 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -78,6 +78,19 @@ interface PluginsRouteHost { const PLUGIN_ACTIONS = ['enable', 'disable', 'remove'] as const; +/** + * Capability wiring plugin id → capability id. The capability registry is a + * closed set whose ids belong to the client/engine contract (mirrored by the + * klient schema; precedent: the CLI names the same set 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> = { + 'kimi-cu': 'kimi-cu', + 'kimi-cu-win': 'kimi-cu', + 'kimi-webbridge': 'kimi-webbridge', +}; + const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; // Custom catalogs accepted by the CLI may carry the source under the legacy @@ -346,6 +359,7 @@ export function registerPluginsRoutes( source, installed: installedInfo, updateAvailable: updateAvailable ? true : undefined, + capabilityId: CAPABILITY_ROW_IDS[entry.id], }; }); reply.send(okEnvelope({ entries }, req.id)); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 4588192a13..14e19bc8ca 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -76,6 +76,13 @@ const CATALOG = { displayName: 'GH Plugin', 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', + }, ], }; @@ -220,6 +227,7 @@ describe('server-v2 /api/v1 plugins', () => { tier: string; source: string; version?: string; + capabilityId?: string; installed?: { version?: string }; }[]; }>('GET', '/api/v1/plugins/marketplace'); @@ -231,6 +239,7 @@ describe('server-v2 /api/v1 plugins', () => { ['alias-plugin', 'third-party'], ['blank-tier-plugin', 'third-party'], ['gh-plugin', 'third-party'], + ['kimi-webbridge', 'third-party'], ]); expect(before.body.data.entries[0]?.installed).toBeUndefined(); // Catalog-relative sources resolve against the catalog URL. @@ -246,6 +255,11 @@ describe('server-v2 /api/v1 plugins', () => { expect(before.body.data.entries.find((e) => e.id === 'third-party-plugin')?.version).toBe( '3.1.0', ); + // Capability wiring plugins carry their capability id. + expect( + before.body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId, + ).toBe('kimi-webbridge'); + expect(before.body.data.entries.find((e) => e.id === 'demo-plugin')?.capabilityId).toBeUndefined(); // Install an older version than the catalog → updateAvailable. const source = await makePluginDir('demo-plugin', '1.0.0'); From a12ea8370ea065846b32851c51d58868ef463815 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 05:18:33 +0800 Subject: [PATCH 19/46] fix(kap-server): fall back to the source-checkout catalog for the default location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the marketplace location is the built-in default (no server option or env override) and the fetch fails, read the repo checkout's own plugins/marketplace.json — the CLI loader's behavior for offline source-checkout dev. An explicitly configured catalog still fails hard with 50001. Bundled installs have no checkout file, so the fallback simply never fires there. --- packages/kap-server/src/routes/plugins.ts | 37 ++++++++++++++++--- .../src/routes/registerApiV1Routes.ts | 3 ++ packages/kap-server/src/start.ts | 3 ++ packages/kap-server/test/plugins.test.ts | 36 ++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index d331e991e8..249e353e30 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -37,6 +37,7 @@ import { isError2, type Scope, } from '@moonshot-ai/agent-core-v2'; +import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -259,10 +260,22 @@ function localCatalogPath(location: string): string { return expandHome(location.startsWith('file://') ? fileURLToPath(location) : location); } +/** + * The repo checkout's own catalog — the CLI loader's fallback when the + * configured catalog is unreachable (offline / source-checkout dev). Absent + * in bundled installs, where the fallback simply never fires. + */ +function sourceCheckoutCatalogPath(): string | undefined { + const candidate = resolve(import.meta.dirname, '../../../../plugins/marketplace.json'); + return existsSync(candidate) ? candidate : undefined; +} + /** * Read the raw marketplace catalog JSON. Remote catalogs go through fetch; * local catalogs (plain path or `file://`, both accepted by the CLI loader) * are read from disk so the same custom catalog works for desktop/web hosts. + * A failed remote read falls back to the source checkout's catalog when one + * exists (CLI parity). */ async function readMarketplaceCatalog(opts: PluginsRouteOptions): Promise { const location = opts.marketplaceUrl; @@ -270,16 +283,30 @@ async function readMarketplaceCatalog(opts: PluginsRouteOptions): Promise[0], core, { marketplaceUrl: opts.pluginMarketplaceUrl, + marketplaceIsDefault: opts.pluginMarketplaceIsDefault, }); registerMessagesRoutes( apiV1 as unknown as Parameters[0], diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 5ee680c519..67b4f2387a 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -517,6 +517,9 @@ export async function startServer(opts: ServerStartOptions): Promise { void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); }, diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 14e19bc8ca..463828192f 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -369,6 +369,42 @@ describe('server-v2 /api/v1 plugins', () => { ]); }); + 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 }[] }>( + 'GET', + '/api/v1/plugins/marketplace', + ); + expect(body.code).toBe(0); + const datasource = body.data.entries.find((e) => e.id === 'kimi-datasource'); + expect(datasource?.source.endsWith(join('plugins', 'official', 'kimi-datasource'))).toBe(true); + }); + it('expands ~ in local catalog paths like the CLI loader', async () => { await server?.close(); const fakeHome = await mkdtemp(join(tmpdir(), 'kimi-tilde-home-')); From 1e5c7792151ef29bc295f933ec7026c6855f7ef9 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 05:29:14 +0800 Subject: [PATCH 20/46] fix(kap-server): resolve fallback catalog sources against the fallback file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readMarketplaceCatalog returned only the JSON, so entries from the source-checkout fallback resolved their relative sources against the (unreachable) CDN URL — coming back as unusable https paths instead of local directories. The reader now returns the location actually read, and source resolution uses it. --- packages/kap-server/src/routes/plugins.ts | 26 ++++++++++++++++------- packages/kap-server/test/plugins.test.ts | 2 ++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 249e353e30..75f00dbb11 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -277,10 +277,20 @@ function sourceCheckoutCatalogPath(): string | undefined { * A failed remote read falls back to the source checkout's catalog when one * exists (CLI parity). */ -async function readMarketplaceCatalog(opts: PluginsRouteOptions): Promise { +/** + * Read the raw marketplace catalog JSON plus the location it was actually + * read from — relative entry sources resolve against the latter (the + * source-checkout fallback serves local directory sources). + */ +async function readMarketplaceCatalog( + opts: PluginsRouteOptions, +): Promise<{ raw: unknown; location: string }> { const location = opts.marketplaceUrl; if (!/^https?:\/\//.test(location)) { - return JSON.parse(await readFile(localCatalogPath(location), 'utf8')); + return { + raw: JSON.parse(await readFile(localCatalogPath(location), 'utf8')), + location, + }; } const fetchImpl = opts.fetchImpl ?? fetch; try { @@ -288,12 +298,12 @@ async function readMarketplaceCatalog(opts: PluginsRouteOptions): Promise { - let raw: unknown; + let catalog: { raw: unknown; location: string }; try { - raw = await readMarketplaceCatalog(opts); + catalog = await readMarketplaceCatalog(opts); } catch (error) { reply.send( errEnvelope( @@ -341,7 +351,7 @@ export function registerPluginsRoutes( ); return; } - const parsed = rawMarketplaceSchema.safeParse(raw); + const parsed = rawMarketplaceSchema.safeParse(catalog.raw); if (!parsed.success) { reply.send( errEnvelope(ErrorCode.INTERNAL_ERROR, 'Plugin marketplace returned an invalid catalog', req.id), @@ -353,7 +363,7 @@ export function registerPluginsRoutes( // lookups for bare GitHub repos ride the shared per-call timeout). const resolved = await Promise.all( parsed.data.plugins.map(async (entry) => { - const source = resolveEntrySource(entry.source, opts.marketplaceUrl); + const source = resolveEntrySource(entry.source, catalog.location); // Entries may omit `version`: derive it from a GitHub ref tail, or // look up the latest release of a bare repo source (CLI parity). const version = diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 463828192f..3396f6b304 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -402,6 +402,8 @@ describe('server-v2 /api/v1 plugins', () => { ); 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); }); From e66c4b01b19c557de7759bab618d3d95350ad18c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 05:40:51 +0800 Subject: [PATCH 21/46] fix(kap-server): honor the CLI's marketplace metadata aliases Custom catalogs using name / shortDescription / websiteURL (accepted by the CLI parser) lost those fields to schema stripping, falling back to the entry id. Normalize the aliases in the same preprocess as the source/tier normalization. --- packages/kap-server/src/routes/plugins.ts | 25 ++++++++++++++++++----- packages/kap-server/test/plugins.test.ts | 17 +++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 75f00dbb11..2fd08ff8eb 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -105,12 +105,27 @@ const rawMarketplaceEntrySchema = z.preprocess( // the first valid of source / url / downloadUrl wins (trimmed). const pick = (v: unknown) => typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined; - // A blank tier means "missing" (third-party), same as the CLI parser. - const tier = pick(record['tier']); - const source = pick(record['source']) ?? pick(record['url']) ?? pick(record['downloadUrl']); const normalized: Record = { ...record }; - if (tier === undefined) delete normalized['tier']; - else normalized['tier'] = tier; + // Metadata: blank reads as missing, and the CLI parser's aliases are + // honored (name / shortDescription / websiteURL). + const metadataAliases = [ + ['displayName', 'name'], + ['description', 'shortDescription'], + ['homepage', 'websiteURL'], + ] as const; + for (const [field, alias] of metadataAliases) { + const value = pick(record[field]) ?? pick(record[alias]); + if (value === undefined) delete normalized[field]; + else normalized[field] = value; + } + // A blank tier means "missing" (third-party); a non-string tier keeps + // failing validation, matching the CLI parser's type error. + const tier = record['tier']; + if (typeof tier === 'string') { + if (tier.trim().length === 0) delete normalized['tier']; + else normalized['tier'] = tier.trim(); + } + const source = pick(record['source']) ?? pick(record['url']) ?? pick(record['downloadUrl']); // A source with no valid value or alias must fail validation (not slip // through as whitespace): drop the key so the schema reports it missing. if (source !== undefined) normalized['source'] = source; diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 3396f6b304..906d38a137 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -83,6 +83,14 @@ const CATALOG = { displayName: 'Kimi WebBridge', source: 'https://cdn.example.test/kimi-webbridge.zip', }, + { + // CLI metadata aliases: name / shortDescription / websiteURL. + id: 'meta-alias-plugin', + name: 'Meta Alias', + shortDescription: 'Aliased metadata', + websiteURL: 'https://example.test/meta', + source: 'https://example.test/meta.zip', + }, ], }; @@ -225,9 +233,12 @@ describe('server-v2 /api/v1 plugins', () => { entries: { id: string; tier: string; + displayName: string; source: string; version?: string; capabilityId?: string; + description?: string; + homepage?: string; installed?: { version?: string }; }[]; }>('GET', '/api/v1/plugins/marketplace'); @@ -240,6 +251,7 @@ describe('server-v2 /api/v1 plugins', () => { ['blank-tier-plugin', 'third-party'], ['gh-plugin', 'third-party'], ['kimi-webbridge', 'third-party'], + ['meta-alias-plugin', 'third-party'], ]); expect(before.body.data.entries[0]?.installed).toBeUndefined(); // Catalog-relative sources resolve against the catalog URL. @@ -260,6 +272,11 @@ describe('server-v2 /api/v1 plugins', () => { before.body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId, ).toBe('kimi-webbridge'); expect(before.body.data.entries.find((e) => e.id === 'demo-plugin')?.capabilityId).toBeUndefined(); + // 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'); // Install an older version than the catalog → updateAvailable. const source = await makePluginDir('demo-plugin', '1.0.0'); From 1eb6f0af8eda3980cec56e4e54674ea3b2928476 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 05:51:37 +0800 Subject: [PATCH 22/46] fix(kap-server): filter marketplace keywords instead of rejecting the catalog A keywords array with non-string or blank members failed the strict schema and took the whole catalog down with 50001. Normalize to the CLI parser's semantics: non-array reads as missing, arrays keep trimmed non-blank strings only. --- packages/kap-server/src/routes/plugins.ts | 14 ++++++++++++++ packages/kap-server/test/plugins.test.ts | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 2fd08ff8eb..70d421b812 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -125,6 +125,20 @@ const rawMarketplaceEntrySchema = z.preprocess( if (tier.trim().length === 0) delete normalized['tier']; else normalized['tier'] = tier.trim(); } + // Keywords keep only non-blank strings (a junk member never fails the + // catalog); a non-array value reads as missing — CLI stringArrayField + // semantics. + const keywords = record['keywords']; + if (keywords !== undefined) { + const kept = Array.isArray(keywords) + ? keywords + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + : []; + if (kept.length > 0) normalized['keywords'] = kept; + else delete normalized['keywords']; + } const source = pick(record['source']) ?? pick(record['url']) ?? pick(record['downloadUrl']); // A source with no valid value or alias must fail validation (not slip // through as whitespace): drop the key so the schema reports it missing. diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 906d38a137..e210389762 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -89,6 +89,7 @@ const CATALOG = { name: 'Meta Alias', shortDescription: 'Aliased metadata', websiteURL: 'https://example.test/meta', + keywords: ['web', 3, ' ', 'tools'], source: 'https://example.test/meta.zip', }, ], @@ -239,6 +240,7 @@ describe('server-v2 /api/v1 plugins', () => { capabilityId?: string; description?: string; homepage?: string; + keywords?: string[]; installed?: { version?: string }; }[]; }>('GET', '/api/v1/plugins/marketplace'); @@ -277,6 +279,8 @@ describe('server-v2 /api/v1 plugins', () => { 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'); From 06526afea525d3fb857f3c39e9da670424b5c083 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 06:02:10 +0800 Subject: [PATCH 23/46] fix(kap-server): treat a blank or non-string marketplace version as missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI parser reads version through its lenient stringField and falls through to source-derived versions; the route's schema rejected a numeric version with 50001 for the whole catalog. Normalize version in the preprocess like the other fields — the gh-plugin fixture now carries a numeric version and still derives 2.0.0 from its tag source. --- packages/kap-server/src/routes/plugins.ts | 6 ++++++ packages/kap-server/test/plugins.test.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 70d421b812..7028bbca23 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -139,6 +139,12 @@ const rawMarketplaceEntrySchema = z.preprocess( if (kept.length > 0) normalized['keywords'] = kept; else delete normalized['keywords']; } + // Version: blank or non-string reads as missing (derivation from the + // source then kicks in downstream) — the raw trimmed string is kept + // as-is otherwise (strictness lives in the update check, not here). + const version = pick(record['version']); + if (version === undefined) delete normalized['version']; + else normalized['version'] = version; const source = pick(record['source']) ?? pick(record['url']) ?? pick(record['downloadUrl']); // A source with no valid value or alias must fail validation (not slip // through as whitespace): drop the key so the schema reports it missing. diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index e210389762..a94f3fd7b7 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -71,9 +71,11 @@ const CATALOG = { source: 'https://example.test/bt.zip', }, { - // No version field; the GitHub release-tag source encodes it. + // 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', }, { From dd179e25c8ceea134b194ea6231c381d874e645f Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 06:40:59 +0800 Subject: [PATCH 24/46] fix(kap-server): trim marketplace entry ids before the install-state join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whitespace-padded id survived validation raw and never matched the installed records (updateAvailable silently lost). Normalize the id in the preprocess — trimmed, blank rejected — matching the CLI's requiredString. --- packages/kap-server/src/routes/plugins.ts | 5 +++++ packages/kap-server/test/plugins.test.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 7028bbca23..ca1f38b012 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -106,6 +106,11 @@ const rawMarketplaceEntrySchema = z.preprocess( const pick = (v: unknown) => typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined; const normalized: Record = { ...record }; + // The id keys the install-state join — trim it like the CLI's + // requiredString; a blank id drops out so the schema rejects the entry. + const id = pick(record['id']); + if (id === undefined) delete normalized['id']; + else normalized['id'] = id; // Metadata: blank reads as missing, and the CLI parser's aliases are // honored (name / shortDescription / websiteURL). const metadataAliases = [ diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index a94f3fd7b7..accea23dd5 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -87,7 +87,8 @@ const CATALOG = { }, { // CLI metadata aliases: name / shortDescription / websiteURL. - id: 'meta-alias-plugin', + // The padded id trims before the install-state join. + id: ' meta-alias-plugin ', name: 'Meta Alias', shortDescription: 'Aliased metadata', websiteURL: 'https://example.test/meta', From 8ae05103f7613b1797f183f4947c41f7894309f3 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 06:51:39 +0800 Subject: [PATCH 25/46] fix(kap-server): gate capability markers to the default catalog A custom catalog (env or server option) may legitimately carry a same-id fork of a capability's wiring plugin; marking it capabilityId would route users to the built-in install. Apply the marker only for the default catalog (including the source-checkout fallback), matching the CLI injecting built-in rows only for the default catalog. --- packages/kap-server/src/routes/plugins.ts | 15 +++++++++------ packages/kap-server/test/plugins.test.ts | 12 ++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index ca1f38b012..c0b5aa48e7 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -80,11 +80,13 @@ interface PluginsRouteHost { const PLUGIN_ACTIONS = ['enable', 'disable', 'remove'] as const; /** - * Capability wiring plugin id → capability id. The capability registry is a - * closed set whose ids belong to the client/engine contract (mirrored by the - * klient schema; precedent: the CLI names the same set 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. + * 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> = { 'kimi-cu': 'kimi-cu', @@ -436,7 +438,8 @@ export function registerPluginsRoutes( source, installed: installedInfo, updateAvailable: updateAvailable ? true : undefined, - capabilityId: CAPABILITY_ROW_IDS[entry.id], + capabilityId: + opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined, }; }); reply.send(okEnvelope({ entries }, req.id)); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index accea23dd5..248bb2eebb 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -272,11 +272,11 @@ describe('server-v2 /api/v1 plugins', () => { expect(before.body.data.entries.find((e) => e.id === 'third-party-plugin')?.version).toBe( '3.1.0', ); - // Capability wiring plugins carry their capability id. + // 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, - ).toBe('kimi-webbridge'); - expect(before.body.data.entries.find((e) => e.id === 'demo-plugin')?.capabilityId).toBeUndefined(); + ).toBeUndefined(); // 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'); @@ -420,7 +420,7 @@ describe('server-v2 /api/v1 plugins', () => { }); base = `http://127.0.0.1:${server.port}`; - const { body } = await call<{ entries: { id: string; source: string }[] }>( + const { body } = await call<{ entries: { id: string; source: string; capabilityId?: string }[] }>( 'GET', '/api/v1/plugins/marketplace', ); @@ -429,6 +429,10 @@ describe('server-v2 /api/v1 plugins', () => { // 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'); }); it('expands ~ in local catalog paths like the CLI loader', async () => { From 12178e16df65a450b48ce33b8ca363e486ae210d Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 07:04:51 +0800 Subject: [PATCH 26/46] fix(kap-server): compare marketplace versions with real semver The hand-rolled strict x.y.z check rejected valid semver the CLI accepts (v-prefixed, prerelease tags), so updateAvailable diverged between CLI and wire clients. Take the semver package (already in the monorepo via the CLI) for the update check and the two source-derived version validators. --- packages/kap-server/package.json | 2 ++ packages/kap-server/src/routes/plugins.ts | 23 +++++++---------------- packages/kap-server/test/plugins.test.ts | 3 ++- pnpm-lock.yaml | 8 +++++++- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/kap-server/package.json b/packages/kap-server/package.json index bf6ff7fab7..ab0d4b843c 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.1", "@types/ws": "^8.18.0", "tsx": "^4.21.0" } diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index c0b5aa48e7..759ecbe55a 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -43,6 +43,7 @@ import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gt, valid } from 'semver'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; @@ -175,20 +176,10 @@ const rawMarketplaceSchema = z.object({ plugins: z.array(rawMarketplaceEntrySchema), }); -/** Strict `x.y.z` numeric comparison (no prerelease); avoids a semver dep. */ +/** CLI-parity update check: both sides must be valid semver (`v` prefix and + * prerelease tags accepted), catalog strictly greater. */ function semverGt(a: string, b: string): boolean { - const parse = (v: string): number[] | undefined => { - const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v); - return m === null ? undefined : [Number(m[1]), Number(m[2]), Number(m[3])]; - }; - const pa = parse(a); - const pb = parse(b); - if (pa === undefined || pb === undefined) return false; - for (let i = 0; i < 3; i += 1) { - if (pa[i]! > pb[i]!) return true; - if (pa[i]! < pb[i]!) return false; - } - return false; + return valid(a) !== null && valid(b) !== null && gt(a, b); } /** @@ -225,7 +216,7 @@ function expandHome(input: string): string { /** * Derive a version from a GitHub release/tree/commit source (same shapes as - * the CLI parser; strict `x.y.z`, no prerelease — mirrors `semverGt`). + * the CLI parser; validity follows `semver.valid`). */ function deriveVersionFromGithubSource(source: string): string | undefined { let url: URL; @@ -246,7 +237,7 @@ function deriveVersionFromGithubSource(source: string): string | undefined { decoded = ref; } const candidate = decoded.replace(/^v/i, ''); - return /^(\d+)\.(\d+)\.(\d+)$/.test(candidate) ? candidate : undefined; + return valid(candidate) !== null ? candidate : undefined; } /** @@ -288,7 +279,7 @@ async function resolveLatestGithubRelease( decoded = tag; } const candidate = decoded.replace(/^v/i, ''); - return /^(\d+)\.(\d+)\.(\d+)$/.test(candidate) ? candidate : undefined; + return valid(candidate) !== null ? candidate : undefined; } catch { return undefined; } diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 248bb2eebb..31d200d9e7 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -41,7 +41,8 @@ const CATALOG = { id: 'demo-plugin', tier: 'official', displayName: 'Demo Plugin', - version: '2.0.0', + // A `v`-prefixed catalog version still drives the update check. + version: 'v2.0.0', source: 'https://cdn.example.test/demo.zip', }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ce665033..8e38baec1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -783,6 +783,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 +802,9 @@ importers: '@types/bcryptjs': specifier: ^2.4.6 version: 2.4.6 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@types/ws': specifier: ^8.18.0 version: 8.18.1 @@ -13276,7 +13282,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: From b6b27d83dc790ef6ba8256608329bf5ec6f30d0a Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 07:16:19 +0800 Subject: [PATCH 27/46] fix(kap-server): validate marketplace entry types and count the dev server as default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Custom catalog rows with an unsupported type (e.g. integration) were stripped by the schema and advertised as installable plugins; the CLI rejects the catalog outright. Model the same plugin/managed/guide vocabulary. - scripts/dev.mjs marks its repo-owned catalog with KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER=1 — honor the flag in the isDefault check so capability markers and the checkout fallback behave exactly like the CLI under the dev marketplace. --- packages/kap-server/src/routes/plugins.ts | 7 ++++ packages/kap-server/src/start.ts | 5 ++- packages/kap-server/test/plugins.test.ts | 46 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 759ecbe55a..97ee785ba9 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -133,6 +133,10 @@ const rawMarketplaceEntrySchema = z.preprocess( if (tier.trim().length === 0) delete normalized['tier']; else normalized['tier'] = tier.trim(); } + // `type` trims only; the enum below rejects anything outside the CLI's + // plugin / managed / guide vocabulary. + const type = record['type']; + if (typeof type === 'string') normalized['type'] = type.trim(); // Keywords keep only non-blank strings (a junk member never fails the // catalog); a non-array value reads as missing — CLI stringArrayField // semantics. @@ -162,6 +166,9 @@ const rawMarketplaceEntrySchema = z.preprocess( }, z.object({ id: z.string().min(1), + // The CLI's validateMarketplaceEntryType vocabulary (legacy aliases + // included); unknown types must not surface as installable plugins. + type: z.enum(['plugin', 'managed', 'guide']).optional(), tier: z.enum(['official', 'curated']).optional(), displayName: z.string().optional(), description: z.string().optional(), diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 67b4f2387a..2f782fddc3 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -519,7 +519,10 @@ export async function startServer(opts: ServerStartOptions): Promise { void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); }, diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 31d200d9e7..c2876cce1c 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -329,6 +329,52 @@ describe('server-v2 /api/v1 plugins', () => { 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', + ); + }); + it('maps an unreachable marketplace to 50001', async () => { const realFetch = globalThis.fetch; vi.stubGlobal( From e9b3708dd877f60018903b6ed0741f74daef6dc8 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 07:27:41 +0800 Subject: [PATCH 28/46] fix(kap-server): join capability rows through their platform wiring plugin id kimi-cu installs its wiring plugin as kimi-cu-win on Windows x64, so a catalog row keyed kimi-cu never matched the installed record there (no installed state, no updateAvailable). The row mapping now knows each capability's wiring plugin ids and joins through them. --- packages/kap-server/src/routes/plugins.ts | 23 ++++++++++++++++------- packages/kap-server/test/plugins.test.ts | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 97ee785ba9..28d9763f61 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -89,10 +89,14 @@ const PLUGIN_ACTIONS = ['enable', 'disable', 'remove'] as const; * them through `/capabilities/{id}:install` — a plain `POST /plugins` * installs only the wiring layer, never the binary runtime. */ -const CAPABILITY_ROW_IDS: Readonly> = { - 'kimi-cu': 'kimi-cu', - 'kimi-cu-win': 'kimi-cu', - 'kimi-webbridge': 'kimi-webbridge', +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'] }, }; const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; @@ -416,7 +420,13 @@ export function registerPluginsRoutes( const installed = await core.accessor.get(IPluginService).listPlugins(); const byId = new Map(installed.map((p) => [p.id, p])); const entries: PluginMarketplaceEntryWire[] = resolved.map(({ entry, source, version }) => { - const record = byId.get(entry.id); + const capabilityRow = + opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined; + const record = + byId.get(entry.id) ?? + capabilityRow?.wiringPluginIds + .map((id) => byId.get(id)) + .find((candidate) => candidate !== undefined); const installedInfo = record === undefined ? undefined @@ -436,8 +446,7 @@ export function registerPluginsRoutes( source, installed: installedInfo, updateAvailable: updateAvailable ? true : undefined, - capabilityId: - opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined, + capabilityId: capabilityRow?.capabilityId, }; }); reply.send(okEnvelope({ entries }, req.id)); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index c2876cce1c..0e4406ac8f 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -86,6 +86,13 @@ const CATALOG = { 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. @@ -257,6 +264,7 @@ describe('server-v2 /api/v1 plugins', () => { ['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(); @@ -373,6 +381,17 @@ describe('server-v2 /api/v1 plugins', () => { expect(body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId).toBe( 'kimi-webbridge', ); + + // 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'); }); it('maps an unreachable marketplace to 50001', async () => { From c96d44ad229f61f24cb746f49976794f33c2e228 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 07:38:15 +0800 Subject: [PATCH 29/46] fix(kap-server): map plugin load failures to 40001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An install source pointing at a directory/zip with a missing or invalid manifest throws plugin.load_failed — a client-fixable input error that fell through to 50001. Map it to validation.failed alongside the other input mistakes. --- packages/kap-server/src/routes/plugins.ts | 6 ++++-- packages/kap-server/test/plugins.test.ts | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 28d9763f61..527a55df82 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -565,8 +565,10 @@ export function registerPluginsRoutes( const PLUGIN_ERROR_MAP: Readonly> = { [PluginErrors.codes.PLUGIN_NOT_FOUND]: ErrorCode.PLUGIN_NOT_FOUND, - // Client-fixable input mistakes (relative source, missing local path) keep - // their 4xx semantics instead of collapsing into a 50001. + // 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, }; diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 0e4406ac8f..096864be32 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -238,6 +238,11 @@ describe('server-v2 /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 () => { From 49c04fe314840d773be9859edc0d5d1d59a557ea Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 12:17:05 +0800 Subject: [PATCH 30/46] build(kap-server): align @types/semver with the workspace version sherif rejects multiple workspace versions of one dependency; the CLI pins @types/semver at ^7.7.0. --- packages/kap-server/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/package.json b/packages/kap-server/package.json index ab0d4b843c..cb9035015f 100644 --- a/packages/kap-server/package.json +++ b/packages/kap-server/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.6", - "@types/semver": "^7.7.1", + "@types/semver": "^7.7.0", "@types/ws": "^8.18.0", "tsx": "^4.21.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e38baec1e..c59ea9edb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -803,7 +803,7 @@ importers: specifier: ^2.4.6 version: 2.4.6 '@types/semver': - specifier: ^7.7.1 + specifier: ^7.7.0 version: 7.7.1 '@types/ws': specifier: ^8.18.0 From 838094136d0a85e1fbb6d3e9bc39271f3b071491 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 14:32:45 +0800 Subject: [PATCH 31/46] refactor(agent-core-v2): share the plugin marketplace client/parser across hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kap-server marketplace route grew its own copy of the CLI's catalog loading/parsing logic (lenient aliases, blank-means-missing fields, source resolution, GitHub version derivation) — two implementations of a public, hand-writable format would drift on every catalog change. Move the read/parse/version machinery into the plugin domain as app/plugin/marketplace (pure functions, no DI): the CLI keeps a thin wrapper owning configured-source resolution and its checkout fallback, and the route keeps only the wire concerns (install-state merge, capabilityId markers, error envelopes). plugins.ts drops ~230 lines of duplicated machinery. One deliberate behavior fix rides along: tilde entry sources now expand against the home directory at parse time (the CLI previously passed them through literally, failing later at install validation). --- apps/kimi-code/src/constant/app.ts | 8 +- .../kimi-code/src/utils/plugin-marketplace.ts | 449 ++---------------- packages/agent-core-v2/package.json | 2 + .../src/app/plugin/marketplace.ts | 443 +++++++++++++++++ packages/agent-core-v2/src/index.ts | 1 + packages/kap-server/src/routes/plugins.ts | 343 +++---------- packages/kap-server/src/start.ts | 6 +- pnpm-lock.yaml | 8 +- 8 files changed, 565 insertions(+), 695 deletions(-) create mode 100644 packages/agent-core-v2/src/app/plugin/marketplace.ts diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 9cccb3634e..b8cb8f63af 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -85,8 +85,12 @@ 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). +export { + KIMI_CODE_PLUGIN_MARKETPLACE_URL, + KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV, +} from '@moonshot-ai/agent-core-v2'; // 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..c17e672e83 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'; 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'; 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 ea85c28ee2..d378ebebfb 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/plugin/marketplace.ts b/packages/agent-core-v2/src/app/plugin/marketplace.ts new file mode 100644 index 0000000000..db9fb59fa8 --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/marketplace.ts @@ -0,0 +1,443 @@ +/** + * `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. 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, or from the bare repo's latest release via the + * `/releases/latest` redirect (never the rate-limited API); the version feeds + * `computeUpdateStatus`, which reports an update only on strict semver + * latest > installed. The default catalog location is the production CDN URL; + * hosts may additionally pass a source-checkout fallback catalog for offline + * dev. 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'; + +/** Production marketplace catalog; hosts may override per option or env. */ +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[]; + /** + * 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 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 { + /** Catalog location string: http(s) URL, file:// URL, or a local path. */ + readonly source: string; + /** Base for relative local catalog paths (host cwd). */ + readonly workDir: string; + readonly fetchImpl?: typeof fetch; + /** + * Fallback catalog consulted only when reading `source` fails — hosts pass + * their source-checkout resolver when (and only when) the catalog location + * is the built-in default, so an explicitly configured catalog fails hard. + */ + readonly sourceCheckoutLocation?: () => Promise; +} + +/** + * 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, +): 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 }; + } + // 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 }; +} + +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) }; +} + +/** + * Resolve the catalog location and read its raw text, falling back to the + * source-checkout catalog when the primary read fails and the host provided + * one. Returns the location actually read — entry sources resolve against it. + */ +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)), + }; +} + +/** + * 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. + */ +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] }; +} + +/** + * Fill in missing entry versions by resolving the latest GitHub release of + * bare-repo sources (parallel; per-entry failures degrade to no version). + */ +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); +} + +/** + * 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/src/index.ts b/packages/agent-core-v2/src/index.ts index 3af2678add..28249c7306 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -214,6 +214,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/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 527a55df82..658abc48fd 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -11,13 +11,15 @@ * 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; plain paths and `file://` URLs read from disk like the CLI loader) - * and merged with the live install state — install status is always detected - * from the local records, never from the catalog. Catalog-relative sources - * (`./official/*.zip`) resolve against the catalog location so the returned - * `source` is directly installable. Entries without a `version` get one from - * a GitHub ref tail or the bare repo's latest release (CLI parity), which is - * what drives `updateAvailable`. + * 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). @@ -30,20 +32,22 @@ * - other errors → `50001` via the global error handler */ +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; + import { + computeUpdateStatus, ErrorCodes as DomainErrorCodes, IPluginService, PluginErrors, isError2, + parsePluginMarketplace, + readPluginMarketplace, + withLatestVersions, + type MarketplaceLocation, + type PluginMarketplace, type Scope, } from '@moonshot-ai/agent-core-v2'; -import { existsSync } from 'node: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'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; @@ -101,254 +105,21 @@ const CAPABILITY_ROW_IDS: Readonly< const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; -// Custom catalogs accepted by the CLI may carry the source under the legacy -// `url` / `downloadUrl` aliases — normalize before validating so a catalog -// that works in the CLI works here too. -const rawMarketplaceEntrySchema = z.preprocess( - (value) => { - if (typeof value !== 'object' || value === null) return value; - const record = value as Record; - // CLI stringField semantics: non-string or blank counts as missing, and - // the first valid of source / url / downloadUrl wins (trimmed). - const pick = (v: unknown) => - typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined; - const normalized: Record = { ...record }; - // The id keys the install-state join — trim it like the CLI's - // requiredString; a blank id drops out so the schema rejects the entry. - const id = pick(record['id']); - if (id === undefined) delete normalized['id']; - else normalized['id'] = id; - // Metadata: blank reads as missing, and the CLI parser's aliases are - // honored (name / shortDescription / websiteURL). - const metadataAliases = [ - ['displayName', 'name'], - ['description', 'shortDescription'], - ['homepage', 'websiteURL'], - ] as const; - for (const [field, alias] of metadataAliases) { - const value = pick(record[field]) ?? pick(record[alias]); - if (value === undefined) delete normalized[field]; - else normalized[field] = value; - } - // A blank tier means "missing" (third-party); a non-string tier keeps - // failing validation, matching the CLI parser's type error. - const tier = record['tier']; - if (typeof tier === 'string') { - if (tier.trim().length === 0) delete normalized['tier']; - else normalized['tier'] = tier.trim(); - } - // `type` trims only; the enum below rejects anything outside the CLI's - // plugin / managed / guide vocabulary. - const type = record['type']; - if (typeof type === 'string') normalized['type'] = type.trim(); - // Keywords keep only non-blank strings (a junk member never fails the - // catalog); a non-array value reads as missing — CLI stringArrayField - // semantics. - const keywords = record['keywords']; - if (keywords !== undefined) { - const kept = Array.isArray(keywords) - ? keywords - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter((item) => item.length > 0) - : []; - if (kept.length > 0) normalized['keywords'] = kept; - else delete normalized['keywords']; - } - // Version: blank or non-string reads as missing (derivation from the - // source then kicks in downstream) — the raw trimmed string is kept - // as-is otherwise (strictness lives in the update check, not here). - const version = pick(record['version']); - if (version === undefined) delete normalized['version']; - else normalized['version'] = version; - const source = pick(record['source']) ?? pick(record['url']) ?? pick(record['downloadUrl']); - // A source with no valid value or alias must fail validation (not slip - // through as whitespace): drop the key so the schema reports it missing. - if (source !== undefined) normalized['source'] = source; - else delete normalized['source']; - return normalized; - }, - z.object({ - id: z.string().min(1), - // The CLI's validateMarketplaceEntryType vocabulary (legacy aliases - // included); unknown types must not surface as installable plugins. - type: z.enum(['plugin', 'managed', 'guide']).optional(), - tier: z.enum(['official', 'curated']).optional(), - displayName: z.string().optional(), - description: z.string().optional(), - homepage: z.string().optional(), - keywords: z.array(z.string()).optional(), - version: z.string().optional(), - source: z.string().min(1), - }), -); - -const rawMarketplaceSchema = z.object({ - plugins: z.array(rawMarketplaceEntrySchema), -}); - -/** CLI-parity update check: both sides must be valid semver (`v` prefix and - * prerelease tags accepted), catalog strictly greater. */ -function semverGt(a: string, b: string): boolean { - return valid(a) !== null && valid(b) !== null && gt(a, b); -} - -/** - * Catalog sources may be relative to the catalog location (the production CDN - * catalog uses `./official/*.zip`). Clients hand `source` back to - * `POST /plugins`, whose normalizer rejects non-absolute paths — resolve - * against the catalog URL (or, for a local catalog, its directory) so every - * returned source is directly installable. - */ -function resolveEntrySource(source: string, marketplaceUrl: string): string { - if (/^https?:\/\//.test(source)) return source; - // `file://` entry sources convert to filesystem paths up front — the - // install normalizer only accepts http(s) or absolute local paths. - if (source.startsWith('file://')) return fileURLToPath(source); - // Home-relative entry sources expand before any absolute/relative decision. - const expanded = expandHome(source); - if (isAbsolute(expanded)) return expanded; - if (/^https?:\/\//.test(marketplaceUrl)) { - try { - return new URL(expanded, marketplaceUrl).href; - } catch { - return expanded; - } - } - return resolve(dirname(localCatalogPath(marketplaceUrl)), expanded); -} - -/** `~` / `~/` home expansion, same as the CLI loader's resolveLocalPath. */ -function expandHome(input: string): string { - if (input === '~') return homedir(); - if (input.startsWith('~/')) return join(homedir(), input.slice(2)); - return input; -} - -/** - * Derive a version from a GitHub release/tree/commit source (same shapes as - * the CLI parser; validity follows `semver.valid`). - */ -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; -} - -/** - * Bare-repo GitHub sources carry no version — resolve the latest release tag - * through the `/releases/latest` redirect (a UI route, not the rate-limited - * API), same as the CLI. Lookups never fail the listing: any error degrades - * to no version. - */ -async function resolveLatestGithubRelease( - source: string, - fetchImpl: typeof fetch, -): Promise { - 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 — ref tails are already - // handled by deriveVersionFromGithubSource. - const segments = url.pathname.split('/').filter(Boolean); - if (segments.length !== 2) return undefined; - const [owner, repo] = segments; - try { - const resp = await fetchImpl(`https://github.com/${owner}/${repo}/releases/latest`, { - redirect: 'manual', - signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS), - }); - if (resp.status !== 301 && resp.status !== 302) return undefined; - const location = resp.headers.get('location'); - if (location === null) return undefined; - const tag = /\/releases\/tag\/([^/?#]+)/.exec(location)?.[1]; - if (tag === undefined) return undefined; - let decoded: string; - try { - decoded = decodeURIComponent(tag); - } catch { - decoded = tag; - } - const candidate = decoded.replace(/^v/i, ''); - return valid(candidate) !== null ? candidate : undefined; - } catch { - return undefined; - } -} - -/** - * Local catalog location → filesystem path: `file://` conversion plus home - * expansion. - */ -function localCatalogPath(location: string): string { - return expandHome(location.startsWith('file://') ? fileURLToPath(location) : location); +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 CLI loader's fallback when the - * configured catalog is unreachable (offline / source-checkout dev). Absent - * in bundled installs, where the fallback simply never fires. + * 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. */ -function sourceCheckoutCatalogPath(): string | undefined { +async function getSourceCheckoutLocation(): Promise { const candidate = resolve(import.meta.dirname, '../../../../plugins/marketplace.json'); - return existsSync(candidate) ? candidate : undefined; -} - -/** - * Read the raw marketplace catalog JSON. Remote catalogs go through fetch; - * local catalogs (plain path or `file://`, both accepted by the CLI loader) - * are read from disk so the same custom catalog works for desktop/web hosts. - * A failed remote read falls back to the source checkout's catalog when one - * exists (CLI parity). - */ -/** - * Read the raw marketplace catalog JSON plus the location it was actually - * read from — relative entry sources resolve against the latter (the - * source-checkout fallback serves local directory sources). - */ -async function readMarketplaceCatalog( - opts: PluginsRouteOptions, -): Promise<{ raw: unknown; location: string }> { - const location = opts.marketplaceUrl; - if (!/^https?:\/\//.test(location)) { - return { - raw: JSON.parse(await readFile(localCatalogPath(location), 'utf8')), - location, - }; - } - const fetchImpl = opts.fetchImpl ?? fetch; - try { - const resp = await fetchImpl(location, { - signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS), - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - return { raw: await resp.json(), location }; - } catch (error) { - const fallback = - opts.marketplaceIsDefault === true ? sourceCheckoutCatalogPath() : undefined; - if (fallback === undefined) throw error; - return { raw: JSON.parse(await readFile(fallback, 'utf8')), location: fallback }; - } + const info = await stat(candidate).catch(() => undefined); + if (info?.isFile() !== true) return undefined; + return { raw: candidate, kind: 'local', resolved: candidate }; } export interface PluginsRouteOptions { @@ -357,8 +128,8 @@ export interface PluginsRouteOptions { /** * 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 (CLI parity: an explicitly - * configured catalog fails hard). + * 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; @@ -382,9 +153,16 @@ export function registerPluginsRoutes( operationId: 'listPluginMarketplace', }, async (req, reply) => { - let catalog: { raw: unknown; location: string }; + const fetchImpl = opts.fetchImpl ?? fetchWithTimeout; + let read: { raw: string; location: MarketplaceLocation }; try { - catalog = await readMarketplaceCatalog(opts); + read = await readPluginMarketplace({ + source: opts.marketplaceUrl, + workDir: process.cwd(), + fetchImpl, + sourceCheckoutLocation: + opts.marketplaceIsDefault === true ? getSourceCheckoutLocation : undefined, + }); } catch (error) { reply.send( errEnvelope( @@ -395,31 +173,23 @@ export function registerPluginsRoutes( ); return; } - const parsed = rawMarketplaceSchema.safeParse(catalog.raw); - if (!parsed.success) { + let marketplace: PluginMarketplace; + try { + marketplace = parsePluginMarketplace(read.raw, read.location); + } catch (error) { reply.send( - errEnvelope(ErrorCode.INTERNAL_ERROR, 'Plugin marketplace returned an invalid catalog', req.id), + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `Plugin marketplace returned an invalid catalog: ${error instanceof Error ? error.message : String(error)}`, + req.id, + ), ); return; } - const fetchImpl = opts.fetchImpl ?? fetch; - // Resolve sources and versions up front (parallel; latest-release - // lookups for bare GitHub repos ride the shared per-call timeout). - const resolved = await Promise.all( - parsed.data.plugins.map(async (entry) => { - const source = resolveEntrySource(entry.source, catalog.location); - // Entries may omit `version`: derive it from a GitHub ref tail, or - // look up the latest release of a bare repo source (CLI parity). - const version = - entry.version ?? - deriveVersionFromGithubSource(source) ?? - (await resolveLatestGithubRelease(source, fetchImpl)); - return { entry, source, version }; - }), - ); + marketplace = await withLatestVersions(marketplace, fetchImpl); const installed = await core.accessor.get(IPluginService).listPlugins(); const byId = new Map(installed.map((p) => [p.id, p])); - const entries: PluginMarketplaceEntryWire[] = resolved.map(({ entry, source, version }) => { + const entries: PluginMarketplaceEntryWire[] = marketplace.plugins.map((entry) => { const capabilityRow = opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined; const record = @@ -432,18 +202,17 @@ export function registerPluginsRoutes( ? undefined : { enabled: record.enabled, version: record.version }; const updateAvailable = - version !== undefined && - record?.version !== undefined && - semverGt(version, record.version); + computeUpdateStatus(entry.version, record?.version, record !== undefined).kind === + 'update'; return { id: entry.id, tier: entry.tier ?? 'third-party', - displayName: entry.displayName ?? entry.id, + displayName: entry.displayName, description: entry.description, homepage: entry.homepage, - keywords: entry.keywords, - version, - source, + keywords: entry.keywords === undefined ? undefined : [...entry.keywords], + version: entry.version, + source: entry.source, installed: installedInfo, updateAvailable: updateAvailable ? true : undefined, capabilityId: capabilityRow?.capabilityId, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 2f782fddc3..2bfd38e23f 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -18,6 +18,7 @@ import { ISessionIndex, ISessionIndexMirror, IWorkspaceService, + KIMI_CODE_PLUGIN_MARKETPLACE_URL, logSeed, resolveConfigPath, resolveKimiHome, @@ -100,9 +101,6 @@ export interface ServerHostIdentity extends KimiHostIdentity { readonly replyStyleGuide?: string; } -/** Default plugin marketplace catalog (overridable per server option or env). */ -const DEFAULT_PLUGIN_MARKETPLACE_URL = 'https://code.kimi.com/kimi-code/plugins/marketplace.json'; - export interface ServerStartOptions { readonly host?: string; readonly port?: number; @@ -516,7 +514,7 @@ export async function startServer(opts: ServerStartOptions): Promise Date: Thu, 13 Aug 2026 14:54:48 +0800 Subject: [PATCH 32/46] docs(agent-core-v2): fold the marketplace module's member docs into the file header The package convention keeps explanatory comments in the top-of-file block only; the moved parser carried several function/member-level JSDoc blocks from its CLI home. The header now carries the format contract, leniency rules, source/version resolution order, built-in masking semantics, and the fallback gating rule. --- .../src/app/plugin/marketplace.ts | 69 +++++-------------- 1 file changed, 16 insertions(+), 53 deletions(-) diff --git a/packages/agent-core-v2/src/app/plugin/marketplace.ts b/packages/agent-core-v2/src/app/plugin/marketplace.ts index db9fb59fa8..8e5191f04b 100644 --- a/packages/agent-core-v2/src/app/plugin/marketplace.ts +++ b/packages/agent-core-v2/src/app/plugin/marketplace.ts @@ -6,16 +6,25 @@ * 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. Entry + * 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, or from the bare repo's latest release via the - * `/releases/latest` redirect (never the rate-limited API); the version feeds - * `computeUpdateStatus`, which reports an update only on strict semver - * latest > installed. The default catalog location is the production CDN URL; - * hosts may additionally pass a source-checkout fallback catalog for offline - * dev. No DI collaborators — pure functions over `fetch`/`fs`. + * 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'; @@ -25,7 +34,6 @@ import { fileURLToPath } from 'node:url'; import { gt, valid } from 'semver'; -/** Production marketplace catalog; hosts may override per option or env. */ 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'; @@ -43,11 +51,6 @@ export interface PluginMarketplaceEntry { 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; } @@ -69,24 +72,12 @@ export interface MarketplaceLocation { } export interface ReadPluginMarketplaceOptions { - /** Catalog location string: http(s) URL, file:// URL, or a local path. */ readonly source: string; - /** Base for relative local catalog paths (host cwd). */ readonly workDir: string; readonly fetchImpl?: typeof fetch; - /** - * Fallback catalog consulted only when reading `source` fails — hosts pass - * their source-checkout resolver when (and only when) the catalog location - * is the built-in default, so an explicitly configured catalog fails hard. - */ readonly sourceCheckoutLocation?: () => Promise; } -/** - * 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, @@ -122,11 +113,6 @@ export function resolveMarketplaceLocation(source: string, workDir: string): Mar return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) }; } -/** - * Resolve the catalog location and read its raw text, falling back to the - * source-checkout catalog when the primary read fails and the host provided - * one. Returns the location actually read — entry sources resolve against it. - */ export async function readPluginMarketplace( options: ReadPluginMarketplaceOptions, ): Promise<{ raw: string; location: MarketplaceLocation }> { @@ -169,15 +155,6 @@ export function parsePluginMarketplace(raw: string, location: MarketplaceLocatio }; } -/** - * 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. - */ export function withBuiltInEntries( marketplace: PluginMarketplace, builtIns: readonly PluginMarketplaceEntry[], @@ -192,10 +169,6 @@ export function withBuiltInEntries( return { ...marketplace, plugins: [...catalog, ...enrichedBuiltIns] }; } -/** - * Fill in missing entry versions by resolving the latest GitHub release of - * bare-repo sources (parallel; per-entry failures degrade to no version). - */ export async function withLatestVersions( marketplace: PluginMarketplace, fetchImpl: typeof fetch, @@ -301,16 +274,6 @@ function resolveEntrySource(source: string, location: MarketplaceLocation): stri 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 { From c89db9ee366805ccd0ddf9c073aa4a0ac42d814b Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 15:06:43 +0800 Subject: [PATCH 33/46] docs(agent-core-v2): drop the remaining statement comments in the marketplace module The header carries the rationale (update semantics, GitHub ref shapes, the releases/latest choice); the convention allows nothing beside statements. --- packages/agent-core-v2/src/app/plugin/marketplace.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/agent-core-v2/src/app/plugin/marketplace.ts b/packages/agent-core-v2/src/app/plugin/marketplace.ts index 8e5191f04b..4a74102f93 100644 --- a/packages/agent-core-v2/src/app/plugin/marketplace.ts +++ b/packages/agent-core-v2/src/app/plugin/marketplace.ts @@ -93,8 +93,6 @@ export function computeUpdateStatus( ) { 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 }; } @@ -284,10 +282,6 @@ function deriveVersionFromGithubSource(source: string): string | 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; @@ -326,8 +320,6 @@ function parseGithubRepo(source: string): { owner: string; repo: string } | unde 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; @@ -339,10 +331,6 @@ async function fetchLatestReleaseTag( 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; From 26db3a6c7550c2758b36c47075fd850becc2ba35 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 16:30:58 +0800 Subject: [PATCH 34/46] fix(kimi-code): import the shared marketplace module by its deep path constant/app.ts is evaluated on every CLI invocation; re-exporting from the agent-core-v2 root would pull the whole engine module graph into startup. The package's wildcard subpath export lets both CLI files take only the pure marketplace module (node builtins + semver). --- apps/kimi-code/src/constant/app.ts | 4 +++- apps/kimi-code/src/utils/plugin-marketplace.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index b8cb8f63af..19b4acc25c 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -87,10 +87,12 @@ 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'; // 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'; +} 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 c17e672e83..557698111a 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -19,7 +19,7 @@ import { type MarketplaceLocation, type PluginMarketplace, type PluginMarketplaceEntry, -} from '@moonshot-ai/agent-core-v2'; +} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL, @@ -33,7 +33,7 @@ export { type PluginMarketplaceEntry, type PluginMarketplaceTier, type MarketplaceUpdateStatus, -} from '@moonshot-ai/agent-core-v2'; +} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace'; export interface LoadPluginMarketplaceOptions { readonly workDir: string; From 8f4ba55272ecdad5bdbd39d4f114dfc5994cf4b1 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 13 Aug 2026 21:12:07 +0800 Subject: [PATCH 35/46] feat(kap-server): fan plugin and capability lifecycle out as global WS events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients currently poll the plugins/capabilities REST surfaces and can hold stale rows while another client mutates the set. Publish two global events instead: - event.plugin.changed — fired off IPluginService.onDidReload, so any install/enable/disable/remove from any client reaches every host - event.capability.changed — every capability install progress transition (CapabilityService gains onDidChangeInstall), so rows update live and settle is observable without polling Both ride the existing global fan-out (no subscription needed) and are documented in the wire schema registry. --- .../src/app/capability/capability.ts | 6 +- .../src/app/capability/capabilityService.ts | 36 ++++++++---- .../agent-core-v2/src/app/capability/types.ts | 5 ++ .../app/capability/capabilityService.test.ts | 27 +++++++++ .../kap-server/src/protocol/events-zod.ts | 16 ++++++ packages/kap-server/src/start.ts | 20 +++++++ .../kap-server/src/transport/ws/v1/events.ts | 26 +++++++++ .../ws/v1/sessionEventBroadcaster.ts | 57 +++++++++++++++++++ packages/kap-server/test/plugins.test.ts | 36 +++++++++++- .../test/sessionEventBroadcaster.test.ts | 54 ++++++++++++++++++ 10 files changed, 271 insertions(+), 12 deletions(-) diff --git a/packages/agent-core-v2/src/app/capability/capability.ts b/packages/agent-core-v2/src/app/capability/capability.ts index 15056fe0ae..ec92b45687 100644 --- a/packages/agent-core-v2/src/app/capability/capability.ts +++ b/packages/agent-core-v2/src/app/capability/capability.ts @@ -8,12 +8,16 @@ */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; -import type { CapabilityStatus } from './types'; +import type { CapabilityInstallChange, CapabilityStatus } from './types'; export interface ICapabilityService { readonly _serviceBrand: undefined; + /** Fires on every install progress transition (start / step / settle / error). */ + readonly onDidChangeInstall: Event; + 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 7938598e14..307939c225 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,7 @@ import { createKimiWebbridgeEntry } from './entries/kimiWebbridge'; import type { CapabilityEntry, CapabilityId, + CapabilityInstallChange, CapabilityInstallProgress, CapabilityReadiness, CapabilityStatus, @@ -33,13 +37,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 +62,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 { @@ -90,16 +106,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 { const note = await entry.install((step, percent) => { - this.installProgress.set( + this.setInstallProgress( entry.id, percent === undefined ? { running: true, step } : { running: true, step, percent }, ); }); - this.installProgress.set(entry.id, { running: false, note }); + 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 +123,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/types.ts b/packages/agent-core-v2/src/app/capability/types.ts index b647289f02..46908fe482 100644 --- a/packages/agent-core-v2/src/app/capability/types.ts +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -50,6 +50,11 @@ export interface CapabilityStatus { export type CapabilityInstallReporter = (step: string, percent?: number) => void; +export interface CapabilityInstallChange { + readonly id: CapabilityId; + readonly install: CapabilityInstallProgress; +} + export interface CapabilityEntry { readonly id: CapabilityId; readonly pluginId?: string; 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 a52158f3b2..d8299b3479 100644 --- a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts +++ b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts @@ -213,6 +213,33 @@ describe('CapabilityService', () => { expect.unreachable('install never settled'); }); + 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[seen.length - 1]).toEqual({ id: 'kimi-cu', install: { running: false } }); + }); + it('surfaces an install note from the entry through progress', async () => { const service = fakeService([ fakeEntry({ diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 35ddf7efef..6ee223deb2 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), diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 2bfd38e23f..ad84e493f8 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -17,6 +17,8 @@ import { IProviderDiscoveryService, ISessionIndex, ISessionIndexMirror, + ICapabilityService, + IPluginService, IWorkspaceService, KIMI_CODE_PLUGIN_MARKETPLACE_URL, logSeed, @@ -381,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. @@ -450,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')) { diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 49474daeab..1749a7bd0e 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 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/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 096864be32..fcba6b0ea2 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -21,9 +21,11 @@ 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 } from './helpers/auth'; +import { authHeaders, bearerToken } from './helpers/auth'; interface Envelope { code: number; @@ -229,6 +231,38 @@ describe('server-v2 /api/v1 plugins', () => { 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' }); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 88408732d4..33c78f3dbc 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -1194,6 +1194,60 @@ 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 }, + }, + }); + }); + + 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); From 1bf6513ab03324cd176aec25d7f3e55bcb708f8c Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 01:20:47 +0800 Subject: [PATCH 36/46] fix: register the lifecycle events in the wire union and tidy the contract header - event.plugin.changed / event.capability.changed were declared but not part of agentEventSchema, leaving the wire catalog incomplete. - The onDidChangeInstall member doc moves into the capability contract file header (package comment convention). --- packages/agent-core-v2/src/app/capability/capability.ts | 4 ++-- packages/kap-server/src/protocol/events-zod.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/app/capability/capability.ts b/packages/agent-core-v2/src/app/capability/capability.ts index ec92b45687..b252d9f048 100644 --- a/packages/agent-core-v2/src/app/capability/capability.ts +++ b/packages/agent-core-v2/src/app/capability/capability.ts @@ -4,7 +4,8 @@ * 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). */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -15,7 +16,6 @@ import type { CapabilityInstallChange, CapabilityStatus } from './types'; export interface ICapabilityService { readonly _serviceBrand: undefined; - /** Fires on every install progress transition (start / step / settle / error). */ readonly onDidChangeInstall: Event; listCapabilities(): Promise; diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 6ee223deb2..e5d5bb429b 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -981,6 +981,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [ sessionWorkChangedEventSchema, sessionStatusChangedEventSchema, diUnitChangedEventSchema, + pluginChangedEventSchema, + capabilityChangedEventSchema, goalUpdatedEventSchema, skillActivatedEventSchema, pluginCommandActivatedEventSchema, From 50b9cee01ee226539b7bbb07b0c65b315ef2c501 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 01:36:15 +0800 Subject: [PATCH 37/46] feat(protocol): mirror the plugin/capability lifecycle events in the shared WS schema Clients and e2e harnesses validating server frames against @moonshot-ai/protocol would reject event.plugin.changed / event.capability.changed. Register both in the shared catalog (TS interfaces, zod schemas, and both unions), matching the model_catalog.changed precedent for global events. --- packages/protocol/src/events.ts | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index bea2fbe265..4441c7d5cd 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, From 430009b985ea2eeb2133677e595a3f54bf309a36 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 01:49:19 +0800 Subject: [PATCH 38/46] fix(kap-server): prefer the platform wiring plugin when joining capability rows A stale same-id record (e.g. a raw kimi-cu plugin next to the real kimi-cu-win wiring on Windows x64) previously won the join, showing the wrong installed state and update availability. Capability rows now join through the wiring plugin ids in platform preference order before falling back to the catalog id. --- packages/kap-server/src/routes/plugins.ts | 23 +++++++++++++++++++---- packages/kap-server/test/plugins.test.ts | 12 ++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 658abc48fd..45e5e90d3b 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -103,6 +103,18 @@ const CAPABILITY_ROW_IDS: Readonly< '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 { @@ -192,11 +204,14 @@ export function registerPluginsRoutes( const entries: PluginMarketplaceEntryWire[] = marketplace.plugins.map((entry) => { const capabilityRow = opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined; + // 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 = - byId.get(entry.id) ?? - capabilityRow?.wiringPluginIds - .map((id) => byId.get(id)) - .find((candidate) => candidate !== undefined); + 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 diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index fcba6b0ea2..f4294a5abf 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -431,6 +431,18 @@ describe('server-v2 /api/v1 plugins', () => { 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 () => { From 1a5bca60f90f8bfd0777382f4dfe63ea40a76a17 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 02:12:01 +0800 Subject: [PATCH 39/46] fix(kap-server): put the github metadata of plugin summaries on the wire schema GitHub-sourced plugin summaries carry github {owner, repo, ref, installedSha} from the domain; the route serializes raw domain objects, so the field reached clients undocumented. Declare it in pluginSummarySchema so the OpenAPI surface matches reality. --- packages/kap-server/src/protocol/rest-plugin.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/kap-server/src/protocol/rest-plugin.ts b/packages/kap-server/src/protocol/rest-plugin.ts index d10e76bae8..fa3003cfeb 100644 --- a/packages/kap-server/src/protocol/rest-plugin.ts +++ b/packages/kap-server/src/protocol/rest-plugin.ts @@ -7,6 +7,17 @@ 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(), @@ -21,6 +32,7 @@ export const pluginSummarySchema = z.object({ hasErrors: z.boolean(), source: z.enum(['local-path', 'zip-url', 'github']), originalSource: z.string().optional(), + github: pluginGithubMetadataSchema.optional(), }); export type PluginSummaryWire = z.infer; From c2ba69113c5238a63696686f62487265bcff7ed6 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 02:19:08 +0800 Subject: [PATCH 40/46] test(node-sdk): cover the new lifecycle events in the exhaustive switch The event-type exhaustiveness test broke when the shared protocol union gained event.plugin.changed / event.capability.changed. --- packages/node-sdk/test/session-event-types.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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': From 5c624c7bc4655234c3a642195b85ebd8c87dc432 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 02:37:09 +0800 Subject: [PATCH 41/46] fix(kap-server): mark capability progress events volatile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-chunk download progress transitions ride the same fan-out as durable frames and were being persisted to the __global__ journal — hundreds of stale frames per install. event.capability.changed is live-only state, so it joins the volatile list alongside event.di.unit_changed; the settle frame stays recoverable via a direct capability read. event.plugin.changed remains durable (rare, and a reconnecting client should replay it). --- packages/kap-server/src/transport/ws/v1/events.ts | 4 ++++ packages/kap-server/test/sessionEventBroadcaster.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 1749a7bd0e..dbf8c31aae 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -253,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/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 33c78f3dbc..0fae462e9b 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -1220,6 +1220,10 @@ describe('SessionEventBroadcaster', () => { 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 () => { From 7fd9cd3453c6ad8f4bb019e3096f4959c82b0391 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 02:53:40 +0800 Subject: [PATCH 42/46] feat(kap-server): inject built-in capability rows into the default catalog response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checked-in production catalog carries kimi-webbridge but not kimi-cu — the CLI injects built-in rows client-side, so wire clients never saw Kimi Computer Use in /plugins/marketplace. For the default catalog the route now appends supported capabilities the catalog lacks (static descriptors via ICapabilityService.describeCapabilities — no detector probes), marked with capabilityId and a capability: sentinel source so installs still route through the capability surface. --- .../src/app/capability/capability.ts | 5 +++- .../src/app/capability/capabilityService.ts | 11 ++++++++ .../agent-core-v2/src/app/capability/types.ts | 8 ++++++ .../app/capability/capabilityService.test.ts | 12 ++++++++- packages/kap-server/src/routes/plugins.ts | 25 +++++++++++++++++++ packages/kap-server/test/plugins.test.ts | 9 +++++++ 6 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/app/capability/capability.ts b/packages/agent-core-v2/src/app/capability/capability.ts index b252d9f048..83fcf96b56 100644 --- a/packages/agent-core-v2/src/app/capability/capability.ts +++ b/packages/agent-core-v2/src/app/capability/capability.ts @@ -11,13 +11,16 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; -import type { CapabilityInstallChange, CapabilityStatus } from './types'; +import type { CapabilityDescriptor, CapabilityInstallChange, CapabilityStatus } from './types'; export interface ICapabilityService { readonly _serviceBrand: undefined; readonly onDidChangeInstall: Event; + /** Static registry descriptors — no detection probes run. */ + 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 307939c225..e58343e87e 100644 --- a/packages/agent-core-v2/src/app/capability/capabilityService.ts +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -29,6 +29,7 @@ import { createKimiWebbridgeEntry } from './entries/kimiWebbridge'; import type { CapabilityEntry, CapabilityId, + CapabilityDescriptor, CapabilityInstallChange, CapabilityInstallProgress, CapabilityReadiness, @@ -81,6 +82,16 @@ export class CapabilityService extends Disposable 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))); } diff --git a/packages/agent-core-v2/src/app/capability/types.ts b/packages/agent-core-v2/src/app/capability/types.ts index 46908fe482..ae2dfdee08 100644 --- a/packages/agent-core-v2/src/app/capability/types.ts +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -50,6 +50,14 @@ 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; 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 d8299b3479..8d3d259277 100644 --- a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts +++ b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts @@ -213,6 +213,16 @@ 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({ @@ -237,7 +247,7 @@ describe('CapabilityService', () => { 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[seen.length - 1]).toEqual({ id: 'kimi-cu', install: { running: false } }); + expect(seen.at(-1)).toEqual({ id: 'kimi-cu', install: { running: false } }); }); it('surfaces an install note from the entry through progress', async () => { diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index 45e5e90d3b..db9ddb1066 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -38,6 +38,7 @@ import { resolve } from 'node:path'; import { computeUpdateStatus, ErrorCodes as DomainErrorCodes, + ICapabilityService, IPluginService, PluginErrors, isError2, @@ -233,6 +234,30 @@ export function registerPluginsRoutes( capabilityId: capabilityRow?.capabilityId, }; }); + // 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. capabilityId routes their install through + // `/capabilities/{id}:install`; the `capability:` source is a + // sentinel, never a valid plain-plugin source. + if (opts.marketplaceIsDefault === true) { + const presentIds = new Set(entries.map((entry) => entry.id)); + for (const descriptor of core.accessor.get(ICapabilityService).describeCapabilities()) { + if (!descriptor.supported || presentIds.has(descriptor.id)) continue; + entries.push({ + id: descriptor.id, + tier: 'official', + displayName: descriptor.displayName, + description: descriptor.description, + homepage: undefined, + keywords: undefined, + version: undefined, + source: `capability:${descriptor.id}`, + installed: undefined, + updateAvailable: undefined, + capabilityId: descriptor.id, + }); + } + } reply.send(okEnvelope({ entries }, req.id)); }, ); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index f4294a5abf..bde3b635fa 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -325,6 +325,8 @@ describe('server-v2 /api/v1 plugins', () => { 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'); @@ -550,6 +552,13 @@ describe('server-v2 /api/v1 plugins', () => { // 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 + // (kimi-cu is not in the checked-in catalog). + const cu = body.data.entries.find((e) => e.id === 'kimi-cu'); + 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'); }); it('expands ~ in local catalog paths like the CLI loader', async () => { From f11c4ad5ac989564b725653ccee8d3889772fd7d Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 03:07:39 +0800 Subject: [PATCH 43/46] fix(kap-server): run injected capability rows through the install-state join The injected kimi-cu row hardcoded installed: undefined, so an already-installed capability still read as installable. Injection now happens before projection, so injected rows get the same backing-plugin join (installed state, update badge, capabilityId marker) as catalog rows. Also moves the describeCapabilities note into the contract header (package comment convention). --- .../src/app/capability/capability.ts | 3 +- packages/kap-server/src/routes/plugins.ts | 46 +++++++++---------- packages/kap-server/test/plugins.test.ts | 24 ++++++++-- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/agent-core-v2/src/app/capability/capability.ts b/packages/agent-core-v2/src/app/capability/capability.ts index 83fcf96b56..68ce444ac1 100644 --- a/packages/agent-core-v2/src/app/capability/capability.ts +++ b/packages/agent-core-v2/src/app/capability/capability.ts @@ -6,6 +6,8 @@ * are hardcoded in a closed registry — install sources are fixed official * 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'; @@ -18,7 +20,6 @@ export interface ICapabilityService { readonly onDidChangeInstall: Event; - /** Static registry descriptors — no detection probes run. */ describeCapabilities(): readonly CapabilityDescriptor[]; listCapabilities(): Promise; diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index db9ddb1066..c8b1d3ff6d 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -199,6 +199,28 @@ export function registerPluginsRoutes( ); 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])); @@ -234,30 +256,6 @@ export function registerPluginsRoutes( capabilityId: capabilityRow?.capabilityId, }; }); - // 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. capabilityId routes their install through - // `/capabilities/{id}:install`; the `capability:` source is a - // sentinel, never a valid plain-plugin source. - if (opts.marketplaceIsDefault === true) { - const presentIds = new Set(entries.map((entry) => entry.id)); - for (const descriptor of core.accessor.get(ICapabilityService).describeCapabilities()) { - if (!descriptor.supported || presentIds.has(descriptor.id)) continue; - entries.push({ - id: descriptor.id, - tier: 'official', - displayName: descriptor.displayName, - description: descriptor.description, - homepage: undefined, - keywords: undefined, - version: undefined, - source: `capability:${descriptor.id}`, - installed: undefined, - updateAvailable: undefined, - capabilityId: descriptor.id, - }); - } - } reply.send(okEnvelope({ entries }, req.id)); }, ); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index bde3b635fa..72e63ee072 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -539,10 +539,15 @@ describe('server-v2 /api/v1 plugins', () => { }); base = `http://127.0.0.1:${server.port}`; - const { body } = await call<{ entries: { id: string; source: string; capabilityId?: string }[] }>( - 'GET', - '/api/v1/plugins/marketplace', - ); + 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. @@ -559,6 +564,17 @@ describe('server-v2 /api/v1 plugins', () => { 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 () => { From d4b3b3a72b9b89bf8783cd4d31915ab3368831cb Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 03:32:21 +0800 Subject: [PATCH 44/46] test(kap-server): gate the injected-row assertions on platform support kimi-cu injects only where supported (macOS / Windows x64); on Linux CI the row is correctly absent. --- packages/kap-server/test/plugins.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 72e63ee072..527d3b4e0b 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -558,8 +558,14 @@ describe('server-v2 /api/v1 plugins', () => { 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 - // (kimi-cu is not in the checked-in catalog). + // 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'); From aef9891c29359184d989d8452cdb8b63da88bf02 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 13:29:12 +0800 Subject: [PATCH 45/46] fix(protocol): classify capability progress as volatile in the shared catalog kap-server never journals event.capability.changed (it is in the server-local volatile list); shared-protocol clients reading isVolatileEventType would treat per-chunk progress frames as durable and replayable. Mirror the classification. --- packages/protocol/src/__tests__/snapshot.test.ts | 3 ++- packages/protocol/src/events.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 4441c7d5cd..e466bcd983 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -1965,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]; From 04dc2af42f3c36a72e5198a9eb3466f2a976edaa Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 14 Aug 2026 13:47:34 +0800 Subject: [PATCH 46/46] fix(kap-server): hide capability rows on unsupported platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog-carried capability rows (kimi-webbridge in the default catalog) were marked with capabilityId regardless of host support — on an unsupported platform clients would route into an impossible capability install. Rows whose capability is unsupported are now excluded from the default-catalog response entirely (the CLI hides its built-in rows the same way). --- packages/kap-server/src/routes/plugins.ts | 25 +++++++++++++++++++---- packages/kap-server/test/plugins.test.ts | 11 ++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts index c8b1d3ff6d..63a7d79ea5 100644 --- a/packages/kap-server/src/routes/plugins.ts +++ b/packages/kap-server/src/routes/plugins.ts @@ -224,9 +224,26 @@ export function registerPluginsRoutes( marketplace = await withLatestVersions(marketplace, fetchImpl); const installed = await core.accessor.get(IPluginService).listPlugins(); const byId = new Map(installed.map((p) => [p.id, p])); - const entries: PluginMarketplaceEntryWire[] = marketplace.plugins.map((entry) => { + // 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 = @@ -242,7 +259,7 @@ export function registerPluginsRoutes( const updateAvailable = computeUpdateStatus(entry.version, record?.version, record !== undefined).kind === 'update'; - return { + entries.push({ id: entry.id, tier: entry.tier ?? 'third-party', displayName: entry.displayName, @@ -254,8 +271,8 @@ export function registerPluginsRoutes( installed: installedInfo, updateAvailable: updateAvailable ? true : undefined, capabilityId: capabilityRow?.capabilityId, - }; - }); + }); + } reply.send(okEnvelope({ entries }, req.id)); }, ); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 527d3b4e0b..7d10ef179b 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -423,6 +423,17 @@ describe('server-v2 /api/v1 plugins', () => { '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');