feat(kap-server): add plugin marketplace and capability REST routes (#2868)

* feat(agent-core-v2): surface a machine-key note from capability installs

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.

* feat(kap-server): add plugin management and capability REST routes

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).

* 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)

* fix(kap-server): resolve catalog-relative marketplace sources and widen the unsupported-test skip

- 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.

* 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.

* 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.

* 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.

* test(kap-server): keep the new route tests portable to Windows x64

- 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.

* 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.

* 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.

* fix(kap-server): expand home-relative marketplace entry sources

A catalog entry with source '~/...' fell through to the catalog-relative
branch and came back as <catalog-dir>/~/... — unresolvable by POST
/plugins. Expand ~ via the shared helper before the absolute/relative
decision.

* 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.

* 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.

* fix(kap-server): derive marketplace versions from GitHub release sources

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).

* 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').

* fix(kap-server): resolve latest versions for bare GitHub marketplace entries

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.

* docs(kap-server): note the marketplace version resolution in the plugins route header

* feat(kap-server): mark capability wiring rows in the marketplace response

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).

* fix(kap-server): fall back to the source-checkout catalog for the default location

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.

* fix(kap-server): resolve fallback catalog sources against the fallback file

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.

* 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.

* 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.

* fix(kap-server): treat a blank or non-string marketplace version as missing

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.

* fix(kap-server): trim marketplace entry ids before the install-state join

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.

* 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.

* 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.

* fix(kap-server): validate marketplace entry types and count the dev server as default

- 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.

* 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.

* fix(kap-server): map plugin load failures to 40001

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.

* 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.

* refactor(agent-core-v2): share the plugin marketplace client/parser across hosts

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).

* 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.

* 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.

* 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).

* feat(kap-server): fan plugin and capability lifecycle out as global WS events

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.

* 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).

* 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.

* 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.

* 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.

* 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.

* fix(kap-server): mark capability progress events volatile

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).

* feat(kap-server): inject built-in capability rows into the default catalog response

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:<id>
sentinel source so installs still route through the capability
surface.

* 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).

* 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.

* 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.

* fix(kap-server): hide capability rows on unsupported platforms

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).
This commit is contained in:
qer 2026-08-14 15:45:34 +08:00 committed by GitHub
parent 249d8faa34
commit 741708f948
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2397 additions and 430 deletions

View file

@ -85,8 +85,14 @@ export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`;
// bodies, and the CDN install scripts read it for fresh installs.
export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json`;
export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json';
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`;
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
// The marketplace catalog location constants live in the shared
// agent-core-v2 plugin domain (kap-server consumes them from there).
// Deep-path import: this module is evaluated on every CLI invocation, so it
// must not pull in the engine root.
export {
KIMI_CODE_PLUGIN_MARKETPLACE_URL,
KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV,
} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace';
// Official plugins whose usage bills against the user's plan quota. Installing
// one of these shows a quota note after the install result.
export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource'];

View file

@ -1,77 +1,39 @@
import { readFile, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* `#/utils/plugin-marketplace` CLI-side wrapper over the shared plugin
* marketplace client/parser (`@moonshot-ai/agent-core-v2`,
* `app/plugin/marketplace`). The shared module owns catalog reading, the
* lenient entry normalization, source resolution, and version derivation;
* this wrapper adds only the CLI's configured-source resolution (option
* env production default), the source-checkout fallback for offline dev,
* and the caller-supplied built-in capability entry injection.
*/
import { gt, valid } from 'semver';
import { stat } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
parsePluginMarketplace,
readPluginMarketplace,
withBuiltInEntries,
withLatestVersions,
type MarketplaceLocation,
type PluginMarketplace,
type PluginMarketplaceEntry,
} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace';
import {
KIMI_CODE_PLUGIN_MARKETPLACE_URL,
KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV,
} from '#/constant/app';
export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const;
export type PluginMarketplaceTier = (typeof PLUGIN_MARKETPLACE_TIERS)[number];
export interface PluginMarketplaceEntry {
readonly id: string;
readonly displayName: string;
readonly source: string;
readonly tier?: PluginMarketplaceTier;
readonly version?: string;
readonly description?: string;
readonly homepage?: string;
readonly keywords?: readonly string[];
/**
* Internal provenance flag for client-injected built-in rows. The catalog
* parser builds entries field-by-field and never sets it, so a custom
* catalog cannot forge it (unlike the `capability:<id>` source string).
*/
readonly builtIn?: boolean;
}
export interface PluginMarketplace {
readonly source: string;
readonly version?: string;
readonly plugins: readonly PluginMarketplaceEntry[];
}
export type PluginUpdateStatus =
| { readonly kind: 'not-installed' }
| { readonly kind: 'up-to-date'; readonly version?: string }
| { readonly kind: 'update'; readonly local: string; readonly latest: string };
/**
* Compare a marketplace entry's (latest) version against the locally installed
* version. Only reports `update` when both are valid semver and latest > local,
* so a stale or non-semver version never produces a spurious or downgrading prompt.
*/
export function computeUpdateStatus(
latest: string | undefined,
local: string | undefined,
installed: boolean,
): PluginUpdateStatus {
if (!installed) return { kind: 'not-installed' };
if (
latest !== undefined &&
local !== undefined &&
valid(latest) !== null &&
valid(local) !== null &&
gt(latest, local)
) {
return { kind: 'update', local, latest };
}
// Report only the actual installed version. When it is unknown, don't borrow the
// marketplace version — that would falsely claim "up to date" and hide future updates.
return { kind: 'up-to-date', version: local };
}
interface MarketplaceLocation {
readonly raw: string;
readonly kind: 'remote' | 'local';
readonly resolved: string;
}
export {
computeUpdateStatus,
PLUGIN_MARKETPLACE_TIERS,
type PluginMarketplace,
type PluginMarketplaceEntry,
type PluginMarketplaceTier,
type MarketplaceUpdateStatus,
} from '@moonshot-ai/agent-core-v2/app/plugin/marketplace';
export interface LoadPluginMarketplaceOptions {
readonly workDir: string;
@ -89,352 +51,37 @@ export async function loadPluginMarketplace(
options: LoadPluginMarketplaceOptions,
): Promise<PluginMarketplace> {
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<PluginMarketplace> {
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<MarketplaceLocation | undefined> {
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<string> {
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<string, unknown>, 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<string, unknown>,
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: /<owner>/<repo>/<tail...>. Recognized tails:
// releases/tag/<tag>
// tree/<ref>
// commit/<sha>
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<string | undefined> {
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 (/<owner>/<repo>) 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<string | undefined> {
// 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<string, unknown>, 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<string, unknown>, 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<string, unknown>,
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function formatParseError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -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",

View file

@ -4,16 +4,24 @@
* Manages the built-in product capabilities (`kimi-cu`, `kimi-webbridge`):
* layered readiness detection and idempotent install orchestration. Entries
* are hardcoded in a closed registry install sources are fixed official
* CDN URLs, never client-supplied.
* CDN URLs, never client-supplied. Install progress transitions are published
* through `onDidChangeInstall` (start / step / settle / error).
* `describeCapabilities` answers the static registry without running any
* detection probes.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
import type { CapabilityStatus } from './types';
import type { CapabilityDescriptor, CapabilityInstallChange, CapabilityStatus } from './types';
export interface ICapabilityService {
readonly _serviceBrand: undefined;
readonly onDidChangeInstall: Event<CapabilityInstallChange>;
describeCapabilities(): readonly CapabilityDescriptor[];
listCapabilities(): Promise<readonly CapabilityStatus[]>;
getCapability(id: string): Promise<CapabilityStatus>;

View file

@ -2,17 +2,20 @@
* `capability` domain (L3) `ICapabilityService` implementation.
*
* Holds the closed registry of built-in capability entries and serializes
* install runs per entry. Install progress lives in memory only and is
* polled by clients; a failed attempt leaves its error in the progress state
* until the next attempt starts and logs the failure through `log`. Listing
* degrades a single entry's failing detection to a failed step on that entry
* instead of rejecting the whole list. Bound at App scope.
* install runs per entry. Install progress lives in memory only; clients poll
* it or subscribe to `onDidChangeInstall` (fired on every transition), and a
* failed attempt leaves its error in the progress state until the next
* attempt starts and logs the failure through `log`. Listing degrades a
* single entry's failing detection to a failed step on that entry instead of
* rejecting the whole list. Bound at App scope.
*/
import { homedir } from 'node:os';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { ILogService } from '#/_base/log/log';
import { Error2 } from '#/errors';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
@ -26,6 +29,8 @@ import { createKimiWebbridgeEntry } from './entries/kimiWebbridge';
import type {
CapabilityEntry,
CapabilityId,
CapabilityDescriptor,
CapabilityInstallChange,
CapabilityInstallProgress,
CapabilityReadiness,
CapabilityStatus,
@ -33,13 +38,24 @@ import type {
const IDLE_PROGRESS: CapabilityInstallProgress = { running: false };
export class CapabilityService implements ICapabilityService {
export class CapabilityService extends Disposable implements ICapabilityService {
declare readonly _serviceBrand: undefined;
private readonly onDidChangeInstallEmitter = this._register(
new Emitter<CapabilityInstallChange>(),
);
readonly onDidChangeInstall: Event<CapabilityInstallChange> =
this.onDidChangeInstallEmitter.event;
private readonly entries: ReadonlyMap<CapabilityId, CapabilityEntry>;
private readonly installProgress = new Map<CapabilityId, CapabilityInstallProgress>();
private readonly runningInstalls = new Set<CapabilityId>();
private setInstallProgress(id: CapabilityId, progress: CapabilityInstallProgress): void {
this.installProgress.set(id, progress);
this.onDidChangeInstallEmitter.fire({ id, install: progress });
}
constructor(
@IBootstrapService bootstrap: IBootstrapService,
@IPluginService plugins: IPluginService,
@ -47,6 +63,7 @@ export class CapabilityService implements ICapabilityService {
@ILogService private readonly log: ILogService,
entriesOverride?: readonly CapabilityEntry[],
) {
super();
if (entriesOverride !== undefined) {
this.entries = new Map(entriesOverride.map((entry) => [entry.id, entry]));
} else {
@ -65,6 +82,16 @@ export class CapabilityService implements ICapabilityService {
}
}
describeCapabilities(): readonly CapabilityDescriptor[] {
return [...this.entries.values()].map((entry) => ({
id: entry.id,
pluginId: entry.pluginId,
displayName: entry.displayName,
description: entry.description,
supported: entry.supported,
}));
}
listCapabilities(): Promise<readonly CapabilityStatus[]> {
return Promise.all([...this.entries.values()].map((entry) => this.statusOfSafe(entry)));
}
@ -90,16 +117,16 @@ export class CapabilityService implements ICapabilityService {
}
this.runningInstalls.add(entry.id);
this.installProgress.set(entry.id, { running: true });
this.setInstallProgress(entry.id, { running: true });
void (async () => {
try {
await entry.install((step, percent) => {
this.installProgress.set(
const note = await entry.install((step, percent) => {
this.setInstallProgress(
entry.id,
percent === undefined ? { running: true, step } : { running: true, step, percent },
);
});
this.installProgress.set(entry.id, { running: false });
this.setInstallProgress(entry.id, { running: false, note });
} catch (error) {
const step = this.installProgress.get(entry.id)?.step;
this.log.warn('capability install failed', {
@ -107,7 +134,7 @@ export class CapabilityService implements ICapabilityService {
step,
error,
});
this.installProgress.set(entry.id, {
this.setInstallProgress(entry.id, {
running: false,
error: error instanceof Error ? error.message : String(error),
});

View file

@ -433,7 +433,7 @@ function createMacKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry {
}
}
async function install(report: CapabilityInstallReporter): Promise<void> {
async function install(report: CapabilityInstallReporter): Promise<string | undefined> {
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<void> {
async function install(report: CapabilityInstallReporter): Promise<string | undefined> {
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 {

View file

@ -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<void> {
async function install(report: CapabilityInstallReporter): Promise<string | undefined> {
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(

View file

@ -27,6 +27,7 @@ export interface CapabilityInstallProgress {
readonly step?: string;
readonly percent?: number;
readonly error?: string;
readonly note?: string;
}
export interface CapabilityDetectResult {
@ -49,6 +50,19 @@ export interface CapabilityStatus {
export type CapabilityInstallReporter = (step: string, percent?: number) => void;
export interface CapabilityDescriptor {
readonly id: CapabilityId;
readonly pluginId?: string;
readonly displayName: string;
readonly description: string;
readonly supported: boolean;
}
export interface CapabilityInstallChange {
readonly id: CapabilityId;
readonly install: CapabilityInstallProgress;
}
export interface CapabilityEntry {
readonly id: CapabilityId;
readonly pluginId?: string;
@ -56,5 +70,5 @@ export interface CapabilityEntry {
readonly description: string;
readonly supported: boolean;
detect(): Promise<CapabilityDetectResult>;
install(report: CapabilityInstallReporter): Promise<void>;
install(report: CapabilityInstallReporter): Promise<string | undefined>;
}

View file

@ -0,0 +1,394 @@
/**
* `plugin` domain plugin marketplace catalog client and parser.
*
* Loads and normalizes the plugin marketplace catalog (`marketplace.json`)
* for every host (CLI panel, kap-server REST route). The catalog format is a
* public, hand-writable contract, so parsing is deliberately lenient: legacy
* field aliases (`url`/`downloadUrl`, `name`/`shortDescription`/`websiteURL`)
* are honored, blank strings read as missing, `keywords` keeps only non-blank
* strings, and `type`/`tier` validate against the accepted vocabulary
* (`plugin` plus legacy `managed`/`guide`; `official`/`curated`). Entry
* sources may be http(s), GitHub repo/ref URLs, `file://`, absolute paths,
* `~`-relative, or catalog-relative (`./official/*.zip`) all resolve to a
* directly installable form here. Entries without a `version` get one from a
* GitHub ref tail (`releases/tag/<tag>`, `tree/<ref>`, `commit/<sha>`
* semver-shaped refs only), or from the bare repo's latest release via the
* `/releases/latest` redirect (a UI route, deliberately never the
* rate-limited api.github.com). `computeUpdateStatus` reports an update only
* on strict semver latest > installed, and never borrows the catalog version
* for an unknown local one. `withBuiltInEntries` masks same-id catalog rows
* with client-injected built-in capability entries (taking only their
* version), so what those ids mean stays bound to the client release; the
* `builtIn` flag is set only by that path, never from catalog data.
* `readPluginMarketplace` returns the location actually read, because entry
* sources resolve against it including the host-supplied source-checkout
* fallback, which hosts pass only when the catalog location is the built-in
* default (an explicitly configured catalog fails hard). No DI collaborators
* pure functions over `fetch`/`fs`.
*/
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gt, valid } from 'semver';
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL =
'https://code.kimi.com/kimi-code/plugins/marketplace.json';
export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL';
export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const;
export type PluginMarketplaceTier = (typeof PLUGIN_MARKETPLACE_TIERS)[number];
export interface PluginMarketplaceEntry {
readonly id: string;
readonly displayName: string;
readonly source: string;
readonly tier?: PluginMarketplaceTier;
readonly version?: string;
readonly description?: string;
readonly homepage?: string;
readonly keywords?: readonly string[];
readonly builtIn?: boolean;
}
export interface PluginMarketplace {
readonly source: string;
readonly version?: string;
readonly plugins: readonly PluginMarketplaceEntry[];
}
export type MarketplaceUpdateStatus =
| { readonly kind: 'not-installed' }
| { readonly kind: 'up-to-date'; readonly version?: string }
| { readonly kind: 'update'; readonly local: string; readonly latest: string };
export interface MarketplaceLocation {
readonly raw: string;
readonly kind: 'remote' | 'local';
readonly resolved: string;
}
export interface ReadPluginMarketplaceOptions {
readonly source: string;
readonly workDir: string;
readonly fetchImpl?: typeof fetch;
readonly sourceCheckoutLocation?: () => Promise<MarketplaceLocation | undefined>;
}
export function computeUpdateStatus(
latest: string | undefined,
local: string | undefined,
installed: boolean,
): MarketplaceUpdateStatus {
if (!installed) return { kind: 'not-installed' };
if (
latest !== undefined &&
local !== undefined &&
valid(latest) !== null &&
valid(local) !== null &&
gt(latest, local)
) {
return { kind: 'update', local, latest };
}
return { kind: 'up-to-date', version: local };
}
export function resolveMarketplaceLocation(source: string, workDir: string): MarketplaceLocation {
const trimmed = source.trim();
if (trimmed.length === 0) {
throw new Error(`${KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`);
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return { raw: trimmed, kind: 'remote', resolved: trimmed };
}
if (trimmed.startsWith('file://')) {
const path = fileURLToPath(trimmed);
return { raw: trimmed, kind: 'local', resolved: path };
}
return { raw: trimmed, kind: 'local', resolved: resolveLocalPath(trimmed, workDir) };
}
export async function readPluginMarketplace(
options: ReadPluginMarketplaceOptions,
): Promise<{ raw: string; location: MarketplaceLocation }> {
const location = resolveMarketplaceLocation(options.source, options.workDir);
const fetchImpl = options.fetchImpl ?? fetch;
try {
return { raw: await readMarketplaceText(location, fetchImpl), location };
} catch (error) {
const fallback =
options.sourceCheckoutLocation !== undefined
? await options.sourceCheckoutLocation()
: undefined;
if (fallback === undefined) throw error;
return { raw: await readMarketplaceText(fallback, fetchImpl), location: fallback };
}
}
export function parsePluginMarketplace(raw: string, location: MarketplaceLocation): PluginMarketplace {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Plugin marketplace is not valid JSON: ${formatParseError(error)}`, {
cause: error,
});
}
if (!isRecord(parsed)) {
throw new TypeError('Plugin marketplace must be an object.');
}
const rawPlugins = parsed['plugins'];
if (!Array.isArray(rawPlugins)) {
throw new TypeError('Plugin marketplace must contain a "plugins" array.');
}
return {
source: location.resolved,
version: stringField(parsed, 'version'),
plugins: rawPlugins.map((entry, index) => parseMarketplaceEntry(entry, index, location)),
};
}
export function withBuiltInEntries(
marketplace: PluginMarketplace,
builtIns: readonly PluginMarketplaceEntry[],
): PluginMarketplace {
const builtInIds = new Set(builtIns.map((entry) => entry.id));
const catalogById = new Map(marketplace.plugins.map((entry) => [entry.id, entry]));
const catalog = marketplace.plugins.filter((entry) => !builtInIds.has(entry.id));
const enrichedBuiltIns = builtIns.map((entry) => {
const version = catalogById.get(entry.id)?.version;
return version === undefined ? entry : { ...entry, version };
});
return { ...marketplace, plugins: [...catalog, ...enrichedBuiltIns] };
}
export async function withLatestVersions(
marketplace: PluginMarketplace,
fetchImpl: typeof fetch,
): Promise<PluginMarketplace> {
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<string> {
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<string, unknown>, 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<string, unknown>,
id: string,
): PluginMarketplaceTier | undefined {
const raw = value['tier'];
if (raw === undefined) return undefined;
if (typeof raw !== 'string') {
throw new TypeError(`Plugin marketplace entry ${id} "tier" must be a string.`);
}
const tier = raw.trim();
if (tier.length === 0) return undefined;
if ((PLUGIN_MARKETPLACE_TIERS as readonly string[]).includes(tier)) {
return tier as PluginMarketplaceTier;
}
throw new Error(
`Plugin marketplace entry ${id} "tier" must be one of: ${PLUGIN_MARKETPLACE_TIERS.join(', ')}.`,
);
}
function resolveEntrySource(source: string, location: MarketplaceLocation): string {
const trimmed = source.trim();
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
if (trimmed.startsWith('file://')) return fileURLToPath(trimmed);
if (trimmed === '~' || trimmed.startsWith('~/')) {
return resolveLocalPath(trimmed, '');
}
if (isAbsolute(trimmed)) return trimmed;
if (location.kind === 'remote') {
return new URL(trimmed, location.resolved).toString();
}
return resolve(dirname(location.resolved), trimmed);
}
function deriveVersionFromGithubSource(source: string): string | undefined {
let url: URL;
try {
url = new URL(source);
} catch {
return undefined;
}
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') {
return undefined;
}
const [, , kind, a, b] = url.pathname.split('/').filter(Boolean);
const ref =
kind === 'releases' && a === 'tag' ? b : kind === 'tree' || kind === 'commit' ? a : undefined;
if (ref === undefined) return undefined;
let decoded: string;
try {
decoded = decodeURIComponent(ref);
} catch {
decoded = ref;
}
const candidate = decoded.replace(/^v/i, '');
return valid(candidate) !== null ? candidate : undefined;
}
async function resolveLatestGithubRelease(
source: string,
fetchImpl: typeof fetch,
): Promise<string | undefined> {
const repo = parseGithubRepo(source);
if (repo === undefined) return undefined;
try {
const tag = await fetchLatestReleaseTag(repo.owner, repo.repo, fetchImpl);
if (tag === undefined) return undefined;
const candidate = tag.replace(/^v/i, '');
return valid(candidate) !== null ? candidate : undefined;
} catch {
return undefined;
}
}
function parseGithubRepo(source: string): { owner: string; repo: string } | undefined {
let url: URL;
try {
url = new URL(source);
} catch {
return undefined;
}
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined;
const segments = url.pathname.split('/').filter(Boolean);
if (segments.length !== 2) return undefined;
const [owner, repo] = segments;
return { owner: owner!, repo: repo! };
}
async function fetchLatestReleaseTag(
owner: string,
repo: string,
fetchImpl: typeof fetch,
): Promise<string | undefined> {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>,
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function formatParseError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -213,6 +213,7 @@ export * from '#/app/plugin/source';
export * from '#/app/plugin/github-resolver';
export * from '#/app/plugin/archive';
export * from '#/app/plugin/manager';
export * from '#/app/plugin/marketplace';
export * from '#/app/plugin/plugin';
export * from '#/app/plugin/pluginService';
export * from '#/app/capability/capability';

View file

@ -23,7 +23,7 @@ function fakeEntry(overrides: {
pluginId?: string;
supported?: boolean;
detect?: CapabilityDetectResult;
install?: (report: CapabilityInstallReporter) => Promise<void>;
install?: (report: CapabilityInstallReporter) => Promise<string | undefined>;
}): 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<void>((resolve) => {
return new Promise<string | undefined>((resolve) => {
release = () => {
resolve();
resolve(undefined);
};
});
},
@ -213,6 +213,59 @@ describe('CapabilityService', () => {
expect.unreachable('install never settled');
});
it('describes the registry without running detectors', async () => {
const service = fakeService([
fakeEntry({ id: 'kimi-cu', supported: true }),
fakeEntry({ id: 'kimi-webbridge', supported: false }),
]);
const descriptors = service.describeCapabilities();
expect(descriptors.map((d) => d.id)).toEqual(['kimi-cu', 'kimi-webbridge']);
expect(descriptors.find((d) => d.id === 'kimi-webbridge')?.supported).toBe(false);
});
it('emits onDidChangeInstall on every progress transition', async () => {
const service = fakeService([
fakeEntry({
id: 'kimi-cu',
install: (report) => {
report('download', 42);
return Promise.resolve(undefined);
},
}),
]);
const seen: Array<{ id: string; install: { running: boolean; step?: string } }> = [];
service.onDidChangeInstall((change) => {
seen.push({ id: change.id, install: change.install });
});
await service.installCapability('kimi-cu');
for (let i = 0; i < 50; i += 1) {
const status = await service.getCapability('kimi-cu');
if (!status.install.running) break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(seen[0]).toEqual({ id: 'kimi-cu', install: { running: true } });
expect(seen).toContainEqual({ id: 'kimi-cu', install: { running: true, step: 'download', percent: 42 } });
expect(seen.at(-1)).toEqual({ id: 'kimi-cu', install: { running: false } });
});
it('surfaces an install note from the entry through progress', async () => {
const service = fakeService([
fakeEntry({
id: 'kimi-cu',
install: () => Promise.resolve('user-skill-migrated'),
}),
]);
await service.installCapability('kimi-cu');
for (let i = 0; i < 50; i += 1) {
const status = await service.getCapability('kimi-cu');
if (!status.install.running) break;
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect((await service.getCapability('kimi-cu')).install.note).toBe('user-skill-migrated');
});
it('surfaces install errors through progress until the next attempt', async () => {
let attempts = 0;
const service = fakeService([
@ -222,7 +275,7 @@ describe('CapabilityService', () => {
attempts += 1;
return attempts === 1
? Promise.reject(new Error('boom'))
: Promise.resolve();
: Promise.resolve(undefined);
},
}),
]);

View file

@ -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 () => {

View file

@ -34,6 +34,7 @@
"bcryptjs": "^2.4.3",
"fastify": "^5.1.0",
"pino": "^9.5.0",
"semver": "^7.7.4",
"smol-toml": "^1.6.1",
"ulid": "^3.0.1",
"ws": "^8.18.0",
@ -41,6 +42,7 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/semver": "^7.7.0",
"@types/ws": "^8.18.0",
"tsx": "^4.21.0"
}

View file

@ -72,6 +72,10 @@ export const ErrorCode = {
TOOL_CALL_NOT_FOUND: 40416,
/** 目录models.dev catalog中不存在该条目 */
CATALOG_ENTRY_NOT_FOUND: 40417,
/** capability_id 不存在 */
CAPABILITY_NOT_FOUND: 40418,
/** plugin_id 不存在 */
PLUGIN_NOT_FOUND: 40419,
/** session 有正在进行的 prompt拒绝新请求 */
SESSION_BUSY: 40901,
@ -120,6 +124,10 @@ export const ErrorCode = {
PAGE_TOKEN_MISMATCH: 40922,
/** 会话标题生成不可用flag 未开 / 无 managed OAuth 登录 / 还没有 prompt / 后端失败) */
SESSION_TITLE_UNAVAILABLE: 40923,
/** capability 正在安装中,拒绝并发安装 */
CAPABILITY_INSTALL_IN_PROGRESS: 40924,
/** 当前平台/架构不支持该 capability */
CAPABILITY_UNSUPPORTED: 40925,
/** approval 60s 超时 */
APPROVAL_EXPIRED: 41001,

View file

@ -628,6 +628,22 @@ export const configWarningEventSchema = z.object({
),
});
export const pluginChangedEventSchema = z.object({
type: z.literal('event.plugin.changed'),
});
export const capabilityChangedEventSchema = z.object({
type: z.literal('event.capability.changed'),
capability_id: z.string(),
install: z.object({
running: z.boolean(),
step: z.string().optional(),
percent: z.number().optional(),
error: z.string().optional(),
note: z.string().optional(),
}),
});
export const diUnitChangedEventSchema = z.object({
type: z.literal('event.di.unit_changed'),
scope: z.string().min(1),
@ -965,6 +981,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [
sessionWorkChangedEventSchema,
sessionStatusChangedEventSchema,
diUnitChangedEventSchema,
pluginChangedEventSchema,
capabilityChangedEventSchema,
goalUpdatedEventSchema,
skillActivatedEventSchema,
pluginCommandActivatedEventSchema,

View file

@ -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<typeof capabilityStepSchema>;
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<typeof capabilityInstallProgressSchema>;
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<typeof capabilityStatusSchema>;
export const listCapabilitiesResponseSchema = z.object({
capabilities: z.array(capabilityStatusSchema),
});
export type ListCapabilitiesResponse = z.infer<typeof listCapabilitiesResponseSchema>;
export const capabilityIdParamSchema = z.object({
capability_id: z.string().min(1),
});
export type CapabilityIdParam = z.infer<typeof capabilityIdParamSchema>;

View file

@ -0,0 +1,86 @@
/**
* GET /v1/plugins
* GET /v1/plugins/marketplace
* POST /v1/plugins
* POST /v1/plugins/{plugin_id}:{enable,disable,remove}
*/
import { z } from 'zod';
/** GitHub provenance for github-sourced plugins (domain PluginGithubMetadata). */
export const pluginGithubMetadataSchema = z.object({
owner: z.string(),
repo: z.string(),
ref: z.object({
kind: z.enum(['branch', 'tag', 'sha']),
value: z.string(),
}),
installedSha: z.string().optional(),
});
export const pluginSummarySchema = z.object({
id: z.string(),
displayName: z.string(),
version: z.string().optional(),
enabled: z.boolean(),
state: z.enum(['ok', 'error']),
skillCount: z.number(),
mcpServerCount: z.number(),
enabledMcpServerCount: z.number(),
hookCount: z.number(),
commandCount: z.number(),
hasErrors: z.boolean(),
source: z.enum(['local-path', 'zip-url', 'github']),
originalSource: z.string().optional(),
github: pluginGithubMetadataSchema.optional(),
});
export type PluginSummaryWire = z.infer<typeof pluginSummarySchema>;
export const listPluginsResponseSchema = z.object({
plugins: z.array(pluginSummarySchema),
});
export type ListPluginsResponse = z.infer<typeof listPluginsResponseSchema>;
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<typeof installPluginRequestSchema>;
export const pluginMarketplaceEntrySchema = z.object({
id: z.string(),
tier: z.enum(['official', 'curated', 'third-party']),
displayName: z.string(),
description: z.string().optional(),
homepage: z.string().optional(),
keywords: z.array(z.string()).optional(),
/** Catalog-declared version; absent for entries that track a moving source. */
version: z.string().optional(),
source: z.string(),
/** Present when the plugin is installed locally (detected on demand). */
installed: z
.object({
version: z.string().optional(),
enabled: z.boolean(),
})
.optional(),
/** True only when both versions are valid semver and catalog > installed. */
updateAvailable: z.boolean().optional(),
/**
* Set when the entry is a built-in capability's wiring plugin install it
* through `/capabilities/{capabilityId}:install` (binary runtime + wiring);
* a plain plugin install sets up the wiring layer only.
*/
capabilityId: z.string().optional(),
});
export type PluginMarketplaceEntryWire = z.infer<typeof pluginMarketplaceEntrySchema>;
export const pluginMarketplaceResponseSchema = z.object({
entries: z.array(pluginMarketplaceEntrySchema),
});
export type PluginMarketplaceResponse = z.infer<typeof pluginMarketplaceResponseSchema>;
export const pluginIdParamSchema = z.object({
tail: z.string().min(1),
});
export type PluginIdParam = z.infer<typeof pluginIdParamSchema>;

View file

@ -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<string, unknown> },
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | 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<CapabilitiesRouteHost['get']>[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<CapabilitiesRouteHost['get']>[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<CapabilitiesRouteHost['post']>[2],
);
}
const CAPABILITY_ERROR_MAP: Readonly<Record<string, ErrorCode>> = {
[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,
);
}

View file

@ -0,0 +1,411 @@
/**
* `/plugins` REST routes plugin management and the marketplace catalog.
*
* GET /plugins data: {plugins: PluginSummary[]}
* GET /plugins/marketplace data: {entries: MarketplaceEntry[]}
* POST /plugins body {source} data: PluginSummary
* POST /plugins/{plugin_id}:enable|:disable|:remove
*
* Thin projection of the App-scope `IPluginService` (install/remove/enable
* are serialized there and fire `onDidReload`, which converges session skill
* catalogs and the capability shelf-install hook). The marketplace catalog is
* read on demand from the configured location (`pluginMarketplaceUrl` server
* option, env `KIMI_CODE_PLUGIN_MARKETPLACE_URL`, default the production
* catalog) through the shared `app/plugin/marketplace` client catalog
* reading, the lenient entry normalization, source resolution, and version
* derivation all live there (one implementation, consumed by the CLI too).
* When the location is the built-in default, a failed read falls back to the
* source checkout's own catalog (offline dev); an explicitly configured
* catalog fails hard. The route merges the entries with the live install
* state install status is always detected from the local records, never
* from the catalog and marks capability wiring rows with `capabilityId` so
* clients route them through `/capabilities/{id}:install`.
*
* **Action suffix**: `:enable` / `:disable` / `:remove` via `parseActionSuffix`
* (bare ids rejected).
*
* **Error mapping**:
* - unknown plugin id `40419 plugin.not_found` (from the domain code)
* - bad install source / path `40001 validation.failed` / `40409 fs.path_not_found`
* - malformed `{tail}` / body `40001 validation.failed`
* - catalog unreachable/invalid `50001` with a plain-language message
* - other errors `50001` via the global error handler
*/
import { stat } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
computeUpdateStatus,
ErrorCodes as DomainErrorCodes,
ICapabilityService,
IPluginService,
PluginErrors,
isError2,
parsePluginMarketplace,
readPluginMarketplace,
withLatestVersions,
type MarketplaceLocation,
type PluginMarketplace,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
import { ErrorCode } from '../protocol/error-codes';
import {
installPluginRequestSchema,
listPluginsResponseSchema,
pluginMarketplaceResponseSchema,
pluginIdParamSchema,
pluginSummarySchema,
type PluginMarketplaceEntryWire,
} from '../protocol/rest-plugin';
import { parseActionSuffix } from './action-suffix';
interface PluginsRouteHost {
get(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}
const PLUGIN_ACTIONS = ['enable', 'disable', 'remove'] as const;
/**
* Capability wiring plugin id capability id, applied only to the DEFAULT
* catalog (a custom catalog may legitimately carry a same-id fork the CLI
* likewise injects built-in rows only for the default catalog). The closed
* id set belongs to the client/engine contract (mirrored by the klient
* schema; the CLI names it inline). Marking these rows lets clients route
* them through `/capabilities/{id}:install` a plain `POST /plugins`
* installs only the wiring layer, never the binary runtime.
*/
const CAPABILITY_ROW_IDS: Readonly<
Record<string, { capabilityId: string; wiringPluginIds: readonly string[] }>
> = {
// kimi-cu's wiring plugin id is platform-specific ('kimi-cu-win' on
// Windows x64); the catalog row joins install state through either id.
'kimi-cu': { capabilityId: 'kimi-cu', wiringPluginIds: ['kimi-cu', 'kimi-cu-win'] },
'kimi-cu-win': { capabilityId: 'kimi-cu', wiringPluginIds: ['kimi-cu', 'kimi-cu-win'] },
'kimi-webbridge': { capabilityId: 'kimi-webbridge', wiringPluginIds: ['kimi-webbridge'] },
};
/**
* Wiring plugin ids in this platform's preference order the canonical one
* first ('kimi-cu-win' on Windows x64), so a stale same-id record never
* shadows the capability's actual wiring plugin.
*/
function orderedWiringPluginIds(ids: readonly string[]): readonly string[] {
if (process.platform === 'win32' && process.arch === 'x64' && ids.includes('kimi-cu-win')) {
return ['kimi-cu-win', ...ids.filter((id) => id !== 'kimi-cu-win')];
}
return ids;
}
const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000;
function fetchWithTimeout(...args: Parameters<typeof fetch>): Promise<Response> {
const [input, init] = args;
return fetch(input, { ...init, signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) });
}
/**
* The repo checkout's own catalog the fallback when the default location
* is unreachable (offline / source-checkout dev). Absent in bundled
* installs, where the fallback simply never fires.
*/
async function getSourceCheckoutLocation(): Promise<MarketplaceLocation | undefined> {
const candidate = resolve(import.meta.dirname, '../../../../plugins/marketplace.json');
const info = await stat(candidate).catch(() => undefined);
if (info?.isFile() !== true) return undefined;
return { raw: candidate, kind: 'local', resolved: candidate };
}
export interface PluginsRouteOptions {
/** Resolved catalog URL (server option / env already applied by start.ts). */
readonly marketplaceUrl: string;
/**
* True when the catalog location is the built-in default (neither the
* server option nor the env var set) only then does a failed remote read
* fall back to the source-checkout catalog and get capability markers
* (an explicitly configured catalog fails hard and stays unmarked).
*/
readonly marketplaceIsDefault?: boolean;
readonly fetchImpl?: typeof fetch;
}
export function registerPluginsRoutes(
app: PluginsRouteHost,
core: Scope,
opts: PluginsRouteOptions,
): void {
// GET /plugins/marketplace — registered BEFORE /plugins/{tail} so the
// literal segment wins over the param route.
const marketplaceRoute = defineRoute(
{
method: 'GET',
path: '/plugins/marketplace',
success: { data: pluginMarketplaceResponseSchema },
errors: {},
description: 'List the plugin marketplace catalog merged with live install state',
tags: ['plugins'],
operationId: 'listPluginMarketplace',
},
async (req, reply) => {
const fetchImpl = opts.fetchImpl ?? fetchWithTimeout;
let read: { raw: string; location: MarketplaceLocation };
try {
read = await readPluginMarketplace({
source: opts.marketplaceUrl,
workDir: process.cwd(),
fetchImpl,
sourceCheckoutLocation:
opts.marketplaceIsDefault === true ? getSourceCheckoutLocation : undefined,
});
} catch (error) {
reply.send(
errEnvelope(
ErrorCode.INTERNAL_ERROR,
`Plugin marketplace is unreachable: ${error instanceof Error ? error.message : String(error)}`,
req.id,
),
);
return;
}
let marketplace: PluginMarketplace;
try {
marketplace = parsePluginMarketplace(read.raw, read.location);
} catch (error) {
reply.send(
errEnvelope(
ErrorCode.INTERNAL_ERROR,
`Plugin marketplace returned an invalid catalog: ${error instanceof Error ? error.message : String(error)}`,
req.id,
),
);
return;
}
// The default catalog is completed with the built-in capability rows
// it does not carry itself (e.g. kimi-cu) — the CLI injects the same
// rows client-side. Injected before the projection below so they get
// the same install-state join and capabilityId marker; the
// `capability:<id>` source is a sentinel, never a plain-plugin source.
if (opts.marketplaceIsDefault === true) {
const presentIds = new Set(marketplace.plugins.map((entry) => entry.id));
const missing = core.accessor
.get(ICapabilityService)
.describeCapabilities()
.filter((descriptor) => descriptor.supported && !presentIds.has(descriptor.id))
.map((descriptor) => ({
id: descriptor.id,
tier: 'official' as const,
displayName: descriptor.displayName,
description: descriptor.description,
source: `capability:${descriptor.id}`,
}));
if (missing.length > 0) {
marketplace = { ...marketplace, plugins: [...marketplace.plugins, ...missing] };
}
}
marketplace = await withLatestVersions(marketplace, fetchImpl);
const installed = await core.accessor.get(IPluginService).listPlugins();
const byId = new Map(installed.map((p) => [p.id, p]));
// Capability rows unsupported on this host are hidden entirely (the CLI
// does the same for its built-in rows) — never marked, never offered.
const supportedCapabilityIds = new Set<string>(
core.accessor
.get(ICapabilityService)
.describeCapabilities()
.filter((descriptor) => descriptor.supported)
.map((descriptor) => descriptor.id),
);
const entries: PluginMarketplaceEntryWire[] = [];
for (const entry of marketplace.plugins) {
const capabilityRow =
opts.marketplaceIsDefault === true ? CAPABILITY_ROW_IDS[entry.id] : undefined;
if (
capabilityRow !== undefined &&
!supportedCapabilityIds.has(capabilityRow.capabilityId)
) {
continue;
}
// Capability rows join through the wiring plugin ids (platform order)
// BEFORE the bare catalog id — a stale same-id record must not win.
const record =
capabilityRow !== undefined
? (orderedWiringPluginIds(capabilityRow.wiringPluginIds)
.map((id) => byId.get(id))
.find((candidate) => candidate !== undefined) ?? byId.get(entry.id))
: byId.get(entry.id);
const installedInfo =
record === undefined
? undefined
: { enabled: record.enabled, version: record.version };
const updateAvailable =
computeUpdateStatus(entry.version, record?.version, record !== undefined).kind ===
'update';
entries.push({
id: entry.id,
tier: entry.tier ?? 'third-party',
displayName: entry.displayName,
description: entry.description,
homepage: entry.homepage,
keywords: entry.keywords === undefined ? undefined : [...entry.keywords],
version: entry.version,
source: entry.source,
installed: installedInfo,
updateAvailable: updateAvailable ? true : undefined,
capabilityId: capabilityRow?.capabilityId,
});
}
reply.send(okEnvelope({ entries }, req.id));
},
);
app.get(
marketplaceRoute.path,
marketplaceRoute.options,
marketplaceRoute.handler as Parameters<PluginsRouteHost['get']>[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<PluginsRouteHost['get']>[2],
);
// POST /plugins {source} --------------------------------------------------
const installRoute = defineRoute(
{
method: 'POST',
path: '/plugins',
body: installPluginRequestSchema,
success: { data: pluginSummarySchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: {},
[ErrorCode.FS_PATH_NOT_FOUND]: {},
},
description: 'Install a plugin from a local path, zip URL, or GitHub repo',
tags: ['plugins'],
operationId: 'installPlugin',
},
async (req, reply) => {
try {
const plugin = await core.accessor.get(IPluginService).installPlugin(req.body);
reply.send(okEnvelope(plugin, req.id));
} catch (error) {
reply.send(mapPluginError(error, req.id));
}
},
);
app.post(
installRoute.path,
installRoute.options,
installRoute.handler as Parameters<PluginsRouteHost['post']>[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<PluginsRouteHost['post']>[2],
);
}
const PLUGIN_ERROR_MAP: Readonly<Record<string, ErrorCode>> = {
[PluginErrors.codes.PLUGIN_NOT_FOUND]: ErrorCode.PLUGIN_NOT_FOUND,
// Client-fixable input mistakes (relative source, missing local path, an
// unloadable manifest at a valid location) keep their 4xx semantics
// instead of collapsing into a 50001.
[PluginErrors.codes.PLUGIN_LOAD_FAILED]: ErrorCode.VALIDATION_FAILED,
[DomainErrorCodes.VALIDATION_FAILED]: ErrorCode.VALIDATION_FAILED,
[DomainErrorCodes.FS_PATH_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND,
};
function mapPluginError(error: unknown, requestId: string) {
const mapped = isError2(error) ? PLUGIN_ERROR_MAP[error.code] : undefined;
if (mapped !== undefined && isError2(error)) {
return errEnvelope(mapped, error.message, requestId, error.stack);
}
return errEnvelope(
ErrorCode.INTERNAL_ERROR,
error instanceof Error ? error.message : String(error),
requestId,
error instanceof Error ? error.stack : undefined,
);
}

View file

@ -20,6 +20,7 @@ import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBro
import type { TranscriptService } from '../services/transcript/transcriptService';
import { registerApprovalsRoutes } from './approvals';
import { registerAuthRoute } from './auth';
import { registerCapabilitiesRoutes } from './capabilities';
import { registerConfigRoutes } from './config';
import { registerConnectionsRoutes } from './connections';
import { registerFilesRoutes } from './files';
@ -31,6 +32,7 @@ import { registerDebugRoutes } from '../transport/registerDebugRoutes';
import { registerMetaRoute } from './meta';
import { registerModelCatalogRoutes } from './modelCatalog';
import { registerOAuthRoutes } from './oauth';
import { registerPluginsRoutes } from './plugins';
import { registerPromptsRoutes } from './prompts';
import { registerQuestionsRoutes } from './questions';
import { registerSearchRoutes } from './search';
@ -76,6 +78,10 @@ export interface RegisterApiV1RoutesOptions {
readonly connectionRegistry: IConnectionRegistry;
readonly broadcaster: SessionEventBroadcaster;
readonly transcriptService: TranscriptService;
/** Catalog URL for the `/plugins/marketplace` route (resolved by start.ts). */
readonly pluginMarketplaceUrl: string;
/** True when the catalog URL is the built-in default (no option/env set). */
readonly pluginMarketplaceIsDefault: boolean;
/**
* Surface `dangerous_bypass_auth` in the `/meta` payload. Set by `start.ts`
* from the `disableAuth` server option (the `--dangerous-bypass-auth` CLI
@ -132,6 +138,14 @@ export async function registerApiV1Routes(
{ hostIdentity: opts.hostIdentity },
);
registerSkillsRoutes(apiV1 as unknown as Parameters<typeof registerSkillsRoutes>[0], core);
registerCapabilitiesRoutes(
apiV1 as unknown as Parameters<typeof registerCapabilitiesRoutes>[0],
core,
);
registerPluginsRoutes(apiV1 as unknown as Parameters<typeof registerPluginsRoutes>[0], core, {
marketplaceUrl: opts.pluginMarketplaceUrl,
marketplaceIsDefault: opts.pluginMarketplaceIsDefault,
});
registerMessagesRoutes(
apiV1 as unknown as Parameters<typeof registerMessagesRoutes>[0],
core,

View file

@ -17,7 +17,10 @@ import {
IProviderDiscoveryService,
ISessionIndex,
ISessionIndexMirror,
ICapabilityService,
IPluginService,
IWorkspaceService,
KIMI_CODE_PLUGIN_MARKETPLACE_URL,
logSeed,
resolveConfigPath,
resolveKimiHome,
@ -104,6 +107,12 @@ export interface ServerStartOptions {
readonly host?: string;
readonly port?: number;
readonly homeDir?: string;
/**
* Plugin marketplace catalog URL for `GET /api/v1/plugins/marketplace`.
* Defaults to the `KIMI_CODE_PLUGIN_MARKETPLACE_URL` env var, then the
* production catalog.
*/
readonly pluginMarketplaceUrl?: string;
readonly configPath?: string;
/**
* Override the instance-registry directory used in tests that need the
@ -374,6 +383,8 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
const close = async (): Promise<void> => {
await app.close();
configWarningSubscription.dispose();
pluginChangeSubscription.dispose();
capabilityInstallSubscription.dispose();
authFailureLimiter?.dispose();
modelCatalogRefreshScheduler.dispose();
// Telemetry is best-effort and must never prevent core or instance cleanup.
@ -443,6 +454,22 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
});
};
const configWarningSubscription = configService.onDidChangeDiagnostics(publishConfigWarnings);
// Fan plugin/capability lifecycle facts out as global WS events so every
// client (desktop settings, web, CLI) converges without polling: plugin
// mutations end in onDidReload; capability installs report every progress
// transition through onDidChangeInstall.
const pluginService = core.accessor.get(IPluginService);
const pluginChangeSubscription = pluginService.onDidReload(() => {
core.accessor.get(IEventService).publish({ type: 'event.plugin.changed', payload: {} });
});
const capabilityService = core.accessor.get(ICapabilityService);
const capabilityInstallSubscription = capabilityService.onDidChangeInstall((change) => {
core.accessor.get(IEventService).publish({
type: 'event.capability.changed',
payload: { capability_id: change.id, install: change.install },
});
});
void configService.ready
.then(() => {
if (configService.diagnostics().some((diagnostic) => diagnostic.severity === 'warning')) {
@ -504,6 +531,16 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
enableShutdown,
enableTerminals,
guiStore,
pluginMarketplaceUrl:
opts.pluginMarketplaceUrl ??
process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] ??
KIMI_CODE_PLUGIN_MARKETPLACE_URL,
pluginMarketplaceIsDefault:
opts.pluginMarketplaceUrl === undefined &&
(process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] === undefined ||
// The dev marketplace server (scripts/dev.mjs) serves this repo's own
// catalog and marks itself — it still counts as the default.
process.env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1'),
onShutdown: () => {
void close().catch((err: unknown) => logger.error({ err }, 'server close failed'));
},

View file

@ -113,6 +113,30 @@ export interface ConfigWarningEvent {
readonly warnings: readonly ConfigWarningItem[];
}
/**
* Plugin set mutation (install / enable / disable / remove from any client).
* Bare fan-out signal clients re-read the plugins REST surface.
*/
export interface PluginChangedEvent {
readonly type: 'event.plugin.changed';
}
/**
* Capability install progress transition. Global fan-out; clients update the
* row live and re-read the capability once it settles (`running: false`).
*/
export interface CapabilityChangedEvent {
readonly type: 'event.capability.changed';
readonly capability_id: string;
readonly install: {
readonly running: boolean;
readonly step?: string;
readonly percent?: number;
readonly error?: string;
readonly note?: string;
};
}
/**
* DI unit state transition of the engine's scope tree, produced by
* agent-core-v2's `IDebugCascadeService` (the L5 debug surface feed). Global:
@ -210,6 +234,8 @@ export type AgentEvent =
| SessionStatusChangedEvent
| ConfigChangedEvent
| ConfigWarningEvent
| PluginChangedEvent
| CapabilityChangedEvent
| DiUnitChangedEvent
| PromptSubmittedEvent
| BackgroundTaskStartedEvent
@ -227,6 +253,10 @@ export const VOLATILE_EVENT_TYPES = [
'shell.completed',
'agent.status.updated',
'event.di.unit_changed',
// Live-only install progress (per-chunk download ticks) — durable journaling
// would persist hundreds of stale frames per install. The settle frame is
// recoverable via a direct capability read, so the whole type stays volatile.
'event.capability.changed',
] as const;
export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number];

View file

@ -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<string, unknown>)[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;

View file

@ -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",

View file

@ -0,0 +1,138 @@
/**
* `/api/v1` capabilities routes wire contract:
* - GET /api/v1/capabilities envelope shape + both entries
* - GET /api/v1/capabilities/{unknown} 40418
* - POST /api/v1/capabilities/{unknown}:install 40418
* - POST /api/v1/capabilities/{id} (bare) 40001
* - POST /api/v1/capabilities/{id}:{bogus} 40001
* - POST /api/v1/capabilities/kimi-cu:install on an unsupported host 40925
* (skipped on macOS and Windows x64, where kimi-cu is supported)
*
* Real installs are never triggered from tests: the only `:install` calls
* target an unknown id or an unsupported platform. `GET` runs the entries'
* read-only detection against the isolated home dir.
*/
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
capabilityStatusSchema,
listCapabilitiesResponseSchema,
} from '../src/protocol/rest-capability';
import { type RunningServer, startServer } from '../src/start';
import { TEST_HOST_IDENTITY } from './helpers/hostIdentity';
import { authHeaders } from './helpers/auth';
interface Envelope<T> {
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<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`, {
headers: authHeaders(server as RunningServer),
} as never);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function postJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
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<T> };
}
it('lists both built-in capabilities with the documented shape', async () => {
const { body } = await getJson<unknown>('/api/v1/capabilities');
expect(body.code).toBe(0);
const parsed = listCapabilitiesResponseSchema.parse(body.data);
const ids = parsed.capabilities.map((c) => c.id).toSorted();
expect(ids).toEqual(['kimi-cu', 'kimi-webbridge']);
for (const capability of parsed.capabilities) {
expect(capabilityStatusSchema.parse(capability)).toBeTruthy();
expect(capability.install.running).toBe(false);
}
// Platform-gated entry: kimi-cu runs on macOS and Windows x64.
const kimiCu = parsed.capabilities.find((c) => c.id === 'kimi-cu');
if (process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64')) {
expect(kimiCu?.supported).toBe(true);
} else {
expect(kimiCu?.supported).toBe(false);
expect(kimiCu?.state).toBe('unsupported');
}
// The isolated home dir has no plugin records → the skill step is missing.
const webbridge = parsed.capabilities.find((c) => c.id === 'kimi-webbridge');
expect(webbridge?.supported).toBe(true);
expect(webbridge?.steps.find((s) => s.id === 'skill')?.state).toBe('missing');
// The browser extension is a soft gate (never blocks readiness).
expect(webbridge?.steps.find((s) => s.id === 'extension')?.optional).toBe(true);
});
it('gets a single capability and 40418s on an unknown id', async () => {
const { body } = await getJson<unknown>('/api/v1/capabilities/kimi-webbridge');
expect(body.code).toBe(0);
expect(capabilityStatusSchema.parse(body.data).id).toBe('kimi-webbridge');
const missing = await getJson<unknown>('/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<unknown>('/api/v1/capabilities/nope:install');
expect(body.code).toBe(40418);
});
it('rejects bare ids and unknown actions with 40001', async () => {
const bare = await postJson<unknown>('/api/v1/capabilities/kimi-cu');
expect(bare.body.code).toBe(40001);
const bogus = await postJson<unknown>('/api/v1/capabilities/kimi-cu:uninstall');
expect(bogus.body.code).toBe(40001);
});
// kimi-cu is supported on macOS and Windows x64 — only genuinely
// unsupported platforms (Linux, win32-arm64, …) get the 40924 rejection.
it.skipIf(process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'))(
'rejects kimi-cu install on unsupported platforms with 40925',
async () => {
const { body } = await postJson<unknown>('/api/v1/capabilities/kimi-cu:install');
expect(body.code).toBe(40925);
},
);
});

View file

@ -0,0 +1,632 @@
/**
* `/api/v1` plugins routes wire contract:
* - GET /plugins installed list (empty 1 after install)
* - POST /plugins {source} installs (local path), returns summary
* - POST /plugins/{id}:disable / :enable toggles enabled
* - POST /plugins/{id}:remove removes
* - POST bare id / bogus action 40001
* - POST unknown id :remove 40419
* - POST relative / nonexistent source 40001 / 40409 (never 50001)
* - GET /plugins/marketplace catalog merged with live install state
* - GET /plugins/marketplace unreachable 50001
*
* The marketplace catalog is served by a stubbed global fetch; installs use
* local-path sources in temp dirs (no network).
*/
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WebSocket } from 'ws';
import { type RunningServer, startServer } from '../src/start';
import { TEST_HOST_IDENTITY } from './helpers/hostIdentity';
import { authHeaders, bearerToken } from './helpers/auth';
interface Envelope<T> {
code: number;
msg: string;
data: T;
request_id: string;
}
const CATALOG_URL = 'http://marketplace.test/marketplace.json';
const CATALOG = {
version: '1',
plugins: [
{
id: 'demo-plugin',
tier: 'official',
displayName: 'Demo Plugin',
// A `v`-prefixed catalog version still drives the update check.
version: 'v2.0.0',
source: 'https://cdn.example.test/demo.zip',
},
{
id: 'third-party-plugin',
displayName: 'Third Party',
source: 'https://github.com/example/third',
},
{
// Catalog-relative source (the production CDN catalog's shape).
id: 'relative-plugin',
displayName: 'Relative',
source: './plugins/relative.zip',
},
{
// Legacy `url` alias (accepted by the CLI parser); a blank `source`
// must not shadow the alias.
id: 'alias-plugin',
displayName: 'Alias',
source: ' ',
url: './plugins/alias.zip',
},
{
// A blank tier reads as missing (third-party), not a validation error.
id: 'blank-tier-plugin',
displayName: 'Blank Tier',
tier: ' ',
source: 'https://example.test/bt.zip',
},
{
// A non-string version reads as missing, so the GitHub release-tag
// source supplies it.
id: 'gh-plugin',
displayName: 'GH Plugin',
version: 2,
source: 'https://github.com/example/gh/releases/tag/v2.0.0',
},
{
// A capability's wiring plugin — the response marks it so clients
// route the install through the capability surface.
id: 'kimi-webbridge',
displayName: 'Kimi WebBridge',
source: 'https://cdn.example.test/kimi-webbridge.zip',
},
{
// kimi-cu joins install state through the platform wiring id too
// ('kimi-cu-win' on Windows x64).
id: 'kimi-cu',
displayName: 'Kimi Computer Use',
source: 'https://cdn.example.test/kimi-cu.zip',
},
{
// CLI metadata aliases: name / shortDescription / websiteURL.
// The padded id trims before the install-state join.
id: ' meta-alias-plugin ',
name: 'Meta Alias',
shortDescription: 'Aliased metadata',
websiteURL: 'https://example.test/meta',
keywords: ['web', 3, ' ', 'tools'],
source: 'https://example.test/meta.zip',
},
],
};
describe('server-v2 /api/v1 plugins', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
const createdDirs: string[] = [];
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-plugins-'));
const realFetch = globalThis.fetch;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
if (url === CATALOG_URL) {
return new Response(JSON.stringify(CATALOG), { status: 200 });
}
// Latest-release lookups for bare GitHub repo sources.
if (url === 'https://github.com/example/third/releases/latest') {
return new Response(null, {
status: 302,
headers: { location: 'https://github.com/example/third/releases/tag/v3.1.0' },
});
}
if (typeof url === 'string' && url.includes('/releases/latest')) {
return new Response(null, { status: 404 });
}
return realFetch(url as never, init);
}),
);
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
pluginMarketplaceUrl: CATALOG_URL,
});
base = `http://127.0.0.1:${server.port}`;
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
if (server !== undefined) {
await server.close();
server = undefined;
}
for (const dir of createdDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never);
home = undefined;
}
});
async function call<T>(
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`, {
method,
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
// A JSON content-type with an empty body is rejected by Fastify.
body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
} as never);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function makePluginDir(id: string, version: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), `kimi-test-plugin-${id}-`));
createdDirs.push(dir);
await writeFile(
join(dir, 'kimi.plugin.json'),
JSON.stringify({ name: id, version, description: 'test plugin' }),
);
return dir;
}
it('installs, lists, disables, enables, and removes a plugin', async () => {
const empty = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins');
expect(empty.body.data.plugins).toEqual([]);
const source = await makePluginDir('demo-plugin', '1.0.0');
const installed = await call<{ id: string; version: string; enabled: boolean }>(
'POST',
'/api/v1/plugins',
{ source },
);
expect(installed.body.code).toBe(0);
expect(installed.body.data).toMatchObject({ id: 'demo-plugin', version: '1.0.0', enabled: true });
const list = await call<{ plugins: { id: string; enabled: boolean }[] }>(
'GET',
'/api/v1/plugins',
);
expect(list.body.data.plugins.map((p) => [p.id, p.enabled])).toEqual([['demo-plugin', true]]);
const disabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:disable');
expect(disabled.body.code).toBe(0);
const afterDisable = await call<{ plugins: { enabled: boolean }[] }>('GET', '/api/v1/plugins');
expect(afterDisable.body.data.plugins[0]?.enabled).toBe(false);
const enabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:enable');
expect(enabled.body.code).toBe(0);
const removed = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:remove');
expect(removed.body.code).toBe(0);
const afterRemove = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins');
expect(afterRemove.body.data.plugins).toEqual([]);
});
it('rejects bare ids, bogus actions, and unknown plugins', async () => {
const bare = await call('POST', '/api/v1/plugins/demo-plugin');
expect(bare.body.code).toBe(40001);
const bogus = await call('POST', '/api/v1/plugins/demo-plugin:explode');
expect(bogus.body.code).toBe(40001);
const unknown = await call('POST', '/api/v1/plugins/nope:remove');
expect(unknown.body.code).toBe(40419);
const badSource = await call('POST', '/api/v1/plugins', { source: '' });
expect(badSource.body.code).toBe(40001);
});
it('fans out event.plugin.changed over WS on install and remove', async () => {
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/ws`, [
`kimi-code.bearer.${bearerToken(server!)}`,
]);
const types: string[] = [];
try {
await new Promise<void>((resolve, reject) => {
ws.once('message', () => {
resolve();
}); // server_hello
ws.once('error', reject);
});
ws.on('message', (data: Buffer) => {
const frame = JSON.parse(data.toString('utf8')) as { type?: string };
if (frame.type !== undefined) types.push(frame.type);
});
const source = await makePluginDir('demo-plugin', '1.0.0');
await call('POST', '/api/v1/plugins', { source });
await vi.waitFor(() => {
expect(types).toContain('event.plugin.changed');
});
await call('POST', '/api/v1/plugins/demo-plugin:remove');
await vi.waitFor(() => {
expect(types.filter((t) => t === 'event.plugin.changed').length).toBeGreaterThanOrEqual(2);
});
} finally {
ws.close();
}
});
it('maps client-fixable install input errors to 4xx, never 50001', async () => {
// Relative source: the domain rejects non-absolute local paths.
const relative = await call('POST', '/api/v1/plugins', { source: 'relative/dir' });
expect(relative.body.code).toBe(40001);
// Absolute but nonexistent path.
const missing = await call('POST', '/api/v1/plugins', {
source: join(home!, 'no-such-plugin-dir'),
});
expect(missing.body.code).toBe(40409);
// Existing directory without a valid manifest → plugin.load_failed.
const noManifest = await mkdtemp(join(tmpdir(), 'kimi-no-manifest-'));
createdDirs.push(noManifest);
const unloadable = await call('POST', '/api/v1/plugins', { source: noManifest });
expect(unloadable.body.code).toBe(40001);
});
it('serves the marketplace catalog merged with live install state', async () => {
const before = await call<{
entries: {
id: string;
tier: string;
displayName: string;
source: string;
version?: string;
capabilityId?: string;
description?: string;
homepage?: string;
keywords?: string[];
installed?: { version?: string };
}[];
}>('GET', '/api/v1/plugins/marketplace');
expect(before.body.code).toBe(0);
expect(before.body.data.entries.map((e) => [e.id, e.tier])).toEqual([
['demo-plugin', 'official'],
['third-party-plugin', 'third-party'],
['relative-plugin', 'third-party'],
['alias-plugin', 'third-party'],
['blank-tier-plugin', 'third-party'],
['gh-plugin', 'third-party'],
['kimi-webbridge', 'third-party'],
['kimi-cu', 'third-party'],
['meta-alias-plugin', 'third-party'],
]);
expect(before.body.data.entries[0]?.installed).toBeUndefined();
// Catalog-relative sources resolve against the catalog URL.
const relative = before.body.data.entries.find((e) => e.id === 'relative-plugin');
expect(relative?.source).toBe('http://marketplace.test/plugins/relative.zip');
// The legacy `url` alias is accepted and resolved the same way.
const alias = before.body.data.entries.find((e) => e.id === 'alias-plugin');
expect(alias?.source).toBe('http://marketplace.test/plugins/alias.zip');
// Version derived from the GitHub release-tag source.
expect(before.body.data.entries.find((e) => e.id === 'gh-plugin')?.version).toBe('2.0.0');
// Bare GitHub repo source: latest release tag resolved through the
// /releases/latest redirect.
expect(before.body.data.entries.find((e) => e.id === 'third-party-plugin')?.version).toBe(
'3.1.0',
);
// A custom catalog never gets capability markers (same-id forks stay
// plain plugins) — markers only apply to the default catalog.
expect(
before.body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId,
).toBeUndefined();
// And no built-in injection either.
expect(before.body.data.entries.some((e) => e.source.startsWith('capability:'))).toBe(false);
// CLI metadata aliases map onto the wire fields.
const meta = before.body.data.entries.find((e) => e.id === 'meta-alias-plugin');
expect(meta?.displayName).toBe('Meta Alias');
expect(meta?.description).toBe('Aliased metadata');
expect(meta?.homepage).toBe('https://example.test/meta');
// Keywords filter to non-blank strings instead of failing the catalog.
expect(meta?.keywords).toEqual(['web', 'tools']);
// Install an older version than the catalog → updateAvailable.
const source = await makePluginDir('demo-plugin', '1.0.0');
await call('POST', '/api/v1/plugins', { source });
const after = await call<{
entries: {
id: string;
installed?: { version?: string; enabled: boolean };
updateAvailable?: boolean;
}[];
}>('GET', '/api/v1/plugins/marketplace');
const demo = after.body.data.entries.find((e) => e.id === 'demo-plugin');
expect(demo?.installed).toEqual({ version: '1.0.0', enabled: true });
expect(demo?.updateAvailable).toBe(true);
// A version derived from the GitHub tag source drives updateAvailable too.
const ghSource = await makePluginDir('gh-plugin', '1.5.0');
await call('POST', '/api/v1/plugins', { source: ghSource });
const afterGh = await call<{
entries: { id: string; updateAvailable?: boolean }[];
}>('GET', '/api/v1/plugins/marketplace');
expect(afterGh.body.data.entries.find((e) => e.id === 'gh-plugin')?.updateAvailable).toBe(true);
});
it('rejects a catalog whose entry has no usable source', async () => {
const realFetch = globalThis.fetch;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
if (url === CATALOG_URL) {
return new Response(
JSON.stringify({ plugins: [{ id: 'bad', source: ' ' }] }),
{ status: 200 },
);
}
return realFetch(url as never, init);
}),
);
const { body } = await call('GET', '/api/v1/plugins/marketplace');
expect(body.code).toBe(50001);
expect(body.msg).toContain('invalid catalog');
});
it('rejects a catalog with an unsupported entry type', async () => {
const realFetch = globalThis.fetch;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
if (url === CATALOG_URL) {
return new Response(
JSON.stringify({
plugins: [{ id: 'bad', type: 'integration', source: 'https://example.test/x.zip' }],
}),
{ status: 200 },
);
}
return realFetch(url as never, init);
}),
);
const { body } = await call('GET', '/api/v1/plugins/marketplace');
expect(body.code).toBe(50001);
expect(body.msg).toContain('invalid catalog');
});
it('treats the dev marketplace server as the default catalog', async () => {
// scripts/dev.mjs serves the repo catalog and marks itself; capability
// markers apply as if no env were set.
await server?.close();
vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_URL', CATALOG_URL);
vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER', '1');
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home!,
logLevel: 'silent',
});
base = `http://127.0.0.1:${server.port}`;
const { body } = await call<{ entries: { id: string; capabilityId?: string }[] }>(
'GET',
'/api/v1/plugins/marketplace',
);
expect(body.code).toBe(0);
expect(body.data.entries.find((e) => e.id === 'kimi-webbridge')?.capabilityId).toBe(
'kimi-webbridge',
);
// kimi-cu row assertions: on unsupported platforms the row is hidden
// entirely (never marked, never offered).
const cuSupported = process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64');
const after0 = await call<{
entries: { id: string; capabilityId?: string; installed?: { version?: string } }[];
}>('GET', '/api/v1/plugins/marketplace');
if (!cuSupported) {
expect(after0.body.data.entries.find((e) => e.id === 'kimi-cu')).toBeUndefined();
return;
}
// A plugin installed under the Windows wiring id still marks the
// kimi-cu row installed (the join follows the capability's plugin ids).
const winSource = await makePluginDir('kimi-cu-win', '0.5.4');
await call('POST', '/api/v1/plugins', { source: winSource });
const after = await call<{
entries: { id: string; capabilityId?: string; installed?: { version?: string } }[];
}>('GET', '/api/v1/plugins/marketplace');
const cu = after.body.data.entries.find((e) => e.id === 'kimi-cu');
expect(cu?.capabilityId).toBe('kimi-cu');
expect(cu?.installed?.version).toBe('0.5.4');
// With BOTH records present, the platform-canonical wiring plugin wins
// (on macOS that is the bare kimi-cu id, so this stale record shows).
const staleSource = await makePluginDir('kimi-cu', '0.1.0');
await call('POST', '/api/v1/plugins', { source: staleSource });
const both = await call<{
entries: { id: string; installed?: { version?: string } }[];
}>('GET', '/api/v1/plugins/marketplace');
const expected = process.platform === 'win32' && process.arch === 'x64' ? '0.5.4' : '0.1.0';
expect(both.body.data.entries.find((e) => e.id === 'kimi-cu')?.installed?.version).toBe(
expected,
);
});
it('maps an unreachable marketplace to 50001', async () => {
const realFetch = globalThis.fetch;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
if (url === CATALOG_URL) {
throw new Error('network down');
}
return realFetch(url as never, init);
}),
);
const { body } = await call('GET', '/api/v1/plugins/marketplace');
expect(body.code).toBe(50001);
expect(body.msg).toContain('unreachable');
});
it('reads a local marketplace catalog from disk (plain path or file://)', async () => {
// Restart with a file-based catalog — the same env the CLI accepts.
await server?.close();
const catalogDir = await mkdtemp(join(tmpdir(), 'kimi-local-catalog-'));
createdDirs.push(catalogDir);
const fileUrlPluginPath = join(catalogDir, 'plugins', 'file.zip');
await writeFile(
join(catalogDir, 'marketplace.json'),
JSON.stringify({
plugins: [
{ id: 'local-plugin', source: './zips/local.zip' },
// Portable absolute file URL (drive-rooted on Windows).
{ id: 'file-url-plugin', source: pathToFileURL(fileUrlPluginPath).href },
],
}),
);
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home!,
logLevel: 'silent',
pluginMarketplaceUrl: join(catalogDir, 'marketplace.json'),
});
base = `http://127.0.0.1:${server.port}`;
const { body } = await call<{ entries: { id: string; source: string }[] }>(
'GET',
'/api/v1/plugins/marketplace',
);
expect(body.code).toBe(0);
expect(body.data.entries).toEqual([
{
id: 'local-plugin',
tier: 'third-party',
displayName: 'local-plugin',
// Relative sources resolve against the catalog file's directory.
source: join(catalogDir, 'zips', 'local.zip'),
},
{
id: 'file-url-plugin',
tier: 'third-party',
displayName: 'file-url-plugin',
// file:// sources convert to plain absolute paths (installable).
source: fileUrlPluginPath,
},
]);
});
it('falls back to the source-checkout catalog when the remote is unreachable', async () => {
await server?.close();
const realFetch = globalThis.fetch;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL, init?: RequestInit) => {
if (typeof url === 'string' && url.includes('/releases/latest')) {
return new Response(null, { status: 404 });
}
if (url === 'https://code.kimi.com/kimi-code/plugins/marketplace.json') {
throw new Error('offline');
}
return realFetch(url as never, init);
}),
);
// No pluginMarketplaceUrl / env: the default production catalog is
// unreachable and the repo checkout's own catalog takes over (CLI parity).
vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_URL', undefined as unknown as string);
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home!,
logLevel: 'silent',
});
base = `http://127.0.0.1:${server.port}`;
const { body } = await call<{
entries: {
id: string;
source: string;
tier?: string;
displayName?: string;
capabilityId?: string;
}[];
}>('GET', '/api/v1/plugins/marketplace');
expect(body.code).toBe(0);
const datasource = body.data.entries.find((e) => e.id === 'kimi-datasource');
// Relative sources resolve against the fallback file, not the failed URL.
expect(datasource?.source.startsWith('http')).toBe(false);
expect(datasource?.source.endsWith(join('plugins', 'official', 'kimi-datasource'))).toBe(true);
// The default catalog (even served from the checkout fallback) marks
// capability wiring rows.
const webbridge = body.data.entries.find((e) => e.id === 'kimi-webbridge');
expect(webbridge?.capabilityId).toBe('kimi-webbridge');
// Capabilities the catalog does not carry are injected as built-in rows
// where supported (kimi-cu is not in the checked-in catalog, and is
// supported on macOS / Windows x64 only).
const cuSupported = process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64');
const cu = body.data.entries.find((e) => e.id === 'kimi-cu');
if (!cuSupported) {
expect(cu).toBeUndefined();
return;
}
expect(cu?.tier).toBe('official');
expect(cu?.capabilityId).toBe('kimi-cu');
expect(cu?.source).toBe('capability:kimi-cu');
expect(cu?.displayName).toBe('Kimi Computer Use');
// Injected rows join install state like catalog rows.
const cuSource = await makePluginDir('kimi-cu', '0.5.8');
await call('POST', '/api/v1/plugins', { source: cuSource });
const after = await call<{
entries: { id: string; installed?: { version?: string; enabled: boolean } }[];
}>('GET', '/api/v1/plugins/marketplace');
expect(after.body.data.entries.find((e) => e.id === 'kimi-cu')?.installed).toEqual({
version: '0.5.8',
enabled: true,
});
});
it('expands ~ in local catalog paths like the CLI loader', async () => {
await server?.close();
const fakeHome = await mkdtemp(join(tmpdir(), 'kimi-tilde-home-'));
createdDirs.push(fakeHome);
await writeFile(
join(fakeHome, 'marketplace.json'),
JSON.stringify({
plugins: [
{ id: 'tilde-plugin', source: 'https://example.test/t.zip' },
// Home-relative entry source expands against the stubbed HOME.
{ id: 'tilde-entry-plugin', source: '~/plugins/t.zip' },
],
}),
);
// os.homedir() reads HOME on POSIX and USERPROFILE on Windows.
vi.stubEnv('HOME', fakeHome);
vi.stubEnv('USERPROFILE', fakeHome);
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home!,
logLevel: 'silent',
pluginMarketplaceUrl: '~/marketplace.json',
});
base = `http://127.0.0.1:${server.port}`;
const { body } = await call<{ entries: { id: string; source: string }[] }>(
'GET',
'/api/v1/plugins/marketplace',
);
expect(body.code).toBe(0);
expect(body.data.entries.map((e) => e.id)).toEqual(['tilde-plugin', 'tilde-entry-plugin']);
expect(body.data.entries[1]?.source).toBe(join(fakeHome, 'plugins', 't.zip'));
});
});

View file

@ -1194,6 +1194,64 @@ describe('SessionEventBroadcaster', () => {
expect(globalView.deliveries).toEqual(['immediate']);
});
it('fans out event.plugin.changed and event.capability.changed to global targets', async () => {
const globalView = collectingTarget();
bc.addGlobalTarget(globalView.target);
eventBus.emit({ type: 'event.plugin.changed', payload: {} });
eventBus.emit({
type: 'event.capability.changed',
payload: {
capability_id: 'kimi-webbridge',
install: { running: true, step: 'download', percent: 42 },
},
});
await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(2));
expect(globalView.envelopes[0]).toMatchObject({
type: 'event.plugin.changed',
session_id: '__global__',
});
expect(globalView.envelopes[1]).toMatchObject({
type: 'event.capability.changed',
session_id: '__global__',
payload: {
capability_id: 'kimi-webbridge',
install: { running: true, step: 'download', percent: 42 },
},
});
// Progress ticks are live-only (volatile, not journaled); the plugin
// change signal stays durable so a reconnecting client can replay it.
expect(globalView.envelopes[0]!.volatile).toBeUndefined();
expect(globalView.envelopes[1]!.volatile).toBe(true);
});
it('drops malformed event.capability.changed payloads', async () => {
const globalView = collectingTarget();
bc.addGlobalTarget(globalView.target);
eventBus.emit({ type: 'event.capability.changed', payload: null });
eventBus.emit({
type: 'event.capability.changed',
payload: { capability_id: 7, install: { running: true } },
});
eventBus.emit({
type: 'event.capability.changed',
payload: { capability_id: 'kimi-cu' }, // no install object
});
eventBus.emit({
type: 'event.capability.changed',
payload: { capability_id: 'kimi-cu', install: { running: false } },
});
await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1));
expect(globalView.envelopes[0]).toMatchObject({
type: 'event.capability.changed',
payload: { capability_id: 'kimi-cu', install: { running: false } },
});
});
it('drops malformed event.config.warning payloads', async () => {
const globalView = collectingTarget();
bc.addGlobalTarget(globalView.target);

View file

@ -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({

View file

@ -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];

View file

@ -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':

View file

@ -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', () => {

View file

@ -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<ModelCatalogChangedEvent>;
export const pluginChangedEventSchema = z.object({
type: z.literal('event.plugin.changed'),
}) satisfies z.ZodType<PluginChangedEvent>;
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<CapabilityChangedEvent>;
export const goalUpdatedEventSchema = z.object({
type: z.literal('goal.updated'),
snapshot: goalSnapshotSchema.nullable(),
@ -1846,6 +1888,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [
sessionWorkChangedEventSchema,
sessionStatusChangedEventSchema,
modelCatalogChangedEventSchema,
pluginChangedEventSchema,
capabilityChangedEventSchema,
goalUpdatedEventSchema,
skillActivatedEventSchema,
pluginCommandActivatedEventSchema,
@ -1921,6 +1965,10 @@ export const VOLATILE_EVENT_TYPES = [
'shell.started',
'shell.completed',
'agent.status.updated',
// Live-only capability install progress (per-chunk ticks); kap-server
// classifies it volatile (never journaled), so shared-protocol clients must
// not treat it as durable/replayable either.
'event.capability.changed',
] as const satisfies readonly AgentEvent['type'][];
export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number];

12
pnpm-lock.yaml generated
View file

@ -685,6 +685,9 @@ importers:
retry:
specifier: 0.13.1
version: 0.13.1
semver:
specifier: ^7.7.4
version: 7.7.4
smol-toml:
specifier: ^1.6.1
version: 1.6.1
@ -719,6 +722,9 @@ importers:
'@types/retry':
specifier: 0.12.0
version: 0.12.0
'@types/semver':
specifier: ^7.7.0
version: 7.7.1
'@types/sinon':
specifier: ^21.0.1
version: 21.0.1
@ -783,6 +789,9 @@ importers:
pino:
specifier: ^9.5.0
version: 9.14.0
semver:
specifier: ^7.7.4
version: 7.7.4
smol-toml:
specifier: ^1.6.1
version: 1.6.1
@ -799,6 +808,9 @@ importers:
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
'@types/semver':
specifier: ^7.7.0
version: 7.7.1
'@types/ws':
specifier: ^8.18.0
version: 8.18.1