diff --git a/.changeset/built-in-capabilities.md b/.changeset/built-in-capabilities.md new file mode 100644 index 0000000000..9dd678e1c0 --- /dev/null +++ b/.changeset/built-in-capabilities.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add built-in product capabilities (`kimi-cu`, `kimi-webbridge`) to the local server: a closed registry owns layered readiness detection and idempotent install orchestration — binary runtimes from fixed official CDN URLs plus agent wiring through the plugin service — exposed as `GET /api/v1/capabilities`, `GET /api/v1/capabilities/{id}`, and `POST /api/v1/capabilities/{id}:install` with client-polled progress. The plugin marketplace also gains an official `kimi-webbridge` entry (browser-control skills). diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 51bd3515a0..102862f83f 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -1,7 +1,7 @@ import { homedir as osHomedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; +import type { CapabilityStatus, PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { PluginInstallTrustConfirmComponent, @@ -26,7 +26,7 @@ import { isOfficialPluginSource, } from '../utils/plugin-source-label'; import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; -import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; @@ -299,6 +299,101 @@ async function confirmInstallTrust( }); } +const CAPABILITY_POLL_INTERVAL_MS = 700; +const CAPABILITY_POLL_ATTEMPTS = 260; // ~3 minutes of runtime setup budget + +/** Capability entries (kimi-cu, kimi-webbridge) install through the + * capability service — full runtime + wiring orchestration with live + * progress — instead of a bare plugin-package install. v1 engines have no + * capability surface, in which case every entry falls back to the plain + * plugin path. */ +async function isCapabilityEntry(host: SlashCommandHost, id: string): Promise { + try { + const capabilities = await host.requireSession().listCapabilities(); + return capabilities.some((capability) => capability.id === id); + } catch { + return false; + } +} + +/** Poll a background capability install, mirroring progress into the + * panel's inline installing line until it settles (or we run out of budget). */ +async function pollCapabilityInstall( + host: SlashCommandHost, + panel: PluginsPanelComponent, + id: string, + label: string, +): Promise { + const session = host.requireSession(); + for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) { + await new Promise((resolve) => { + setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS); + }); + const status = await session.getCapability(id); + if (!status.install.running) return status; + const step = status.install.step ?? 'configuring runtime'; + const percent = status.install.percent; + panel.setInstalling( + `${truncateForStatus(label)} — ${step}${percent !== undefined ? ` ${percent}%` : ''}`, + ); + host.state.ui.requestRender(); + } + return undefined; +} + +export const __pluginsCommandInternals = { + isCapabilityEntry, + pollCapabilityInstall, + removePlugin, +}; + +async function installCapabilityFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + entry: PluginMarketplaceEntry, +): Promise { + const label = entry.displayName; + // Capability entries are official by construction; the trust prompt is + // reserved for unreviewed third-party plugins. + panel.setInstalling(truncateForStatus(label)); + host.state.ui.requestRender(); + try { + await host.requireSession().installCapability(entry.id); + } catch (error) { + panel.clearInstalling(); + host.state.ui.requestRender(); + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + host.restoreEditor(); + return; + } + let result: CapabilityStatus | undefined; + try { + result = await pollCapabilityInstall(host, panel, entry.id, label); + } catch { + result = undefined; + } + panel.clearInstalling(); + // Close the panel so the result lines land in the transcript, matching the + // plain plugin install flow. + host.restoreEditor(); + if (result === undefined) { + host.showStatus(`${label} setup is still running in the background; /plugins shows its state.`); + return; + } + if (result.install.error !== undefined) { + host.showError(`${label} setup failed: ${result.install.error}. Install again from /plugins to retry.`); + return; + } + const migrationNote = + result.install.note === 'user-skill-migrated' + ? ' Your manually installed skill is now managed as a plugin.' + : ''; + host.showStatus( + `${label} is ready${result.version !== undefined ? ` (v${result.version})` : ''}.${migrationNote}`, + ); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); +} + async function installFromPanel( host: SlashCommandHost, panel: PluginsPanelComponent, @@ -400,6 +495,10 @@ async function handlePluginsPanelSelection( await showPluginsPicker(host, { initialTab: 'installed' }); return; case 'install': + if (await isCapabilityEntry(host, selection.entry.id)) { + await installCapabilityFromPanel(host, panel, selection.entry); + return; + } await installFromPanel( host, panel, @@ -454,6 +553,12 @@ async function handlePluginMcpSelection( async function removePlugin(host: SlashCommandHost, id: string): Promise { await host.requireSession().removePlugin(id); host.showStatus(`Removed ${id}.`); + if (await isCapabilityEntry(host, id)) { + // Capability runtimes (KimiCU.app / WebBridge daemon) are deliberately + // never uninstalled by plugin removal — say so, since the capability + // still works until the user removes those separately. + host.showStatus('Note: the runtime binaries were left untouched and the capability still works. Reinstall any time from the Official tab.'); + } host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 64ec286148..2f94ce048c 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -28,10 +28,11 @@ const INSTALL_TRUST_EXIT = 'exit'; const INSTALL_TRUST_TRUST = 'trust'; const ELLIPSIS = '…'; -// Hardcoded Web Bridge promotion: a built-in entry that always leads the -// Official tab, even when the marketplace catalog is unavailable. Selecting it -// opens the install page in the browser rather than installing from a source, -// because Web Bridge is a browser extension + daemon, not a plugin package. +// Hardcoded Web Bridge promotion: a built-in fallback shown only while the +// marketplace catalog is loading, unreachable, or predates the real +// `kimi-webbridge` entry. Selecting it opens the install page in the browser; +// once the catalog carries the real entry, that row wins and installs +// normally. const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge#local-agent'; const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { id: 'kimi-webbridge', @@ -284,10 +285,12 @@ function pluginStatus(plugin: PluginSummary): string | undefined { } function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string { - // "update …" is a warning (actionable); "installed …" is success; - // "install …" is the available action. + // States recede, actions pop: "installed …" is a quiet fact (dim), while + // "install …" (the available action) stays primary and "update …" stays a + // warning — the two used to share near-identical green-ish treatments in + // the same column and read as interchangeable. if (status.startsWith('update')) return chalk.hex(colors.warning); - if (status.startsWith('installed')) return chalk.hex(colors.success); + if (status.startsWith('installed')) return chalk.hex(colors.textDim); return chalk.hex(colors.primary); } @@ -424,19 +427,16 @@ export class PluginsPanelComponent extends Container implements Focusable { } private get officialEntries(): readonly PluginMarketplaceEntry[] { - // The hardcoded Web Bridge entry always leads the Official tab, even when - // the catalog is loading or unreachable. Dedupe by id so a catalog that - // also lists it does not render a second row. - return [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + // The real catalog entry wins when present (it installs the actual + // plugin); the hardcoded promo row is only a fallback while the catalog + // is loading, unreachable, or predates it — never a duplicate row. + return this.officialCatalogEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id) + ? this.officialCatalogEntries + : [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; } private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { - // Dedupe by id (not reference): if the official catalog also lists - // kimi-webbridge, the pinned row already represents it, so suppress the - // catalog copy to avoid a duplicate row on the Official tab. - return this.marketplaceEntries.filter( - (entry) => entry.tier === 'official' && entry.id !== WEB_BRIDGE_ENTRY.id, - ); + return this.marketplaceEntries.filter((entry) => entry.tier === 'official'); } private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { @@ -661,6 +661,10 @@ export class PluginsPanelComponent extends Container implements Focusable { width: number, entries: readonly PluginMarketplaceEntry[], indexOffset = 0, + // Counts (installed/available footer) are computed over this list: + // the Official tab renders the pinned promo as a row but excludes it + // from the catalog counts, matching its pre-catalog semantics. + entriesForCount: readonly PluginMarketplaceEntry[] = entries, ): void { const colors = currentTheme.palette; if (this.market.status === 'loading' || this.market.status === 'idle') { @@ -679,20 +683,27 @@ export class PluginsPanelComponent extends Container implements Focusable { lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); } } - const installedCount = entries.filter((e) => this.opts.installedIds.has(e.id)).length; + const installedCount = entriesForCount.filter((e) => this.opts.installedIds.has(e.id)).length; lines.push(''); lines.push( - mutedHintLine(` ${installedCount} installed · ${entries.length - installedCount} available`, colors), + mutedHintLine( + ` ${installedCount} installed · ${entriesForCount.length - installedCount} available`, + colors, + ), ); lines.push(mutedHintLine(` Source: ${this.market.source}`, colors)); } private renderOfficial(lines: string[], width: number): void { - // Web Bridge is pinned above the catalog and stays visible while the - // catalog loads or errors, since it's built into the TUI rather than - // fetched. Catalog rows shift down by one index to match. - lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width)); - this.renderMarketplaceTab(lines, width, this.officialCatalogEntries, 1); + // Loading / error: catalog rows can't render yet — show only the pinned + // promo as the fallback. Once loaded, `officialEntries` already includes + // the promo only when the catalog lacks the real entry. + if (this.market.status !== 'loaded') { + lines.push(...this.renderMarketplaceRow(WEB_BRIDGE_ENTRY, 0, width)); + this.renderMarketplaceTab(lines, width, [], 1); + return; + } + this.renderMarketplaceTab(lines, width, this.officialEntries, 0, this.officialCatalogEntries); } private renderThirdParty(lines: string[], width: number): void { diff --git a/apps/kimi-code/test/tui/commands/plugins-capability.test.ts b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts new file mode 100644 index 0000000000..a94f987fc6 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/plugins-capability.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { __pluginsCommandInternals } from '#/tui/commands/plugins'; + +const { isCapabilityEntry, pollCapabilityInstall, removePlugin } = __pluginsCommandInternals; + +function fakeHost(overrides: { + capabilities?: Array<{ id: string }>; + capabilityStatus?: () => Promise<{ + install: { running: boolean; step?: string; percent?: number; error?: string }; + }>; +}) { + const statuses: string[] = []; + const renders: number[] = []; + const session = { + listCapabilities: overrides.capabilities + ? () => Promise.resolve(overrides.capabilities) + : () => Promise.reject(new Error('unavailable on this engine')), + getCapability: + overrides.capabilityStatus ?? + (() => Promise.resolve({ install: { running: false } })), + removePlugin: () => Promise.resolve(), + }; + const host = { + requireSession: () => session, + showStatus: (text: string) => { + statuses.push(text); + }, + state: { ui: { requestRender: () => renders.push(1) } }, + }; + return { host: host as never, statuses, renders }; +} + +function fakePanel() { + const lines: (string | undefined)[] = []; + return { + panel: { + setInstalling: (label: string) => { + lines.push(label); + }, + clearInstalling: () => { + lines.push(undefined); + }, + } as never, + lines, + }; +} + +describe('plugins command capability surface', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('detects capability entries and falls back when the engine lacks the surface', async () => { + const withCaps = fakeHost({ capabilities: [{ id: 'kimi-cu' }] }); + expect(await isCapabilityEntry(withCaps.host, 'kimi-cu')).toBe(true); + expect(await isCapabilityEntry(withCaps.host, 'superpowers')).toBe(false); + + const v1 = fakeHost({}); + expect(await isCapabilityEntry(v1.host, 'kimi-cu')).toBe(false); + }); + + it('polls progress into the panel until the install settles', async () => { + let calls = 0; + const { host } = fakeHost({ + capabilityStatus: () => { + calls += 1; + if (calls === 1) { + return Promise.resolve({ install: { running: true, step: 'download', percent: 40 } }); + } + return Promise.resolve({ install: { running: false } }); + }, + }); + const { panel, lines } = fakePanel(); + + const result = await pollCapabilityInstall(host, panel, 'kimi-cu', 'Kimi Computer Use'); + + expect(result?.install.running).toBe(false); + expect(lines).toContain('Kimi Computer Use — download 40%'); + }); + + it('removePlugin notes that capability runtimes are left untouched', async () => { + const { host, statuses } = fakeHost({ capabilities: [{ id: 'kimi-cu' }] }); + await removePlugin(host, 'kimi-cu'); + expect(statuses.some((s) => s.includes('Removed kimi-cu'))).toBe(true); + expect(statuses.some((s) => s.includes('runtime binaries were left untouched'))).toBe(true); + }); + + it('removePlugin stays quiet for non-capability plugins', async () => { + const { host, statuses } = fakeHost({ capabilities: [{ id: 'kimi-cu' }] }); + await removePlugin(host, 'superpowers'); + expect(statuses.some((s) => s.includes('runtime binaries'))).toBe(false); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 38ee3a67ec..a0b6a51f3a 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -366,7 +366,7 @@ describe('plugins selector dialogs', () => { }); }); - it('does not duplicate Web Bridge when the catalog also lists it', () => { + it('lets the real catalog entry win over the pinned Web Bridge promo', () => { const entries = [ { id: 'kimi-webbridge', @@ -376,12 +376,18 @@ describe('plugins selector dialogs', () => { }, ...officialEntries, ]; - const { panel } = makePanel({ initialTab: 'official' }); + const { panel, onSelect } = makePanel({ initialTab: 'official' }); panel.setMarketplace(entries, '/tmp/marketplace.json'); const out = strip(renderRaw(panel)); - // The label should appear exactly once — the hardcoded row wins, the - // catalog copy is filtered out. + // Exactly one row, and it is the installable catalog copy — the hardcoded + // open-in-browser promo is suppressed. expect(out.split('Kimi WebBridge').length - 1).toBe(1); + expect(out).not.toContain('open in browser'); + panel.handleInput('\r'); // index 0 → the real entry installs + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'kimi-webbridge', source: 'https://x/w.zip' }), + }); }); it('installs a Third-party entry whose id matches the pinned WebBridge', () => { diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 6022855786..d5ad16b4f9 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -160,6 +160,7 @@ const DOMAIN_LAYER = new Map([ ['permissionPolicy', 3], ['permissionRules', 3], ['plugin', 3], + ['capability', 3], ['record', 3], ['modelCatalog', 3], ['agentProfileCatalog', 3], diff --git a/packages/agent-core-v2/src/app/capability/capability.ts b/packages/agent-core-v2/src/app/capability/capability.ts new file mode 100644 index 0000000000..60b2998e40 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/capability.ts @@ -0,0 +1,36 @@ +/** + * `capability` domain (L3) — `ICapabilityService` contract. + * + * 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. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { CapabilityStatus } from './types'; + +export interface ICapabilityService { + readonly _serviceBrand: undefined; + + /** Detect every registered entry (unsupported entries included, marked). */ + listCapabilities(): Promise; + + /** + * Detect one entry. Throws `capability.not_found` (Error2) for an id that + * is not in the registry. + */ + getCapability(id: string): Promise; + + /** + * Start an idempotent install in the background and return the current + * status (with `install.running === true`); clients poll `getCapability` + * for progress. Throws `capability.not_found`, `capability.unsupported` + * (wrong platform), or `capability.install_in_progress`. + */ + installCapability(id: string): Promise; +} + +export const ICapabilityService: ServiceIdentifier = + createDecorator('capabilityService'); diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts new file mode 100644 index 0000000000..006091bab5 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -0,0 +1,196 @@ +/** + * `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 (no WS events in v1); a failed attempt leaves its + * error in the progress state until the next attempt starts. Bound at App + * scope. + */ + +import { homedir } from 'node:os'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Error2 } from '#/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IPluginService } from '#/app/plugin/plugin'; +import { IHostProcessService } from '#/os/interface/hostProcess'; + +import { ICapabilityService } from './capability'; +import { CapabilityErrors } from './errors'; +import { createKimiCuEntry } from './entries/kimiCu'; +import { createKimiWebbridgeEntry } from './entries/kimiWebbridge'; +import type { + CapabilityEntry, + CapabilityId, + CapabilityInstallProgress, + CapabilityReadiness, + CapabilityStatus, +} from './types'; + +const IDLE_PROGRESS: CapabilityInstallProgress = { running: false }; + +export class CapabilityService extends Disposable implements ICapabilityService { + declare readonly _serviceBrand: undefined; + + private readonly entries: ReadonlyMap; + private readonly installProgress = new Map(); + private readonly runningInstalls = new Set(); + + constructor( + @IBootstrapService bootstrap: IBootstrapService, + @IPluginService plugins: IPluginService, + @IHostProcessService hostProcess: IHostProcessService, + entriesOverride?: readonly CapabilityEntry[], + ) { + super(); + if (entriesOverride !== undefined) { + this.entries = new Map(entriesOverride.map((entry) => [entry.id, entry])); + } else { + const ctx = { + platform: process.platform, + arch: process.arch, + kimiHomeDir: bootstrap.homeDir, + userHomeDir: homedir(), + plugins, + hostProcess, + }; + this.entries = new Map([ + ['kimi-cu', createKimiCuEntry(ctx)], + ['kimi-webbridge', createKimiWebbridgeEntry(ctx)], + ]); + } + // Shelf-install hook: when a capability's wiring plugin gets installed + // through ANY path (marketplace shelf, TUI, CLI), auto-complete the + // missing binary layers — a shelf install is a complete install, never + // wiring-only. Triggers only on the wiring step's false→true edge so a + // completed install with still-missing manual steps (TCC permissions) + // does not retrigger heavy downloads on every later plugin reload. + this._register( + plugins.onDidReload(() => { + void this.autoCompleteAfterWiringInstall(); + }), + ); + } + + private readonly lastWiringOk = new Map(); + + private async autoCompleteAfterWiringInstall(): Promise { + for (const entry of this.entries.values()) { + if (!entry.supported || this.runningInstalls.has(entry.id)) continue; + try { + const detected = await entry.detect(); + const wiringOk = + detected.steps.find((step) => step.id === entry.wiringStepId)?.state === 'ok'; + const wasOk = this.lastWiringOk.get(entry.id) ?? false; + this.lastWiringOk.set(entry.id, wiringOk); + const missingRequired = detected.steps.some( + (step) => step.optional !== true && step.state !== 'ok', + ); + if (wiringOk && !wasOk && missingRequired) { + await this.installCapability(entry.id); + } + } catch { + // Best-effort hook — failures surface through capability status. + } + } + } + + listCapabilities(): Promise { + return Promise.all([...this.entries.values()].map((entry) => this.statusOf(entry))); + } + + async getCapability(id: string): Promise { + return this.statusOf(this.requireEntry(id)); + } + + async installCapability(id: string): Promise { + const entry = this.requireEntry(id); + if (!entry.supported) { + throw new Error2( + CapabilityErrors.codes.CAPABILITY_UNSUPPORTED, + `Capability "${entry.id}" is not supported on ${process.platform}/${process.arch}`, + { details: { id: entry.id } }, + ); + } + if (this.runningInstalls.has(entry.id)) { + throw new Error2( + CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS, + `Capability "${entry.id}" is already being installed`, + { details: { id: entry.id } }); + } + + this.runningInstalls.add(entry.id); + this.installProgress.set(entry.id, { running: true }); + // Fire-and-forget: progress and errors are surfaced through polling. + void (async () => { + try { + const note = await entry.install((step, percent) => { + this.installProgress.set( + entry.id, + percent === undefined ? { running: true, step } : { running: true, step, percent }, + ); + }); + this.installProgress.set( + entry.id, + note === undefined ? { running: false } : { running: false, note }, + ); + } catch (error) { + this.installProgress.set(entry.id, { + running: false, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.runningInstalls.delete(entry.id); + } + })(); + + return this.statusOf(entry); + } + + private requireEntry(id: string): CapabilityEntry { + const entry = this.entries.get(id as CapabilityId); + if (entry === undefined) { + throw new Error2( + CapabilityErrors.codes.CAPABILITY_NOT_FOUND, + `Capability "${id}" is not registered`, + { details: { id } }, + ); + } + return entry; + } + + private async statusOf(entry: CapabilityEntry): Promise { + const install = this.installProgress.get(entry.id) ?? IDLE_PROGRESS; + const base = { + id: entry.id, + displayName: entry.displayName, + description: entry.description, + install, + }; + if (!entry.supported) { + return { ...base, supported: false, state: 'unsupported', steps: [] }; + } + const detected = await entry.detect(); + const required = detected.steps.filter((step) => step.optional !== true); + const requiredOk = required.length > 0 && required.every((step) => step.state === 'ok'); + const anyOk = detected.steps.some((step) => step.state === 'ok'); + const state: CapabilityReadiness = requiredOk ? 'ready' : anyOk ? 'partial' : 'not_installed'; + return { + ...base, + supported: true, + state, + steps: detected.steps, + ...(detected.version !== undefined ? { version: detected.version } : {}), + }; + } +} + +registerScopedService( + LifecycleScope.App, + ICapabilityService, + CapabilityService, + ScopeActivation.OnScopeCreated, + 'capability', +); diff --git a/packages/agent-core-v2/src/app/capability/entries/context.ts b/packages/agent-core-v2/src/app/capability/entries/context.ts new file mode 100644 index 0000000000..3e5d93ccd4 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/entries/context.ts @@ -0,0 +1,26 @@ +/** + * Shared context injected into capability entries. Every field is + * constructor-wired by `CapabilityService`; tests substitute fakes + * (temp dirs, fake fetch, fake plugin service) rather than touching the + * host. + */ + +import type { IPluginService } from '#/app/plugin/plugin'; +import type { IHostProcessService } from '#/os/interface/hostProcess'; + +export interface CapabilityEntryContext { + readonly platform: NodeJS.Platform; + readonly arch: string; + /** Kimi home (`~/.kimi-code`) — plugin records and user skills live here. */ + readonly kimiHomeDir: string; + /** OS user home (`~`) — the webbridge daemon installs under it. */ + readonly userHomeDir: string; + readonly plugins: IPluginService; + readonly hostProcess: IHostProcessService; + /** Overridable for tests; defaults to global `fetch`. */ + readonly fetchImpl?: typeof fetch; + /** kimi-cu only: defaults to `/Applications`. */ + readonly applicationsDir?: string; + /** kimi-webbridge only: defaults to `http://127.0.0.1:10086`. */ + readonly webbridgeBaseUrl?: string; +} diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts new file mode 100644 index 0000000000..268618c398 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts @@ -0,0 +1,259 @@ +/** + * `kimi-cu` capability entry (macOS only). + * + * Layers: the official `kimi-cu` plugin (stdio MCP wrapper + skill) + + * KimiCU.app (`/Applications`, launchd background service) + TCC + * permissions (accessibility + screen recording — the user must grant + * these; they can never be set programmatically). + * + * The install replicates the official `setup_macos.sh` step-for-step + * (stop old processes → ditto into /Applications → register service → + * request permissions) with structured progress and errors instead of a + * shell pipe. Elevation when /Applications is not writable goes through + * `osascript ... with administrator privileges` (native auth dialog). + */ + +import { mkdtemp, readFile, rm, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { downloadToFile, runCommand } from '../host'; +import type { + CapabilityDetectResult, + CapabilityEntry, + CapabilityInstallReporter, + CapabilityStep, +} from '../types'; + +import type { CapabilityEntryContext } from './context'; + +const PLUGIN_ID = 'kimi-cu'; +const PLUGIN_ZIP_URL = 'https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip'; +const APP_ZIP_URL = 'https://cdn.kimi.com/kimi-computer-use/latest/KimiCU.app.zip'; +const APP_BUNDLE = 'KimiCU.app'; +const LAUNCHD_LABEL = 'ai.kimi.cu.service'; +const COMMAND_TIMEOUT_MS = 30_000; +const PERMISSIONS_TIMEOUT_MS = 15_000; + +interface PermissionStatus { + readonly accessibility: boolean; + readonly screenRecording: boolean; +} + +/** `request-permissions` (bare) prints `permissions: accessibility=true screenRecording=true`. */ +export function parsePermissionStatus(output: string): PermissionStatus | undefined { + const match = /permissions:\s*accessibility=(true|false)\s+screenRecording=(true|false)/.exec( + output, + ); + if (match === null) return undefined; + return { accessibility: match[1] === 'true', screenRecording: match[2] === 'true' }; +} + +/** Read CFBundleShortVersionString from an .app bundle's Info.plist (XML). */ +export async function readAppBundleVersion(infoPlistPath: string): Promise { + try { + const xml = await readFile(infoPlistPath, 'utf-8'); + const match = /CFBundleShortVersionString<\/key>\s*([^<]+)<\/string>/.exec(xml); + return match?.[1]; + } catch { + return undefined; + } +} + +/** Escape a shell snippet for embedding in an AppleScript double-quoted string. */ +function appleScriptQuote(script: string): string { + return script.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); +} + +export function createKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry { + const applicationsDir = ctx.applicationsDir ?? '/Applications'; + const appPath = path.join(applicationsDir, APP_BUNDLE); + const appBin = path.join(appPath, 'Contents', 'MacOS', 'kimi-cu'); + const infoPlist = path.join(appPath, 'Contents', 'Info.plist'); + const supported = ctx.platform === 'darwin'; + + async function exists(p: string): Promise { + return access(p).then( + () => true, + () => false, + ); + } + + async function serviceRunning(): Promise { + if (!(await exists(appBin))) return false; + const result = await runCommand(ctx.hostProcess, appBin, ['service-status'], { + timeout: COMMAND_TIMEOUT_MS, + }); + // `SMAppService status=1` means enabled (1=enabled, 2=requiresApproval, 3=notFound). + return /status=1\b/.test(result.stdout); + } + + async function permissionStatus(): Promise { + if (!(await exists(appBin))) return undefined; + const result = await runCommand(ctx.hostProcess, appBin, ['request-permissions'], { + timeout: PERMISSIONS_TIMEOUT_MS, + }); + return parsePermissionStatus(result.stdout); + } + + async function detect(): Promise { + const steps: CapabilityStep[] = []; + + const installed = await ctx.plugins.listPlugins(); + const plugin = installed.find((p) => p.id === PLUGIN_ID); + const pluginOk = plugin !== undefined && plugin.enabled && plugin.state === 'ok'; + steps.push({ + id: 'plugin', + state: pluginOk ? 'ok' : 'missing', + ...(plugin?.version !== undefined ? { detail: plugin.version } : {}), + }); + + const version = await readAppBundleVersion(infoPlist); + steps.push({ + id: 'app', + state: (await exists(appBin)) ? 'ok' : 'missing', + ...(version !== undefined ? { detail: version } : {}), + }); + + steps.push({ id: 'service', state: (await serviceRunning()) ? 'ok' : 'missing' }); + + const permissions = await permissionStatus(); + const granted = permissions !== undefined && permissions.accessibility && permissions.screenRecording; + const missingPermissions = permissions === undefined + ? undefined + : [ + ...(permissions.accessibility ? [] : ['accessibility']), + ...(permissions.screenRecording ? [] : ['screenRecording']), + ].join(','); + steps.push({ + id: 'permissions', + state: granted ? 'ok' : 'missing', + ...(granted || missingPermissions === undefined || missingPermissions.length === 0 + ? {} + : { detail: missingPermissions }), + }); + + return { + steps, + ...(version !== undefined ? { version } : plugin?.version !== undefined ? { version: plugin.version } : {}), + }; + } + + async function stopOldProcesses(): Promise { + const uid = typeof process.getuid === 'function' ? String(process.getuid()) : '501'; + // All best-effort: mirrors the official script's `|| true` guards. + if (await exists(appBin)) { + await runCommand(ctx.hostProcess, appBin, ['uninstall'], { timeout: COMMAND_TIMEOUT_MS }); + } + await runCommand(ctx.hostProcess, 'launchctl', ['bootout', `gui/${uid}/${LAUNCHD_LABEL}`], { + timeout: COMMAND_TIMEOUT_MS, + }); + for (const mode of ['mcp', 'service', 'overlay']) { + await runCommand( + ctx.hostProcess, + 'pkill', + ['-f', `${APP_BUNDLE}/Contents/MacOS/kimi-cu[[:space:]]+${mode}`], + { timeout: COMMAND_TIMEOUT_MS }, + ); + } + await new Promise((resolve) => { + setTimeout(resolve, 1_000); + }); + } + + async function moveAppIntoPlace(unzippedApp: string): Promise { + await rm(appPath, { recursive: true, force: true }).catch(() => undefined); + const direct = await runCommand(ctx.hostProcess, 'ditto', [unzippedApp, appPath], { + timeout: COMMAND_TIMEOUT_MS, + }); + if (direct.code === 0) return; + // /Applications not writable → elevate via the native auth dialog. + const script = `/usr/bin/ditto ${appleScriptQuote(unzippedApp)} ${appleScriptQuote(appPath)}`; + const elevated = await runCommand( + ctx.hostProcess, + 'osascript', + ['-e', `do shell script "${script}" with administrator privileges`], + { timeout: 120_000 }, + ); + if (elevated.code !== 0) { + throw new Error( + `Failed to install ${APP_BUNDLE} into ${applicationsDir} ` + + `(direct: ${direct.stderr.trim() || direct.code}; elevated: ${elevated.stderr.trim() || elevated.code})`, + ); + } + } + + async function install(report: CapabilityInstallReporter): Promise { + if (!supported) { + throw new Error(`kimi-cu is only supported on macOS (current: ${ctx.platform})`); + } + + report('plugin'); + await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); + + const workDir = await mkdtemp(path.join(tmpdir(), 'kimi-cu-install-')); + try { + report('download', 0); + const zipPath = path.join(workDir, 'KimiCU.app.zip'); + await downloadToFile( + APP_ZIP_URL, + zipPath, + (percent) => { + report('download', percent); + }, + ctx.fetchImpl, + ); + + report('app'); + await stopOldProcesses(); + const unzipDir = path.join(workDir, 'unzipped'); + const unzipped = await runCommand(ctx.hostProcess, 'ditto', ['-x', '-k', zipPath, unzipDir], { + timeout: 120_000, + }); + if (unzipped.code !== 0) { + throw new Error(`Failed to unzip KimiCU.app: ${unzipped.stderr || unzipped.stdout}`); + } + await moveAppIntoPlace(path.join(unzipDir, APP_BUNDLE)); + // Strip the quarantine bit (harmless for curl-style downloads). + await runCommand(ctx.hostProcess, 'xattr', ['-dr', 'com.apple.quarantine', appPath], { + timeout: COMMAND_TIMEOUT_MS, + }); + + report('service'); + const registered = await runCommand(ctx.hostProcess, appBin, ['install'], { + timeout: COMMAND_TIMEOUT_MS, + }); + if (registered.code !== 0) { + throw new Error(`kimi-cu install failed: ${registered.stderr || registered.stdout}`); + } + await new Promise((resolve) => { + setTimeout(resolve, 1_000); + }); + if (!(await serviceRunning())) { + throw new Error('kimi-cu background service is not running after install'); + } + + report('permissions'); + // Triggers the system permission dialogs; the user still has to flip + // the switches, so this never blocks a successful install — readiness + // is reported by `detect`. + await runCommand(ctx.hostProcess, appBin, ['request-permissions', '--ax', '--screen'], { + timeout: PERMISSIONS_TIMEOUT_MS, + }); + return undefined; + } finally { + await rm(workDir, { recursive: true, force: true }).catch(() => undefined); + } + } + + return { + id: 'kimi-cu', + displayName: 'Kimi Computer Use', + description: + 'macOS GUI automation in the background — read app UIs and click, type, scroll, and drag without taking over your mouse or foregrounding apps.', + supported, + wiringStepId: 'plugin', + detect, + install, + }; +} diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts new file mode 100644 index 0000000000..b20b6a9e7c --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/entries/kimiWebbridge.ts @@ -0,0 +1,228 @@ +/** + * `kimi-webbridge` capability entry (macOS / Linux / Windows). + * + * Layers: daemon binary (`~/.kimi-webbridge/bin/`, local HTTP daemon on + * 127.0.0.1:10086) + agent wiring (the official `kimi-webbridge` plugin — + * skills only, installed through `IPluginService`) + browser extension + * (soft gate, user installs from the webstore or the manual zip). + * + * Lifecycle rules honored here (official operations contract): the daemon + * is only ever STARTED — never stopped / restarted / uninstalled — because + * the Kimi Work desktop app manages its own daemon on the same port and an + * external stop would fight it. `start` is idempotent and converges to a + * single daemon. + * + * Skill-source shadowing: a user who previously ran the official install + * script (or copied the skill around) has it in user-scope dirs — + * `~/.kimi-code/skills/kimi-webbridge/` and/or `~/.agents/skills/kimi-webbridge/` + * — both at priority 20, SHADOWING the plugin copy (priority 5). Install + * therefore removes those stale user copies — they are artifacts of the + * official installer (or of the same content), and the plugin copy carries + * the same skill. Other runtimes' dirs (~/.claude, ~/.codex) are out of + * scope and untouched. + */ + +import { access, chmod, mkdir, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { downloadToFile, runCommand } from '../host'; +import type { + CapabilityDetectResult, + CapabilityEntry, + CapabilityInstallReporter, + CapabilityStep, +} from '../types'; + +import type { CapabilityEntryContext } from './context'; + +const PLUGIN_ID = 'kimi-webbridge'; +const PLUGIN_ZIP_URL = + 'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip'; +const BINARY_CDN_BASE = 'https://cdn.kimi.com/webbridge/latest/releases'; +const DEFAULT_DAEMON_BASE_URL = 'http://127.0.0.1:10086'; +const STATUS_TIMEOUT_MS = 1_500; +const START_TIMEOUT_MS = 30_000; +const START_POLL_INTERVAL_MS = 500; +const START_POLL_ATTEMPTS = 20; + +interface DaemonStatus { + readonly running?: boolean; + readonly version?: string; + readonly extension_connected?: boolean; +} + +function binaryAssetName(platform: NodeJS.Platform, arch: string): string | undefined { + if (platform === 'darwin') { + if (arch === 'arm64') return 'kimi-webbridge-darwin-arm64'; + if (arch === 'x64') return 'kimi-webbridge-darwin-amd64'; + return undefined; + } + if (platform === 'linux') { + if (arch === 'arm64') return 'kimi-webbridge-linux-arm64'; + if (arch === 'x64') return 'kimi-webbridge-linux-amd64'; + return undefined; + } + if (platform === 'win32' && arch === 'x64') return 'kimi-webbridge-windows-amd64.exe'; + return undefined; +} + +export function createKimiWebbridgeEntry(ctx: CapabilityEntryContext): CapabilityEntry { + const baseUrl = ctx.webbridgeBaseUrl ?? DEFAULT_DAEMON_BASE_URL; + const binDir = path.join(ctx.userHomeDir, '.kimi-webbridge', 'bin'); + const binName = ctx.platform === 'win32' ? 'kimi-webbridge.exe' : 'kimi-webbridge'; + const binPath = path.join(binDir, binName); + const userSourceSkillDirs = [ + path.join(ctx.kimiHomeDir, 'skills', 'kimi-webbridge'), + path.join(ctx.userHomeDir, '.agents', 'skills', 'kimi-webbridge'), + ]; + const supported = binaryAssetName(ctx.platform, ctx.arch) !== undefined; + + async function exists(p: string): Promise { + return access(p).then( + () => true, + () => false, + ); + } + + async function fetchDaemonStatus(): Promise { + const fetchImpl = ctx.fetchImpl ?? fetch; + try { + const resp = await fetchImpl(`${baseUrl}/status`, { + signal: AbortSignal.timeout(STATUS_TIMEOUT_MS), + }); + if (!resp.ok) return undefined; + return (await resp.json()) as DaemonStatus; + } catch { + return undefined; + } + } + + async function detect(): Promise { + const steps: CapabilityStep[] = []; + + const binaryPresent = await exists(binPath); + steps.push({ id: 'daemon-binary', state: binaryPresent ? 'ok' : 'missing' }); + + const daemon = await fetchDaemonStatus(); + const daemonRunning = daemon?.running === true; + steps.push({ + id: 'daemon', + state: daemonRunning ? 'ok' : 'missing', + ...(daemonRunning && daemon?.version !== undefined ? { detail: daemon.version } : {}), + }); + + const installed = await ctx.plugins.listPlugins(); + const plugin = installed.find((p) => p.id === PLUGIN_ID); + const pluginOk = plugin !== undefined && plugin.enabled && plugin.state === 'ok'; + steps.push({ + id: 'skill', + state: pluginOk ? 'ok' : 'missing', + ...(plugin?.version !== undefined ? { detail: plugin.version } : {}), + }); + + // Soft gate: extension presence never blocks readiness — use-time + // failures carry official guidance. + steps.push({ + id: 'extension', + state: daemon?.extension_connected === true ? 'ok' : 'missing', + optional: true, + }); + + // Version only from the live daemon: the on-disk `.version` file tracks + // the installer script's lineage (e.g. 3.1.x), not the product version + // (e.g. v1.11.3) — reporting it would be misleading. + return { steps, ...(daemon?.version !== undefined ? { version: daemon.version } : {}) }; + } + + async function waitForDaemon(): Promise { + for (let attempt = 0; attempt < START_POLL_ATTEMPTS; attempt += 1) { + const status = await fetchDaemonStatus(); + if (status?.running === true) return; + await new Promise((resolve) => { + setTimeout(resolve, START_POLL_INTERVAL_MS); + }); + } + throw new Error(`WebBridge daemon did not come up on ${baseUrl} — check ~/.kimi-webbridge/logs`); + } + + async function install(report: CapabilityInstallReporter): Promise { + const asset = binaryAssetName(ctx.platform, ctx.arch); + if (asset === undefined) { + throw new Error(`kimi-webbridge is not supported on ${ctx.platform}/${ctx.arch}`); + } + + report('download', 0); + const url = `${BINARY_CDN_BASE}/${asset}`; + const staging = path.join( + tmpdir(), + `kimi-webbridge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ctx.platform === 'win32' ? '.exe' : ''}`, + ); + try { + await downloadToFile( + url, + staging, + (percent) => { + report('download', percent); + }, + ctx.fetchImpl, + ); + await mkdir(binDir, { recursive: true }); + await rename(staging, binPath).catch(async (error: NodeJS.ErrnoException) => { + if (error.code !== 'EXDEV') throw error; + await renameAcrossDevicesFallback(staging, binPath); + }); + if (ctx.platform !== 'win32') await chmod(binPath, 0o755); + } finally { + await rm(staging, { force: true }).catch(() => undefined); + } + + report('daemon'); + const status = await fetchDaemonStatus(); + if (status?.running !== true) { + // start-if-down only — never stop/restart (Kimi Work coexistence). + const started = await runCommand(ctx.hostProcess, binPath, ['start'], { + timeout: START_TIMEOUT_MS, + }); + if (started.code !== 0) { + throw new Error(`kimi-webbridge start failed: ${started.stderr || started.stdout}`); + } + await waitForDaemon(); + } + + report('skill'); + await ctx.plugins.installPlugin({ source: PLUGIN_ZIP_URL }); + // Un-shadow the plugin copy: user-source skills (priority 20, in either + // user dir) win over the plugin source (priority 5) on name collisions. + // Surface the migration so clients can tell the user their + // manually-installed skill is now managed as a plugin. + let migrated = false; + for (const dir of userSourceSkillDirs) { + if (await exists(dir)) { + migrated = true; + await rm(dir, { recursive: true, force: true }); + } + } + return migrated ? 'user-skill-migrated' : undefined; + } + + return { + id: 'kimi-webbridge', + displayName: 'Kimi WebBridge', + description: + 'Control your real browser (with your login sessions) — navigate, click, type, read pages, and screenshot any website.', + supported, + wiringStepId: 'skill', + detect, + install, + }; +} + +/** rename(2) cannot cross filesystems; fall back to copy+remove. */ +async function renameAcrossDevicesFallback(from: string, to: string): Promise { + const { copyFile } = await import('node:fs/promises'); + await copyFile(from, to); + await rm(from, { force: true }); +} + +export const __kimiWebbridgeInternals = { binaryAssetName }; diff --git a/packages/agent-core-v2/src/app/capability/errors.ts b/packages/agent-core-v2/src/app/capability/errors.ts new file mode 100644 index 0000000000..2fd66882a2 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/errors.ts @@ -0,0 +1,16 @@ +/** + * `capability` domain error codes. + */ + +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const CapabilityErrors = { + codes: { + CAPABILITY_NOT_FOUND: 'capability.not_found', + CAPABILITY_UNSUPPORTED: 'capability.unsupported', + CAPABILITY_INSTALL_IN_PROGRESS: 'capability.install_in_progress', + CAPABILITY_INSTALL_FAILED: 'capability.install_failed', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(CapabilityErrors); diff --git a/packages/agent-core-v2/src/app/capability/host.ts b/packages/agent-core-v2/src/app/capability/host.ts new file mode 100644 index 0000000000..4aeeb926f3 --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/host.ts @@ -0,0 +1,114 @@ +/** + * Shared host helpers for capability entries: process execution with + * captured output, and streaming downloads with progress reporting. + */ + +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import type { IHostProcessService } from '#/os/interface/hostProcess'; + +export interface CommandResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer)); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +/** + * Spawn a command and capture its output. Never throws for a non-zero exit — + * the caller interprets `code`. A spawn failure (missing binary) resolves to + * `code: -1` with the error message in `stderr`. + */ +export async function runCommand( + hostProcess: IHostProcessService, + command: string, + args: readonly string[], + options: { timeout?: number } = {}, +): Promise { + const spawned = await hostProcess.spawn(command, args, { windowsHide: true }).then( + (proc) => ({ ok: true as const, proc }), + (error: unknown) => ({ ok: false as const, error }), + ); + if (!spawned.ok) { + return { code: -1, stdout: '', stderr: spawned.error instanceof Error ? spawned.error.message : String(spawned.error) }; + } + const { proc } = spawned; + try { + const work = Promise.all([ + collect(proc.stdout), + collect(proc.stderr), + proc.wait().catch(() => -1), + ] as const); + const timed = + options.timeout === undefined + ? work + : Promise.race([ + work, + new Promise((_resolve, reject) => { + const timer = setTimeout(() => { + void proc.kill().catch(() => {}); + reject(new Error(`command timed out after ${options.timeout}ms: ${command}`)); + }, options.timeout); + timer.unref?.(); + void work.finally(() => { + clearTimeout(timer); + }); + }), + ]); + const [stdout, stderr, code] = await timed; + return { code, stdout, stderr }; + } finally { + proc.dispose(); + } +} + +export type FetchLike = ( + url: string, +) => Promise<{ + ok: boolean; + status: number; + headers: { get(name: string): string | null }; + body: import('node:stream/web').ReadableStream | null; +}>; + +/** + * Download `url` to `destPath` (parent dirs created), reporting 0–99 percent + * while the response carries a content-length. Returns the byte count. + */ +export async function downloadToFile( + url: string, + destPath: string, + onPercent?: (percent: number) => void, + fetchImpl: FetchLike = fetch as unknown as FetchLike, +): Promise { + const resp = await fetchImpl(url); + if (!resp.ok || resp.body === null) { + throw new Error(`Failed to download ${url}: HTTP ${resp.status}`); + } + const total = Number(resp.headers.get('content-length') ?? 0); + await mkdir(path.dirname(destPath), { recursive: true }); + let received = 0; + const meter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + received += chunk.length; + if (total > 0 && onPercent !== undefined) { + onPercent(Math.min(99, Math.floor((received / total) * 100))); + } + callback(null, chunk); + }, + }); + await pipeline(Readable.fromWeb(resp.body), meter, createWriteStream(destPath)); + onPercent?.(100); + return received; +} diff --git a/packages/agent-core-v2/src/app/capability/types.ts b/packages/agent-core-v2/src/app/capability/types.ts new file mode 100644 index 0000000000..95fc12231f --- /dev/null +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -0,0 +1,94 @@ +/** + * `capability` domain types — built-in product capabilities (kimi-cu, + * kimi-webbridge) that bundle a binary runtime + agent wiring + manual + * user steps. A capability is NOT a plugin: plugins are declarative + * contributions to a session, while capabilities own imperative install + * orchestration and a layered readiness state machine for product-specific + * runtimes (macOS app + launchd service + TCC permissions; local HTTP + * daemon + browser extension). + */ + +export type CapabilityId = 'kimi-cu' | 'kimi-webbridge'; + +export type CapabilityReadiness = 'not_installed' | 'partial' | 'ready' | 'unsupported'; + +export type CapabilityStepState = 'ok' | 'missing' | 'failed'; + +/** + * One readiness check of a capability (e.g. `plugin`, `app`, `service`, + * `permissions`, `daemon`, `skill`, `extension`). `detail` carries a short + * machine-oriented hint (detected version, missing path, error message). + */ +export interface CapabilityStep { + readonly id: string; + readonly state: CapabilityStepState; + readonly detail?: string; + /** + * Optional steps never block `ready` (e.g. the WebBridge browser + * extension — a soft gate surfaced as a hint, with official error guidance + * covering use-time failures). + */ + readonly optional?: boolean; +} + +/** Live install progress, polled by clients (no WS events in v1). */ +export interface CapabilityInstallProgress { + readonly running: boolean; + /** Current step id while running. */ + readonly step?: string; + /** 0–100 while a download with a known content-length is in flight. */ + readonly percent?: number; + /** Set when the last install attempt failed; cleared on the next attempt. */ + readonly error?: string; + /** + * Machine-key note from the last completed install (e.g. + * 'user-skill-migrated' — a pre-existing user-source skill was replaced by + * the plugin-managed copy). Clients localize it; cleared on the next + * attempt. + */ + readonly note?: string; +} + +export interface CapabilityDetectResult { + readonly version?: string; + readonly steps: readonly CapabilityStep[]; +} + +export interface CapabilityStatus { + readonly id: CapabilityId; + readonly displayName: string; + readonly description: string; + readonly supported: boolean; + readonly state: CapabilityReadiness; + readonly version?: string; + readonly steps: readonly CapabilityStep[]; + readonly install: CapabilityInstallProgress; +} + +export type CapabilityInstallReporter = (step: string, percent?: number) => void; + +/** + * A built-in capability entry. `install` must be idempotent and re-entrant: + * every step no-ops when already satisfied, so an interrupted run can be + * retried by calling `install` again. + */ +export interface CapabilityEntry { + readonly id: CapabilityId; + readonly displayName: string; + readonly description: string; + readonly supported: boolean; + /** + * The step representing the agent-wiring layer (`plugin` for kimi-cu, + * `skill` for kimi-webbridge). When that step flips to `ok` through ANY + * install path (marketplace shelf, TUI, CLI), the capability service + * auto-completes the missing binary layers — installing the wiring from + * the shelf is meant to be a complete install, never a half-broken one. + */ + readonly wiringStepId: string; + detect(): Promise; + /** + * Resolves with an optional machine-key note surfaced through + * `CapabilityInstallProgress.note` (e.g. 'user-skill-migrated'). + */ + install(report: CapabilityInstallReporter): Promise; +} diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 83fe5d1bc1..c90bd27875 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -10,6 +10,9 @@ * it. Bound at App scope. */ +import { watch, type FSWatcher } from 'node:fs'; +import path from 'node:path'; + import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; import { Disposable } from '#/_base/di/lifecycle'; @@ -58,6 +61,16 @@ export class PluginService extends Disposable implements IPluginService { private hasLoadedSnapshot = false; private loadError: Error | undefined; private mutationQueue: Promise = Promise.resolve(); + /** + * Echo suppression for the installed.json watcher: while our own mutation + * is persisting (and briefly after), file events are our own writes and + * must not trigger a redundant reload. External processes' writes are not + * covered by the flag and always reload. + */ + private mutationInFlight = false; + private mutationSettledAt = 0; + private installedFileWatcher: FSWatcher | undefined; + private watchDebounce: ReturnType | undefined; private readonly onDidReloadEmitter = this._register(new Emitter()); readonly onDidReload: Event = this.onDidReloadEmitter.event; @@ -76,6 +89,37 @@ export class PluginService extends Disposable implements IPluginService { kimiHomeDir: this.homeDir, discoverSkills: (roots) => discovery.discover(roots), }); + this.startInstalledFileWatcher(); + } + + /** + * Watch `plugins/` for external writes to `installed.json`. Multiple hosts + * share the same KIMI_CODE_HOME (CLI, desktop, other agents): plugin + * installs/removals in one process must converge every other live process + * (skill catalogs, the capability shelf hook) instead of going stale until + * the next restart. Own-process writes are suppressed via the mutation + * flag, so a mutation costs exactly one reload (its own), not two. + */ + private startInstalledFileWatcher(): void { + const pluginsDir = path.join(this.homeDir, 'plugins'); + try { + this.installedFileWatcher = watch(pluginsDir, (_event, fileName) => { + if (fileName !== 'installed.json') return; + if (this.mutationInFlight) return; + if (Date.now() - this.mutationSettledAt < 250) return; + if (this.watchDebounce !== undefined) clearTimeout(this.watchDebounce); + this.watchDebounce = setTimeout(() => { + this.watchDebounce = undefined; + void this.reloadPlugins().catch(() => undefined); + }, 200); + this.watchDebounce.unref?.(); + }); + this.installedFileWatcher.unref?.(); + this._register({ dispose: () => this.installedFileWatcher?.close() }); + } catch { + // The plugins dir may not exist yet on a fresh home — installs create + // it, and this process's own reloads cover that path. + } } listPlugins(): Promise { @@ -87,6 +131,10 @@ export class PluginService extends Disposable implements IPluginService { const record = await this.manager.install(input.source); const info = this.manager.info(record.id); if (info === undefined) throw new Error(`Plugin "${record.id}" missing right after install`); + // Mutations fire the same change event as an explicit reload so + // consumers (session skill catalogs, the capability shelf-install + // hook) converge on every install path, not just `/plugins reload`. + this.onDidReloadEmitter.fire({ added: [record.id], removed: [], errors: [] }); return info; }); } @@ -94,6 +142,7 @@ export class PluginService extends Disposable implements IPluginService { setPluginEnabled(input: SetPluginEnabledInput): Promise { return this.runSerializedOperation(async () => { await this.manager.setEnabled(input.id, input.enabled); + this.onDidReloadEmitter.fire({ added: [], removed: [], errors: [] }); }); } @@ -106,6 +155,7 @@ export class PluginService extends Disposable implements IPluginService { removePlugin(input: RemovePluginInput): Promise { return this.runSerializedOperation(async () => { await this.manager.remove(input.id); + this.onDidReloadEmitter.fire({ added: [], removed: [input.id], errors: [] }); }); } @@ -229,7 +279,15 @@ export class PluginService extends Disposable implements IPluginService { } private enqueueMutation(operation: () => Promise): Promise { - const result = this.mutationQueue.then(operation); + const result = this.mutationQueue.then(async () => { + this.mutationInFlight = true; + try { + return await operation(); + } finally { + this.mutationInFlight = false; + this.mutationSettledAt = Date.now(); + } + }); this.mutationQueue = result.then( () => undefined, () => undefined, diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index a390735277..366d081b8c 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -13,6 +13,7 @@ import { AuthErrors } from '#/app/auth/errors'; import { TaskErrors } from '#/agent/task/errors'; import { ProtocolErrors } from '#/kosong/protocol/errors'; import { ConfigErrors } from '#/app/config/errors'; +import { CapabilityErrors } from '#/app/capability/errors'; import { FileErrors } from '#/app/file/fileService'; import { FsErrors } from '#/session/sessionFs/errors'; import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; @@ -46,6 +47,7 @@ export { AuthErrors } from '#/app/auth/errors'; export { TaskErrors } from '#/agent/task/errors'; export { ProtocolErrors } from '#/kosong/protocol/errors'; export { ConfigErrors } from '#/app/config/errors'; +export { CapabilityErrors } from '#/app/capability/errors'; export { FileErrors } from '#/app/file/fileService'; export { FsErrors } from '#/session/sessionFs/errors'; export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; @@ -76,6 +78,7 @@ export const ErrorCodes = { ...TaskErrors.codes, ...ProtocolErrors.codes, ...ConfigErrors.codes, + ...CapabilityErrors.codes, ...FileErrors.codes, ...FsErrors.codes, ...FullCompactionErrors.codes, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 517b460e51..83e9be0df1 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -185,6 +185,10 @@ export * from '#/app/plugin/archive'; export * from '#/app/plugin/manager'; export * from '#/app/plugin/plugin'; export * from '#/app/plugin/pluginService'; +export * from '#/app/capability/capability'; +export * from '#/app/capability/capabilityService'; +export * from '#/app/capability/errors'; +export * from '#/app/capability/types'; export type { SkillSource } from '#/app/skillCatalog/types'; export * from '#/agent/tools/skill/skill'; diff --git a/packages/agent-core-v2/test/app/capability/capabilityService.test.ts b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts new file mode 100644 index 0000000000..733fd5fd46 --- /dev/null +++ b/packages/agent-core-v2/test/app/capability/capabilityService.test.ts @@ -0,0 +1,350 @@ +/** + * `CapabilityService` — registry semantics, readiness computation, and + * install orchestration (progress transitions, serialized runs, coded + * errors). Entries are fakes; entry internals are covered per-entry. + */ + +import { describe, expect, it } from 'vitest'; + +import { isError2 } from '#/_base/errors/errors'; +import { CapabilityErrors } from '#/app/capability/errors'; +import { CapabilityService } from '#/app/capability/capabilityService'; +import type { IPluginService } from '#/app/plugin/plugin'; +import type { + CapabilityDetectResult, + CapabilityEntry, + CapabilityInstallReporter, +} from '#/app/capability/types'; + +function fakeEntry(overrides: { + id: 'kimi-cu' | 'kimi-webbridge'; + supported?: boolean; + wiringStepId?: string; + detect?: CapabilityDetectResult; + install?: (report: CapabilityInstallReporter) => Promise; +}): CapabilityEntry { + return { + id: overrides.id, + displayName: overrides.id, + description: 'fake', + supported: overrides.supported ?? true, + wiringStepId: overrides.wiringStepId ?? 'plugin', + detect: () => + Promise.resolve( + overrides.detect ?? { steps: [{ id: 'plugin', state: 'ok' }] }, + ), + install: overrides.install ?? (() => Promise.resolve(undefined)), + }; +} + +interface FakePlugins { + service: IPluginService; + fireReload(): void; +} + +function fakePlugins(): FakePlugins { + const listeners: Array<() => void> = []; + const service = { + onDidReload: (listener: () => void) => { + listeners.push(listener); + return { dispose: () => undefined }; + }, + } as unknown as IPluginService; + return { + service, + fireReload: () => { + for (const listener of listeners) listener(); + }, + }; +} + +function fakeService(entries: readonly CapabilityEntry[], plugins?: FakePlugins): CapabilityService { + // bootstrap / hostProcess are unused when entries are injected. + return new CapabilityService( + undefined as never, + (plugins ?? fakePlugins()).service, + undefined as never, + entries, + ); +} + +function expectErrorCode(error: unknown, code: string): void { + expect(isError2(error)).toBe(true); + expect((error as { code: string }).code).toBe(code); +} + +describe('CapabilityService', () => { + it('lists entries with readiness computed from required steps', async () => { + const service = fakeService([ + fakeEntry({ id: 'kimi-cu', detect: { steps: [{ id: 'plugin', state: 'ok' }] } }), + fakeEntry({ + id: 'kimi-webbridge', + detect: { + steps: [ + { id: 'daemon', state: 'ok' }, + { id: 'skill', state: 'missing' }, + { id: 'extension', state: 'missing', optional: true }, + ], + }, + }), + ]); + const list = await service.listCapabilities(); + expect(list.map((c) => [c.id, c.state])).toEqual([ + ['kimi-cu', 'ready'], + ['kimi-webbridge', 'partial'], + ]); + }); + + it('marks optional steps as non-blocking for ready', async () => { + const service = fakeService([ + fakeEntry({ + id: 'kimi-webbridge', + detect: { + version: '3.1.1', + steps: [ + { id: 'daemon', state: 'ok' }, + { id: 'extension', state: 'missing', optional: true }, + ], + }, + }), + ]); + const status = await service.getCapability('kimi-webbridge'); + expect(status.state).toBe('ready'); + expect(status.version).toBe('3.1.1'); + }); + + it('reports not_installed when no step is ok, and unsupported as-is', async () => { + const service = fakeService([ + fakeEntry({ id: 'kimi-cu', detect: { steps: [{ id: 'plugin', state: 'missing' }] } }), + fakeEntry({ id: 'kimi-webbridge', supported: false }), + ]); + const list = await service.listCapabilities(); + expect(list.find((c) => c.id === 'kimi-cu')?.state).toBe('not_installed'); + const unsupported = list.find((c) => c.id === 'kimi-webbridge'); + expect(unsupported?.state).toBe('unsupported'); + expect(unsupported?.supported).toBe(false); + }); + + it('throws capability.not_found for unknown ids', async () => { + const service = fakeService([]); + await service.getCapability('nope').then( + () => { + expect.unreachable(); + }, + (error) => { + expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_NOT_FOUND); + }, + ); + await service.installCapability('nope').then( + () => { + expect.unreachable(); + }, + (error) => { + expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_NOT_FOUND); + }, + ); + }); + + it('rejects install on an unsupported entry', async () => { + const service = fakeService([fakeEntry({ id: 'kimi-cu', supported: false })]); + await service.installCapability('kimi-cu').then( + () => { + expect.unreachable(); + }, + (error) => { + expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_UNSUPPORTED); + }, + ); + }); + + it('serializes installs and clears progress on success', async () => { + let release: (() => void) | undefined; + const service = fakeService([ + fakeEntry({ + id: 'kimi-cu', + install: (report) => { + report('download', 42); + return new Promise((resolve) => { + release = () => resolve(undefined); + }); + }, + }), + ]); + + const started = await service.installCapability('kimi-cu'); + expect(started.install.running).toBe(true); + + await service.installCapability('kimi-cu').then( + () => { + expect.unreachable(); + }, + (error) => { + expectErrorCode(error, CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS); + }, + ); + + const during = await service.getCapability('kimi-cu'); + expect(during.install).toEqual({ running: true, step: 'download', percent: 42 }); + + release?.(); + // Wait for the background install to settle. + for (let i = 0; i < 50; i += 1) { + const status = await service.getCapability('kimi-cu'); + if (!status.install.running) { + expect(status.install.error).toBeUndefined(); + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect.unreachable('install never settled'); + }); + + it('surfaces an install note from the entry through progress', async () => { + const service = fakeService([ + fakeEntry({ + id: 'kimi-cu', + install: () => Promise.resolve('user-skill-migrated'), + }), + ]); + await service.installCapability('kimi-cu'); + for (let i = 0; i < 50; i += 1) { + const status = await service.getCapability('kimi-cu'); + if (!status.install.running) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect((await service.getCapability('kimi-cu')).install.note).toBe('user-skill-migrated'); + }); + + it('surfaces install errors through progress until the next attempt', async () => { + let attempts = 0; + const service = fakeService([ + fakeEntry({ + id: 'kimi-cu', + install: () => { + attempts += 1; + return attempts === 1 + ? Promise.reject(new Error('boom')) + : Promise.resolve(undefined); + }, + }), + ]); + 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)); + } + const failed = await service.getCapability('kimi-cu'); + expect(failed.install).toEqual({ running: false, error: 'boom' }); + + // Retry clears the error. + 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)); + } + const retried = await service.getCapability('kimi-cu'); + expect(retried.install.error).toBeUndefined(); + expect(attempts).toBe(2); + }); +}); + +describe('CapabilityService shelf-install hook', () => { + function mutableEntry(opts: { + id: 'kimi-cu' | 'kimi-webbridge'; + wiringStepId?: string; + steps: Array<{ id: string; state: 'ok' | 'missing'; optional?: boolean }>; + install?: () => Promise; + }) { + const state = { steps: opts.steps }; + let installs = 0; + const entry = fakeEntry({ + id: opts.id, + wiringStepId: opts.wiringStepId, + detect: { steps: state.steps }, + install: () => { + installs += 1; + return (opts.install ?? (() => Promise.resolve(undefined)))(); + }, + }); + // Re-read the mutable step list on every detect. + entry.detect = () => Promise.resolve({ steps: state.steps }); + return { entry, state, installs: () => installs }; + } + + async function settle(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + } + + it('auto-completes binary layers when the wiring plugin gets installed', async () => { + const wiringStepId = 'skill'; + const cu = mutableEntry({ + id: 'kimi-cu', + wiringStepId, + steps: [ + { id: 'skill', state: 'missing' }, + { id: 'daemon', state: 'missing' }, + ], + }); + const plugins = fakePlugins(); + fakeService([cu.entry], plugins); + + // Shelf install lands the wiring layer. + cu.state.steps = [ + { id: 'skill', state: 'ok' }, + { id: 'daemon', state: 'missing' }, + ]; + plugins.fireReload(); + await settle(); + expect(cu.installs()).toBe(1); + + // A later reload with the wiring still ok must NOT retrigger + // (manual steps may still be missing — no heavy re-download loop). + plugins.fireReload(); + await settle(); + expect(cu.installs()).toBe(1); + }); + + it('does nothing when already ready or when wiring is removed', async () => { + const ready = mutableEntry({ + id: 'kimi-cu', + steps: [{ id: 'plugin', state: 'ok' }], + }); + const plugins = fakePlugins(); + fakeService([ready.entry], plugins); + + plugins.fireReload(); + await settle(); + expect(ready.installs()).toBe(0); + + // Wiring removed → no trigger; wiring back → triggers again. + ready.state.steps = [{ id: 'plugin', state: 'missing' }]; + plugins.fireReload(); + await settle(); + expect(ready.installs()).toBe(0); + ready.state.steps = [ + { id: 'plugin', state: 'ok' }, + { id: 'app', state: 'missing' }, + ]; + // Transition needs a false edge first: previous event set it to false. + plugins.fireReload(); + await settle(); + expect(ready.installs()).toBe(1); + }); + + it('skips unsupported entries and entries already installing', async () => { + const unsupported = mutableEntry({ + id: 'kimi-cu', + steps: [{ id: 'plugin', state: 'ok' }, { id: 'app', state: 'missing' }], + }); + const plugins = fakePlugins(); + const entry = { ...unsupported.entry, supported: false }; + fakeService([entry], plugins); + plugins.fireReload(); + await settle(); + expect(unsupported.installs()).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/app/capability/kimiCu.test.ts b/packages/agent-core-v2/test/app/capability/kimiCu.test.ts new file mode 100644 index 0000000000..f3f9cfdbda --- /dev/null +++ b/packages/agent-core-v2/test/app/capability/kimiCu.test.ts @@ -0,0 +1,208 @@ +/** + * `kimi-cu` capability entry — permission-status parsing, app bundle + * version reading, layered detect (plugin / app / service / permissions), + * and platform gating. Host effects are faked (temp app bundle, scripted + * host processes, fake plugins). + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { Readable, Writable } from 'node:stream'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { IPluginService } from '#/app/plugin/plugin'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; +import type { CapabilityEntryContext } from '#/app/capability/entries/context'; +import { + createKimiCuEntry, + parsePermissionStatus, + readAppBundleVersion, +} from '#/app/capability/entries/kimiCu'; + +function fakeProc(code: number, stdout = '', stderr = ''): IHostProcess { + return { + _serviceBrand: undefined, + pid: 1234, + exitCode: code, + stdin: new Writable({ + write: (_c, _e, cb) => { + cb(); + }, + }), + stdout: Readable.from([stdout]), + stderr: Readable.from([stderr]), + wait: () => Promise.resolve(code), + kill: () => Promise.resolve(), + dispose: () => undefined, + } as IHostProcess; +} + +function fakeHostProcess( + script: Array<{ match: string; code: number; stdout?: string; stderr?: string }>, +): { service: IHostProcessService; calls: string[] } { + const calls: string[] = []; + const service: IHostProcessService = { + _serviceBrand: undefined, + spawn: (command: string, args: readonly string[] = []) => { + const key = `${command} ${args.join(' ')}`; + calls.push(key); + const hit = script.find((s) => key.includes(s.match)); + return Promise.resolve(fakeProc(hit?.code ?? 0, hit?.stdout ?? '', hit?.stderr ?? '')); + }, + } as IHostProcessService; + return { service, calls }; +} + +function fakePlugins( + installed: Array<{ id: string; enabled: boolean; state: string; version?: string }>, +): { service: IPluginService; installs: string[] } { + const installs: string[] = []; + const service = { + listPlugins: () => + Promise.resolve( + installed.map((p) => ({ + id: p.id, + displayName: p.id, + version: p.version, + enabled: p.enabled, + state: p.state, + skillCount: 1, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'zip-url', + })), + ), + installPlugin: (input: { source: string }) => { + installs.push(input.source); + return Promise.resolve({} as never); + }, + } as unknown as IPluginService; + return { service, installs }; +} + +describe('parsePermissionStatus', () => { + it('parses the machine-readable request-permissions output', () => { + expect(parsePermissionStatus('permissions: accessibility=true screenRecording=true')).toEqual({ + accessibility: true, + screenRecording: true, + }); + expect(parsePermissionStatus('permissions: accessibility=true screenRecording=false')).toEqual({ + accessibility: true, + screenRecording: false, + }); + expect(parsePermissionStatus('unknown command')).toBeUndefined(); + expect(parsePermissionStatus('')).toBeUndefined(); + }); +}); + +describe('readAppBundleVersion', () => { + let root: string; + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'kimi-cu-version-')); + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('reads CFBundleShortVersionString from Info.plist', async () => { + const plist = path.join(root, 'Info.plist'); + await writeFile( + plist, + ` +CFBundleShortVersionString +0.4.18 +`, + ); + expect(await readAppBundleVersion(plist)).toBe('0.4.18'); + }); + + it('returns undefined for a missing file', async () => { + expect(await readAppBundleVersion(path.join(root, 'nope.plist'))).toBeUndefined(); + }); +}); + +describe('kimi-cu entry', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'kimi-cu-entry-')); + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + async function fakeAppBundle(): Promise { + const applicationsDir = path.join(root, 'Applications'); + const macosDir = path.join(applicationsDir, 'KimiCU.app', 'Contents', 'MacOS'); + await mkdir(macosDir, { recursive: true }); + await writeFile(path.join(macosDir, 'kimi-cu'), '#!/bin/sh\n'); + await writeFile( + path.join(applicationsDir, 'KimiCU.app', 'Contents', 'Info.plist'), + 'CFBundleShortVersionString\n0.4.18', + ); + return applicationsDir; + } + + function makeCtx(overrides: Partial = {}): CapabilityEntryContext { + return { + platform: 'darwin', + arch: 'arm64', + kimiHomeDir: path.join(root, 'kimi-home'), + userHomeDir: path.join(root, 'user-home'), + plugins: fakePlugins([]).service, + hostProcess: fakeHostProcess([]).service, + ...overrides, + }; + } + + it('is supported only on macOS', () => { + expect(createKimiCuEntry(makeCtx()).supported).toBe(true); + expect(createKimiCuEntry(makeCtx({ platform: 'linux' })).supported).toBe(false); + expect(createKimiCuEntry(makeCtx({ platform: 'win32' })).supported).toBe(false); + }); + + it('detects all four layers with details', async () => { + const applicationsDir = await fakeAppBundle(); + const plugins = fakePlugins([{ id: 'kimi-cu', enabled: true, state: 'ok', version: '0.4.18' }]); + const host = fakeHostProcess([ + { match: 'service-status', code: 0, stdout: 'SMAppService status=1 (1=enabled); fallback plist exists=false' }, + { match: 'request-permissions', code: 0, stdout: 'permissions: accessibility=true screenRecording=false' }, + ]); + const entry = createKimiCuEntry( + makeCtx({ applicationsDir, plugins: plugins.service, hostProcess: host.service }), + ); + + const detected = await entry.detect(); + expect(detected.version).toBe('0.4.18'); + expect(detected.steps).toEqual([ + { id: 'plugin', state: 'ok', detail: '0.4.18' }, + { id: 'app', state: 'ok', detail: '0.4.18' }, + { id: 'service', state: 'ok' }, + { id: 'permissions', state: 'missing', detail: 'screenRecording' }, + ]); + }); + + it('reports missing layers on a bare machine', async () => { + const entry = createKimiCuEntry(makeCtx({ applicationsDir: path.join(root, 'Applications') })); + const detected = await entry.detect(); + expect(detected.version).toBeUndefined(); + expect(detected.steps.map((s) => [s.id, s.state])).toEqual([ + ['plugin', 'missing'], + ['app', 'missing'], + ['service', 'missing'], + ['permissions', 'missing'], + ]); + }); + + it('rejects install on non-macOS before any side effect', async () => { + const plugins = fakePlugins([]); + const entry = createKimiCuEntry(makeCtx({ platform: 'linux', plugins: plugins.service })); + await expect(entry.install(() => {})).rejects.toThrow(/only supported on macOS/); + expect(plugins.installs).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts new file mode 100644 index 0000000000..1bebf32142 --- /dev/null +++ b/packages/agent-core-v2/test/app/capability/kimiWebbridge.test.ts @@ -0,0 +1,269 @@ +/** + * `kimi-webbridge` capability entry — platform asset mapping, layered + * detect, and the idempotent install flow (download → start-if-down → + * plugin wiring → un-shadow user-source skill). All host effects are faked + * (temp dirs, scripted fetch, scripted host processes, fake plugins). + */ + +import { mkdtemp, readFile, rm, mkdir, writeFile, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { Readable, Writable } from 'node:stream'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { IPluginService } from '#/app/plugin/plugin'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; +import { + __kimiWebbridgeInternals, + createKimiWebbridgeEntry, +} from '#/app/capability/entries/kimiWebbridge'; +import type { CapabilityEntryContext } from '#/app/capability/entries/context'; + +const DAEMON_BASE = 'http://127.0.0.1:10086'; + +function fakeProc(code: number, stdout = '', stderr = ''): IHostProcess { + return { + _serviceBrand: undefined, + pid: 1234, + exitCode: code, + stdin: new Writable({ + write: (_c, _e, cb) => { + cb(); + }, + }), + stdout: Readable.from([stdout]), + stderr: Readable.from([stderr]), + wait: () => Promise.resolve(code), + kill: () => Promise.resolve(), + dispose: () => undefined, + } as IHostProcess; +} + +interface SpawnCall { + command: string; + args: readonly string[]; +} + +function fakeHostProcess(script?: Array<{ match: string; code: number; stdout?: string; stderr?: string }>): { + service: IHostProcessService; + calls: SpawnCall[]; +} { + const calls: SpawnCall[] = []; + const service: IHostProcessService = { + _serviceBrand: undefined, + spawn: (command: string, args: readonly string[] = []) => { + calls.push({ command, args }); + const key = `${command} ${args.join(' ')}`; + const hit = script?.find((s) => key.includes(s.match)); + return Promise.resolve(fakeProc(hit?.code ?? 0, hit?.stdout ?? '', hit?.stderr ?? '')); + }, + } as IHostProcessService; + return { service, calls }; +} + +function fakePlugins(installed: Array<{ id: string; enabled: boolean; state: string; version?: string }>): { + service: IPluginService; + installs: string[]; +} { + const installs: string[] = []; + const service = { + listPlugins: () => + Promise.resolve( + installed.map((p) => ({ + id: p.id, + displayName: p.id, + version: p.version, + enabled: p.enabled, + state: p.state, + skillCount: 1, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'zip-url', + })), + ), + installPlugin: (input: { source: string }) => { + installs.push(input.source); + installed.push({ id: 'kimi-webbridge', enabled: true, state: 'ok', version: '1.11.3' }); + return Promise.resolve({} as never); + }, + } as unknown as IPluginService; + return { service, installs }; +} + +/** Scripted fetch: answers daemon /status and CDN binary downloads. */ +function fakeFetch(opts: { + statusSequence?: Array; + binary?: Uint8Array; +}): { fetchImpl: typeof fetch } { + let statusCalls = 0; + const fetchImpl = (async (url: string | URL): Promise => { + const u = String(url); + if (u === `${DAEMON_BASE}/status`) { + const step = opts.statusSequence?.[Math.min(statusCalls, (opts.statusSequence?.length ?? 1) - 1)]; + statusCalls += 1; + if (step === 'error' || step === undefined) throw new Error('connection refused'); + return new Response(JSON.stringify(step), { status: 200 }); + } + if (u.includes('cdn.kimi.com/webbridge/')) { + const bytes = opts.binary ?? new Uint8Array([1, 2, 3, 4]); + return new Response(bytes, { + status: 200, + headers: { 'content-length': String(bytes.length) }, + }); + } + throw new Error(`unexpected fetch: ${u}`); + }) as unknown as typeof fetch; + return { fetchImpl }; +} + +describe('kimi-webbridge entry', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'kimi-webbridge-entry-')); + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + function makeCtx(overrides: Partial = {}): CapabilityEntryContext { + return { + platform: 'darwin', + arch: 'arm64', + kimiHomeDir: path.join(root, 'kimi-home'), + userHomeDir: path.join(root, 'user-home'), + plugins: fakePlugins([]).service, + hostProcess: fakeHostProcess().service, + ...overrides, + }; + } + + it('maps platforms to CDN asset names', () => { + const { binaryAssetName } = __kimiWebbridgeInternals; + expect(binaryAssetName('darwin', 'arm64')).toBe('kimi-webbridge-darwin-arm64'); + expect(binaryAssetName('darwin', 'x64')).toBe('kimi-webbridge-darwin-amd64'); + expect(binaryAssetName('linux', 'arm64')).toBe('kimi-webbridge-linux-arm64'); + expect(binaryAssetName('linux', 'x64')).toBe('kimi-webbridge-linux-amd64'); + expect(binaryAssetName('win32', 'x64')).toBe('kimi-webbridge-windows-amd64.exe'); + expect(binaryAssetName('win32', 'arm64')).toBeUndefined(); + expect(binaryAssetName('freebsd', 'x64')).toBeUndefined(); + }); + + it('is unsupported on unknown platforms', () => { + const entry = createKimiWebbridgeEntry(makeCtx({ platform: 'freebsd' })); + expect(entry.supported).toBe(false); + }); + + it('detects a fully installed daemon with extension as soft gate', async () => { + const userHome = path.join(root, 'user-home'); + await mkdir(path.join(userHome, '.kimi-webbridge', 'bin'), { recursive: true }); + await writeFile(path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge'), 'bin'); + const plugins = fakePlugins([{ id: 'kimi-webbridge', enabled: true, state: 'ok', version: '1.11.3' }]); + const { fetchImpl } = fakeFetch({ + statusSequence: [{ running: true, version: '3.1.1', extension_connected: false }], + }); + const entry = createKimiWebbridgeEntry(makeCtx({ plugins: plugins.service, fetchImpl })); + + const detected = await entry.detect(); + expect(detected.version).toBe('3.1.1'); + expect(detected.steps).toEqual([ + { id: 'daemon-binary', state: 'ok' }, + { id: 'daemon', state: 'ok', detail: '3.1.1' }, + { id: 'skill', state: 'ok', detail: '1.11.3' }, + { id: 'extension', state: 'missing', optional: true }, + ]); + }); + + it('reports no version when the daemon is down (the .version file is installer lineage)', async () => { + const userHome = path.join(root, 'user-home'); + await mkdir(path.join(userHome, '.kimi-webbridge', 'bin'), { recursive: true }); + await writeFile(path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge'), 'bin'); + // The on-disk version file carries the installer script's lineage (3.1.x), + // which must NOT be reported as the product version. + await writeFile( + path.join(userHome, '.kimi-webbridge', 'bin', 'kimi-webbridge.version'), + '3.1.6|9556880|1784209976000', + ); + const { fetchImpl } = fakeFetch({ statusSequence: ['error'] }); + const entry = createKimiWebbridgeEntry(makeCtx({ fetchImpl })); + + const detected = await entry.detect(); + expect(detected.version).toBeUndefined(); + expect(detected.steps.find((s) => s.id === 'daemon')?.state).toBe('missing'); + expect(detected.steps.find((s) => s.id === 'daemon-binary')?.state).toBe('ok'); + }); + + it('installs end-to-end: download, start-if-down, plugin wiring, un-shadow', async () => { + const kimiHome = path.join(root, 'kimi-home'); + const userHome = path.join(root, 'user-home'); + // Pre-existing user-source skills from the official installer / manual + // copies — both user dirs must be removed (either would shadow the plugin). + await mkdir(path.join(kimiHome, 'skills', 'kimi-webbridge'), { recursive: true }); + await writeFile(path.join(kimiHome, 'skills', 'kimi-webbridge', 'SKILL.md'), 'old'); + await mkdir(path.join(userHome, '.agents', 'skills', 'kimi-webbridge'), { recursive: true }); + await writeFile(path.join(userHome, '.agents', 'skills', 'kimi-webbridge', 'SKILL.md'), 'old'); + + const plugins = fakePlugins([]); + const host = fakeHostProcess(); + // First status poll (before start): down. Subsequent polls: up. + const { fetchImpl } = fakeFetch({ + statusSequence: [{ running: false }, { running: true, version: '3.1.1', extension_connected: true }], + }); + const reports: Array<[string, number | undefined]> = []; + const entry = createKimiWebbridgeEntry( + makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }), + ); + + const note = await entry.install((step, percent) => reports.push([step, percent])); + + // Migration note surfaced (the pre-existing user skill was replaced). + expect(note).toBe('user-skill-migrated'); + + // Binary downloaded into place and made executable. + const binPath = path.join(root, 'user-home', '.kimi-webbridge', 'bin', 'kimi-webbridge'); + await access(binPath); + // Daemon started exactly once (start-if-down). + expect(host.calls.map((c) => `${c.command} ${c.args.join(' ')}`)).toEqual([`${binPath} start`]); + // Plugin wiring installed from the official CDN zip. + expect(plugins.installs).toEqual([ + 'https://code.kimi.com/kimi-code/plugins/official/kimi-webbridge.zip', + ]); + // User-source shadows removed from BOTH user dirs. + await expect(access(path.join(kimiHome, 'skills', 'kimi-webbridge'))).rejects.toThrow(); + await expect(access(path.join(userHome, '.agents', 'skills', 'kimi-webbridge'))).rejects.toThrow(); + // Progress reported download steps. + expect(reports[0]).toEqual(['download', 0]); + expect(reports.some(([step]) => step === 'daemon')).toBe(true); + expect(reports.some(([step]) => step === 'skill')).toBe(true); + }); + + it('never starts the daemon when one is already running (coexistence)', async () => { + const plugins = fakePlugins([]); + const host = fakeHostProcess(); + const { fetchImpl } = fakeFetch({ + statusSequence: [{ running: true, version: '3.1.1', extension_connected: true }], + }); + const entry = createKimiWebbridgeEntry( + makeCtx({ plugins: plugins.service, hostProcess: host.service, fetchImpl }), + ); + + const note = await entry.install(() => {}); + expect(host.calls).toEqual([]); + // No pre-existing user skill → no migration note. + expect(note).toBeUndefined(); + }); + + it('rejects install on unsupported platforms before any side effect', async () => { + const plugins = fakePlugins([]); + const entry = createKimiWebbridgeEntry( + makeCtx({ platform: 'freebsd', plugins: plugins.service }), + ); + await expect(entry.install(() => {})).rejects.toThrow(/not supported/); + expect(plugins.installs).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts index ee7e52912b..4eb9525e0c 100644 --- a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts +++ b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts @@ -359,6 +359,78 @@ describe('PluginService (plugin boundary)', () => { } }); + it('reloads when another process rewrites installed.json (shared-home convergence)', async () => { + const home = await makeHome(); + await writeValidInstalledFile(home); + const source = await makePluginDir('external-demo', { version: '1.0.0' }); + createdDirs.push(source); + const host = makeHost(home); + try { + const svc = host.app.accessor.get(IPluginService); + // Settle the initial load before the external write (the watcher's + // echo window suppresses events for 250ms after our own mutation). + await svc.listPlugins(); + await new Promise((resolve) => setTimeout(resolve, 300)); + const summaries: ReloadSummary[] = []; + svc.onDidReload((summary) => summaries.push(summary)); + + // Simulate a CLI/desktop peer installing a plugin through the same + // manager semantics: write the record, then materialize the managed dir. + const peer = await makePluginDir('peer-demo', { version: '2.0.0' }); + createdDirs.push(peer); + await writeInstalledFile( + home, + JSON.stringify({ + version: 1, + plugins: [ + { + id: 'peer-demo', + root: peer, + source: 'local-path', + enabled: true, + installedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }), + ); + + for (let i = 0; i < 50 && summaries.length === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + expect(summaries).toEqual([{ added: ['peer-demo'], removed: [], errors: [] }]); + const list = await svc.listPlugins(); + expect(list.map((p) => p.id)).toContain('peer-demo'); + } finally { + host.dispose(); + } + }); + + it('fires onDidReload on install, enable-toggle, and remove (not just explicit reload)', async () => { + const home = await makeHome(); + await writeValidInstalledFile(home); + const source = await makePluginDir('events-demo', { version: '1.0.0' }); + createdDirs.push(source); + const host = makeHost(home); + try { + const svc = host.app.accessor.get(IPluginService); + const summaries: ReloadSummary[] = []; + svc.onDidReload((summary) => summaries.push(summary)); + + await svc.installPlugin({ source }); + await svc.setPluginEnabled({ id: 'events-demo', enabled: false }); + await svc.removePlugin({ id: 'events-demo' }); + + expect(summaries).toEqual([ + { added: ['events-demo'], removed: [], errors: [] }, + { added: [], removed: [], errors: [] }, + { added: [], removed: ['events-demo'], errors: [] }, + ]); + } finally { + host.dispose(); + } + }); + it('restores the previous managed copy when reinstall persistence fails', async () => { const home = await makeHome(); await writeValidInstalledFile(home); diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index e8f033aa78..5a72fa4e55 100644 --- a/packages/kap-server/src/protocol/error-codes.ts +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -72,6 +72,10 @@ export const ErrorCode = { TOOL_CALL_NOT_FOUND: 40416, /** 目录(models.dev catalog)中不存在该条目 */ CATALOG_ENTRY_NOT_FOUND: 40417, + /** capability_id 不存在 */ + CAPABILITY_NOT_FOUND: 40418, + /** plugin_id 不存在 */ + PLUGIN_NOT_FOUND: 40419, /** session 有正在进行的 prompt,拒绝新请求 */ SESSION_BUSY: 40901, @@ -116,6 +120,10 @@ export const ErrorCode = { GOAL_UNSUPPORTED_AGENT: 40920, /** 创建时 provider_id 已存在 */ PROVIDER_ALREADY_EXISTS: 40921, + /** capability 正在安装中,拒绝并发安装 */ + CAPABILITY_INSTALL_IN_PROGRESS: 40922, + /** 当前平台/架构不支持该 capability */ + CAPABILITY_UNSUPPORTED: 40923, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, diff --git a/packages/kap-server/src/protocol/rest-capability.ts b/packages/kap-server/src/protocol/rest-capability.ts new file mode 100644 index 0000000000..0e217329b3 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-capability.ts @@ -0,0 +1,46 @@ +/** + * GET /v1/capabilities + * GET /v1/capabilities/{capability_id} + * POST /v1/capabilities/{capability_id}:install + */ + +import { z } from 'zod'; + +export const capabilityStepSchema = z.object({ + id: z.string(), + state: z.enum(['ok', 'missing', 'failed']), + detail: z.string().optional(), + optional: z.boolean().optional(), +}); +export type CapabilityStepWire = z.infer; + +export const capabilityInstallProgressSchema = z.object({ + running: z.boolean(), + step: z.string().optional(), + percent: z.number().min(0).max(100).optional(), + error: z.string().optional(), + note: z.string().optional(), +}); +export type CapabilityInstallProgressWire = z.infer; + +export const capabilityStatusSchema = z.object({ + id: z.string(), + displayName: z.string(), + description: z.string(), + supported: z.boolean(), + state: z.enum(['not_installed', 'partial', 'ready', 'unsupported']), + version: z.string().optional(), + steps: z.array(capabilityStepSchema), + install: capabilityInstallProgressSchema, +}); +export type CapabilityStatusWire = z.infer; + +export const listCapabilitiesResponseSchema = z.object({ + capabilities: z.array(capabilityStatusSchema), +}); +export type ListCapabilitiesResponse = z.infer; + +export const capabilityIdParamSchema = z.object({ + capability_id: z.string().min(1), +}); +export type CapabilityIdParam = z.infer; diff --git a/packages/kap-server/src/protocol/rest-plugin.ts b/packages/kap-server/src/protocol/rest-plugin.ts new file mode 100644 index 0000000000..b7f4a2ce00 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-plugin.ts @@ -0,0 +1,68 @@ +/** + * GET /v1/plugins + * GET /v1/plugins/marketplace + * POST /v1/plugins + * POST /v1/plugins/{plugin_id}:{enable,disable,remove} + */ + +import { z } from 'zod'; + +export const pluginSummarySchema = z.object({ + id: z.string(), + displayName: z.string(), + version: z.string().optional(), + enabled: z.boolean(), + state: z.enum(['ok', 'error']), + skillCount: z.number(), + mcpServerCount: z.number(), + enabledMcpServerCount: z.number(), + hookCount: z.number(), + commandCount: z.number(), + hasErrors: z.boolean(), + source: z.enum(['local-path', 'zip-url', 'github']), + originalSource: z.string().optional(), +}); +export type PluginSummaryWire = z.infer; + +export const listPluginsResponseSchema = z.object({ + plugins: z.array(pluginSummarySchema), +}); +export type ListPluginsResponse = z.infer; + +export const installPluginRequestSchema = z.object({ + /** local path, https zip URL, or GitHub repo URL — same semantics as the CLI. */ + source: z.string().min(1), +}); +export type InstallPluginRequest = z.infer; + +export const pluginMarketplaceEntrySchema = z.object({ + id: z.string(), + tier: z.enum(['official', 'curated', 'third-party']), + displayName: z.string(), + description: z.string().optional(), + homepage: z.string().optional(), + keywords: z.array(z.string()).optional(), + /** Catalog-declared version; absent for entries that track a moving source. */ + version: z.string().optional(), + source: z.string(), + /** Present when the plugin is installed locally (detected on demand). */ + installed: z + .object({ + version: z.string().optional(), + enabled: z.boolean(), + }) + .optional(), + /** True only when both versions are valid semver and catalog > installed. */ + updateAvailable: z.boolean().optional(), +}); +export type PluginMarketplaceEntryWire = z.infer; + +export const pluginMarketplaceResponseSchema = z.object({ + entries: z.array(pluginMarketplaceEntrySchema), +}); +export type PluginMarketplaceResponse = z.infer; + +export const pluginIdParamSchema = z.object({ + tail: z.string().min(1), +}); +export type PluginIdParam = z.infer; diff --git a/packages/kap-server/src/routes/capabilities.ts b/packages/kap-server/src/routes/capabilities.ts new file mode 100644 index 0000000000..124999ab7d --- /dev/null +++ b/packages/kap-server/src/routes/capabilities.ts @@ -0,0 +1,177 @@ +/** + * `/capabilities` REST routes — built-in product capabilities (kimi-cu, + * kimi-webbridge): layered readiness detection + idempotent install. + * + * GET /capabilities data: {capabilities: CapabilityStatus[]} + * GET /capabilities/{capability_id} data: CapabilityStatus + * POST /capabilities/{capability_id}:install data: CapabilityStatus (install running) + * + * The route surface is a thin projection of the App-scope `ICapabilityService` + * (`agent-core-v2/app/capability`): the closed registry lives there, install + * sources are fixed official CDN URLs, and progress is polled through these + * reads (no WS events in v1). + * + * **Action suffix**: `:install` is the only action — the `POST` path uses the + * shared `parseActionSuffix` helper (bare ids are rejected). + * + * **Error mapping**: + * - unknown capability id → envelope `code: 40418 capability.not_found` + * - install on wrong platform → `40923 capability.unsupported` + * - install already running → `40922 capability.install_in_progress` + * - malformed `{tail}` → `40001 validation.failed` + * - other errors → `50001` via the global error handler + */ + +import { CapabilityErrors, ICapabilityService, isError2, type Scope } from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + capabilityIdParamSchema, + capabilityStatusSchema, + listCapabilitiesResponseSchema, +} from '../protocol/rest-capability'; +import { parseActionSuffix } from './action-suffix'; + +interface CapabilitiesRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const capabilityTailParamsSchema = z.object({ + tail: z.string().min(1), +}); + +export function registerCapabilitiesRoutes(app: CapabilitiesRouteHost, core: Scope): void { + // GET /capabilities ----------------------------------------------------- + const listRoute = defineRoute( + { + method: 'GET', + path: '/capabilities', + success: { data: listCapabilitiesResponseSchema }, + errors: {}, + description: 'List built-in capabilities with layered readiness status', + tags: ['capabilities'], + operationId: 'listCapabilities', + }, + async (req, reply) => { + const capabilities = await core.accessor.get(ICapabilityService).listCapabilities(); + reply.send(okEnvelope({ capabilities }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + // GET /capabilities/{capability_id} -------------------------------------- + const getRoute = defineRoute( + { + method: 'GET', + path: '/capabilities/{capability_id}', + params: capabilityIdParamSchema, + success: { data: capabilityStatusSchema }, + errors: { + [ErrorCode.CAPABILITY_NOT_FOUND]: {}, + }, + description: 'Get one capability readiness status', + tags: ['capabilities'], + operationId: 'getCapability', + }, + async (req, reply) => { + try { + const capability = await core.accessor + .get(ICapabilityService) + .getCapability(req.params.capability_id); + reply.send(okEnvelope(capability, req.id)); + } catch (error) { + reply.send(mapCapabilityError(error, req.id)); + } + }, + ); + app.get( + getRoute.path, + getRoute.options, + getRoute.handler as Parameters[2], + ); + + // POST /capabilities/{capability_id}:install ----------------------------- + const installRoute = defineRoute( + { + method: 'POST', + path: '/capabilities/{tail}', + params: capabilityTailParamsSchema, + success: { data: capabilityStatusSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.CAPABILITY_NOT_FOUND]: {}, + [ErrorCode.CAPABILITY_UNSUPPORTED]: {}, + [ErrorCode.CAPABILITY_INSTALL_IN_PROGRESS]: {}, + }, + description: 'Start an idempotent capability install (poll GET for progress)', + tags: ['capabilities'], + operationId: 'installCapability', + }, + async (req, reply) => { + const parsed = parseActionSuffix({ + tail: req.params.tail, + allowedActions: ['install'], + resourceLabel: 'capability', + }); + if (parsed.kind !== 'action') { + const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${req.params.tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + try { + const capability = await core.accessor + .get(ICapabilityService) + .installCapability(parsed.id); + reply.send(okEnvelope(capability, req.id)); + } catch (error) { + reply.send(mapCapabilityError(error, req.id)); + } + }, + ); + app.post( + installRoute.path, + installRoute.options, + installRoute.handler as Parameters[2], + ); +} + +const CAPABILITY_ERROR_MAP: Readonly> = { + [CapabilityErrors.codes.CAPABILITY_NOT_FOUND]: ErrorCode.CAPABILITY_NOT_FOUND, + [CapabilityErrors.codes.CAPABILITY_UNSUPPORTED]: ErrorCode.CAPABILITY_UNSUPPORTED, + [CapabilityErrors.codes.CAPABILITY_INSTALL_IN_PROGRESS]: ErrorCode.CAPABILITY_INSTALL_IN_PROGRESS, +}; + +function mapCapabilityError(error: unknown, requestId: string) { + const mapped = isError2(error) ? CAPABILITY_ERROR_MAP[error.code] : undefined; + if (mapped !== undefined && isError2(error)) { + return errEnvelope(mapped, error.message, requestId, error.stack); + } + return errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + requestId, + error instanceof Error ? error.stack : undefined, + ); +} diff --git a/packages/kap-server/src/routes/plugins.ts b/packages/kap-server/src/routes/plugins.ts new file mode 100644 index 0000000000..50c153ff03 --- /dev/null +++ b/packages/kap-server/src/routes/plugins.ts @@ -0,0 +1,297 @@ +/** + * `/plugins` REST routes — plugin management and the marketplace catalog. + * + * GET /plugins data: {plugins: PluginSummary[]} + * GET /plugins/marketplace data: {entries: MarketplaceEntry[]} + * POST /plugins body {source} data: PluginSummary + * POST /plugins/{plugin_id}:enable|:disable|:remove + * + * Thin projection of the App-scope `IPluginService` (install/remove/enable + * are serialized there and fire `onDidReload`, which converges session skill + * catalogs and the capability shelf-install hook). The marketplace catalog is + * fetched on demand from the configured URL (`pluginMarketplaceUrl` server + * option, env `KIMI_CODE_PLUGIN_MARKETPLACE_URL`, default the production + * catalog) and merged with the live install state — install status is always + * detected from the local records, never from the catalog. + * + * **Action suffix**: `:enable` / `:disable` / `:remove` via `parseActionSuffix` + * (bare ids rejected). + * + * **Error mapping**: + * - unknown plugin id → `40419 plugin.not_found` (from the domain code) + * - malformed `{tail}` / body → `40001 validation.failed` + * - catalog unreachable/invalid → `50001` with a plain-language message + * - other errors → `50001` via the global error handler + */ + +import { IPluginService, PluginErrors, isError2, type Scope } from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + installPluginRequestSchema, + listPluginsResponseSchema, + pluginMarketplaceResponseSchema, + pluginIdParamSchema, + pluginSummarySchema, + type PluginMarketplaceEntryWire, +} from '../protocol/rest-plugin'; +import { parseActionSuffix } from './action-suffix'; + +interface PluginsRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const PLUGIN_ACTIONS = ['enable', 'disable', 'remove'] as const; + +const MARKETPLACE_FETCH_TIMEOUT_MS = 10_000; + +const rawMarketplaceSchema = z.object({ + plugins: z.array( + z.object({ + id: z.string().min(1), + tier: z.enum(['official', 'curated']).optional(), + displayName: z.string().optional(), + description: z.string().optional(), + homepage: z.string().optional(), + keywords: z.array(z.string()).optional(), + version: z.string().optional(), + source: z.string().min(1), + }), + ), +}); + +/** Strict `x.y.z` numeric comparison (no prerelease); avoids a semver dep. */ +function semverGt(a: string, b: string): boolean { + const parse = (v: string): number[] | undefined => { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v); + return m === null ? undefined : [Number(m[1]), Number(m[2]), Number(m[3])]; + }; + const pa = parse(a); + const pb = parse(b); + if (pa === undefined || pb === undefined) return false; + for (let i = 0; i < 3; i += 1) { + if (pa[i]! > pb[i]!) return true; + if (pa[i]! < pb[i]!) return false; + } + return false; +} + +export interface PluginsRouteOptions { + /** Resolved catalog URL (server option / env already applied by start.ts). */ + readonly marketplaceUrl: string; + readonly fetchImpl?: typeof fetch; +} + +export function registerPluginsRoutes( + app: PluginsRouteHost, + core: Scope, + opts: PluginsRouteOptions, +): void { + // GET /plugins/marketplace — registered BEFORE /plugins/{tail} so the + // literal segment wins over the param route. + const marketplaceRoute = defineRoute( + { + method: 'GET', + path: '/plugins/marketplace', + success: { data: pluginMarketplaceResponseSchema }, + errors: {}, + description: 'List the plugin marketplace catalog merged with live install state', + tags: ['plugins'], + operationId: 'listPluginMarketplace', + }, + async (req, reply) => { + const fetchImpl = opts.fetchImpl ?? fetch; + let raw: unknown; + try { + const resp = await fetchImpl(opts.marketplaceUrl, { + signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + raw = await resp.json(); + } catch (error) { + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + `Plugin marketplace is unreachable: ${error instanceof Error ? error.message : String(error)}`, + req.id, + ), + ); + return; + } + const parsed = rawMarketplaceSchema.safeParse(raw); + if (!parsed.success) { + reply.send( + errEnvelope(ErrorCode.INTERNAL_ERROR, 'Plugin marketplace returned an invalid catalog', req.id), + ); + return; + } + const installed = await core.accessor.get(IPluginService).listPlugins(); + const byId = new Map(installed.map((p) => [p.id, p])); + const entries: PluginMarketplaceEntryWire[] = parsed.data.plugins.map((entry) => { + const record = byId.get(entry.id); + const installedInfo = + record === undefined + ? undefined + : { + enabled: record.enabled, + ...(record.version !== undefined ? { version: record.version } : {}), + }; + const updateAvailable = + entry.version !== undefined && + record?.version !== undefined && + semverGt(entry.version, record.version); + return { + id: entry.id, + tier: entry.tier ?? 'third-party', + displayName: entry.displayName ?? entry.id, + ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(entry.homepage !== undefined ? { homepage: entry.homepage } : {}), + ...(entry.keywords !== undefined ? { keywords: entry.keywords } : {}), + ...(entry.version !== undefined ? { version: entry.version } : {}), + source: entry.source, + ...(installedInfo !== undefined ? { installed: installedInfo } : {}), + ...(updateAvailable ? { updateAvailable: true } : {}), + }; + }); + reply.send(okEnvelope({ entries }, req.id)); + }, + ); + app.get( + marketplaceRoute.path, + marketplaceRoute.options, + marketplaceRoute.handler as Parameters[2], + ); + + // GET /plugins ------------------------------------------------------------ + const listRoute = defineRoute( + { + method: 'GET', + path: '/plugins', + success: { data: listPluginsResponseSchema }, + errors: {}, + description: 'List installed plugins', + tags: ['plugins'], + operationId: 'listPlugins', + }, + async (req, reply) => { + const plugins = await core.accessor.get(IPluginService).listPlugins(); + reply.send(okEnvelope({ plugins }, req.id)); + }, + ); + app.get( + listRoute.path, + listRoute.options, + listRoute.handler as Parameters[2], + ); + + // POST /plugins {source} -------------------------------------------------- + const installRoute = defineRoute( + { + method: 'POST', + path: '/plugins', + body: installPluginRequestSchema, + success: { data: pluginSummarySchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + }, + description: 'Install a plugin from a local path, zip URL, or GitHub repo', + tags: ['plugins'], + operationId: 'installPlugin', + }, + async (req, reply) => { + try { + const plugin = await core.accessor.get(IPluginService).installPlugin(req.body); + reply.send(okEnvelope(plugin, req.id)); + } catch (error) { + reply.send(mapPluginError(error, req.id)); + } + }, + ); + app.post( + installRoute.path, + installRoute.options, + installRoute.handler as Parameters[2], + ); + + // POST /plugins/{plugin_id}:{enable|disable|remove} ------------------------ + const actionRoute = defineRoute( + { + method: 'POST', + path: '/plugins/{tail}', + params: pluginIdParamSchema, + success: { data: z.object({ ok: z.literal(true) }) }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.PLUGIN_NOT_FOUND]: {}, + }, + description: 'Enable, disable, or remove an installed plugin', + tags: ['plugins'], + operationId: 'pluginAction', + }, + async (req, reply) => { + const parsed = parseActionSuffix({ + tail: req.params.tail, + allowedActions: PLUGIN_ACTIONS, + resourceLabel: 'plugin', + }); + if (parsed.kind !== 'action') { + const message = + parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${req.params.tail}`; + reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); + return; + } + const plugins = core.accessor.get(IPluginService); + try { + switch (parsed.action) { + case 'enable': + await plugins.setPluginEnabled({ id: parsed.id, enabled: true }); + break; + case 'disable': + await plugins.setPluginEnabled({ id: parsed.id, enabled: false }); + break; + case 'remove': + await plugins.removePlugin({ id: parsed.id }); + break; + } + reply.send(okEnvelope({ ok: true as const }, req.id)); + } catch (error) { + reply.send(mapPluginError(error, req.id)); + } + }, + ); + app.post( + actionRoute.path, + actionRoute.options, + actionRoute.handler as Parameters[2], + ); +} + +function mapPluginError(error: unknown, requestId: string) { + if (isError2(error) && error.code === PluginErrors.codes.PLUGIN_NOT_FOUND) { + return errEnvelope(ErrorCode.PLUGIN_NOT_FOUND, error.message, requestId, error.stack); + } + return errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + requestId, + error instanceof Error ? error.stack : undefined, + ); +} diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 36adaa3e44..1652873b42 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -19,6 +19,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'; @@ -77,6 +79,8 @@ export interface RegisterApiV1RoutesOptions { readonly broadcaster: SessionEventBroadcaster; readonly snapshotReader: ISnapshotReader; readonly transcriptService: TranscriptService; + /** Catalog URL for the `/plugins/marketplace` route (resolved by start.ts). */ + readonly pluginMarketplaceUrl: string; /** * Surface `dangerous_bypass_auth` in the `/meta` payload. Set by `start.ts` * from the `disableAuth` server option (the `--dangerous-bypass-auth` CLI @@ -124,6 +128,13 @@ export async function registerApiV1Routes( { hostIdentity: opts.hostIdentity }, ); registerSkillsRoutes(apiV1 as unknown as Parameters[0], core); + registerPluginsRoutes(apiV1 as unknown as Parameters[0], core, { + marketplaceUrl: opts.pluginMarketplaceUrl, + }); + registerCapabilitiesRoutes( + apiV1 as unknown as Parameters[0], + core, + ); registerMessagesRoutes( apiV1 as unknown as Parameters[0], core, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 8fa4d47597..4d8184cfe3 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -55,6 +55,9 @@ import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcast import { FsWatchBridge } from './transport/ws/v1/fsWatchBridge'; import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; import { getServerVersion } from './version'; + +/** Default plugin marketplace catalog (overridable per server option or env). */ +const DEFAULT_PLUGIN_MARKETPLACE_URL = 'https://code.kimi.com/kimi-code/plugins/marketplace.json'; import { classify } from './security/bindClassify'; import { createHostCheck, @@ -98,6 +101,12 @@ export interface ServerHostIdentity extends KimiHostIdentity { export interface ServerStartOptions { readonly host?: string; readonly port?: number; + /** + * 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 homeDir?: string; readonly configPath?: string; /** @@ -449,6 +458,10 @@ export async function startServer(opts: ServerStartOptions): Promise { void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); }, diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index df90320865..3c6e08b7f7 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -40,6 +40,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/auth", ], + [ + "GET", + "/api/v1/capabilities", + ], + [ + "GET", + "/api/v1/capabilities/{capability_id}", + ], [ "GET", "/api/v1/catalog/providers", @@ -124,6 +132,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", @@ -248,6 +264,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", @@ -300,6 +320,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/oauth/logout", ], + [ + "POST", + "/api/v1/plugins", + ], + [ + "POST", + "/api/v1/plugins/{tail}", + ], [ "POST", "/api/v1/providers", diff --git a/packages/kap-server/test/capabilities.test.ts b/packages/kap-server/test/capabilities.test.ts new file mode 100644 index 0000000000..1fa9220460 --- /dev/null +++ b/packages/kap-server/test/capabilities.test.ts @@ -0,0 +1,135 @@ +/** + * `/api/v1` capabilities routes — wire contract: + * - GET /api/v1/capabilities → envelope shape + both entries + * - GET /api/v1/capabilities/{unknown} → 40418 + * - POST /api/v1/capabilities/{unknown}:install → 40418 + * - POST /api/v1/capabilities/{id} (bare) → 40001 + * - POST /api/v1/capabilities/{id}:{bogus} → 40001 + * - POST /api/v1/capabilities/kimi-cu:install on a non-macOS host → 40923 + * + * Real installs are never triggered from tests: the only `:install` calls + * target an unknown id or an unsupported platform. `GET` runs the entries' + * read-only detection against the isolated home dir. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + capabilityStatusSchema, + listCapabilitiesResponseSchema, +} from '../src/protocol/rest-capability'; +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 /api/v1 capabilities', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-capabilities-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function postJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: '{}', + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('lists both built-in capabilities with the documented shape', async () => { + const { body } = await getJson('/api/v1/capabilities'); + expect(body.code).toBe(0); + const parsed = listCapabilitiesResponseSchema.parse(body.data); + const ids = parsed.capabilities.map((c) => c.id).toSorted(); + expect(ids).toEqual(['kimi-cu', 'kimi-webbridge']); + for (const capability of parsed.capabilities) { + expect(capabilityStatusSchema.parse(capability)).toBeTruthy(); + expect(capability.install.running).toBe(false); + } + // Platform-gated entry: kimi-cu is macOS-only. + const kimiCu = parsed.capabilities.find((c) => c.id === 'kimi-cu'); + if (process.platform === 'darwin') { + expect(kimiCu?.supported).toBe(true); + } else { + expect(kimiCu?.supported).toBe(false); + expect(kimiCu?.state).toBe('unsupported'); + } + // The isolated home dir has no plugin records → the skill step is missing. + const webbridge = parsed.capabilities.find((c) => c.id === 'kimi-webbridge'); + expect(webbridge?.supported).toBe(true); + expect(webbridge?.steps.find((s) => s.id === 'skill')?.state).toBe('missing'); + // The browser extension is a soft gate (never blocks readiness). + expect(webbridge?.steps.find((s) => s.id === 'extension')?.optional).toBe(true); + }); + + it('gets a single capability and 40418s on an unknown id', async () => { + const { body } = await getJson('/api/v1/capabilities/kimi-webbridge'); + expect(body.code).toBe(0); + expect(capabilityStatusSchema.parse(body.data).id).toBe('kimi-webbridge'); + + const missing = await getJson('/api/v1/capabilities/nope'); + expect(missing.body.code).toBe(40418); + expect(missing.body.data).toBeNull(); + }); + + it('installs 40418 on an unknown id without side effects', async () => { + const { body } = await postJson('/api/v1/capabilities/nope:install'); + expect(body.code).toBe(40418); + }); + + it('rejects bare ids and unknown actions with 40001', async () => { + const bare = await postJson('/api/v1/capabilities/kimi-cu'); + expect(bare.body.code).toBe(40001); + const bogus = await postJson('/api/v1/capabilities/kimi-cu:uninstall'); + expect(bogus.body.code).toBe(40001); + }); + + it.skipIf(process.platform === 'darwin')( + 'rejects kimi-cu install on non-macOS with 40923', + async () => { + const { body } = await postJson('/api/v1/capabilities/kimi-cu:install'); + expect(body.code).toBe(40923); + }, + ); +}); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts new file mode 100644 index 0000000000..a4a810d7af --- /dev/null +++ b/packages/kap-server/test/plugins.test.ts @@ -0,0 +1,207 @@ +/** + * `/api/v1` plugins routes — wire contract: + * - GET /plugins → installed list (empty → 1 after install) + * - POST /plugins {source} → installs (local path), returns summary + * - POST /plugins/{id}:disable / :enable → toggles enabled + * - POST /plugins/{id}:remove → removes + * - POST bare id / bogus action → 40001 + * - POST unknown id :remove → 40419 + * - GET /plugins/marketplace → catalog merged with live install state + * - GET /plugins/marketplace unreachable → 50001 + * + * The marketplace catalog is served by a stubbed global fetch; installs use + * local-path sources in temp dirs (no network). + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +const CATALOG_URL = 'http://marketplace.test/marketplace.json'; + +const CATALOG = { + version: '1', + plugins: [ + { + id: 'demo-plugin', + tier: 'official', + displayName: 'Demo Plugin', + version: '2.0.0', + source: 'https://cdn.example.test/demo.zip', + }, + { + id: 'third-party-plugin', + displayName: 'Third Party', + source: 'https://github.com/example/third', + }, + ], +}; + +describe('server-v2 /api/v1 plugins', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + const createdDirs: string[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-plugins-')); + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + return new Response(JSON.stringify(CATALOG), { status: 200 }); + } + return realFetch(url as never, init); + }), + ); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + pluginMarketplaceUrl: CATALOG_URL, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + if (server !== undefined) { + await server.close(); + server = undefined; + } + for (const dir of createdDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function call( + method: 'GET' | 'POST', + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method, + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + // A JSON content-type with an empty body is rejected by Fastify. + ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function makePluginDir(id: string, version: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `kimi-test-plugin-${id}-`)); + createdDirs.push(dir); + await writeFile( + join(dir, 'kimi.plugin.json'), + JSON.stringify({ name: id, version, description: 'test plugin' }), + ); + return dir; + } + + it('installs, lists, disables, enables, and removes a plugin', async () => { + const empty = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins'); + expect(empty.body.data.plugins).toEqual([]); + + const source = await makePluginDir('demo-plugin', '1.0.0'); + const installed = await call<{ id: string; version: string; enabled: boolean }>( + 'POST', + '/api/v1/plugins', + { source }, + ); + expect(installed.body.code).toBe(0); + expect(installed.body.data).toMatchObject({ id: 'demo-plugin', version: '1.0.0', enabled: true }); + + const list = await call<{ plugins: { id: string; enabled: boolean }[] }>( + 'GET', + '/api/v1/plugins', + ); + expect(list.body.data.plugins.map((p) => [p.id, p.enabled])).toEqual([['demo-plugin', true]]); + + const disabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:disable'); + expect(disabled.body.code).toBe(0); + const afterDisable = await call<{ plugins: { enabled: boolean }[] }>('GET', '/api/v1/plugins'); + expect(afterDisable.body.data.plugins[0]?.enabled).toBe(false); + + const enabled = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:enable'); + expect(enabled.body.code).toBe(0); + + const removed = await call<{ ok: true }>('POST', '/api/v1/plugins/demo-plugin:remove'); + expect(removed.body.code).toBe(0); + const afterRemove = await call<{ plugins: unknown[] }>('GET', '/api/v1/plugins'); + expect(afterRemove.body.data.plugins).toEqual([]); + }); + + it('rejects bare ids, bogus actions, and unknown plugins', async () => { + const bare = await call('POST', '/api/v1/plugins/demo-plugin'); + expect(bare.body.code).toBe(40001); + const bogus = await call('POST', '/api/v1/plugins/demo-plugin:explode'); + expect(bogus.body.code).toBe(40001); + const unknown = await call('POST', '/api/v1/plugins/nope:remove'); + expect(unknown.body.code).toBe(40419); + const badSource = await call('POST', '/api/v1/plugins', { source: '' }); + expect(badSource.body.code).toBe(40001); + }); + + it('serves the marketplace catalog merged with live install state', async () => { + const before = await call<{ + entries: { id: string; tier: string; installed?: { version?: string } }[]; + }>('GET', '/api/v1/plugins/marketplace'); + expect(before.body.code).toBe(0); + expect(before.body.data.entries.map((e) => [e.id, e.tier])).toEqual([ + ['demo-plugin', 'official'], + ['third-party-plugin', 'third-party'], + ]); + expect(before.body.data.entries[0]?.installed).toBeUndefined(); + + // Install an older version than the catalog → updateAvailable. + const source = await makePluginDir('demo-plugin', '1.0.0'); + await call('POST', '/api/v1/plugins', { source }); + + const after = await call<{ + entries: { + id: string; + installed?: { version?: string; enabled: boolean }; + updateAvailable?: boolean; + }[]; + }>('GET', '/api/v1/plugins/marketplace'); + const demo = after.body.data.entries.find((e) => e.id === 'demo-plugin'); + expect(demo?.installed).toEqual({ version: '1.0.0', enabled: true }); + expect(demo?.updateAvailable).toBe(true); + }); + + it('maps an unreachable marketplace to 50001', async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL, init?: RequestInit) => { + if (url === CATALOG_URL) { + throw new Error('network down'); + } + return realFetch(url as never, init); + }), + ); + const { body } = await call('GET', '/api/v1/plugins/marketplace'); + expect(body.code).toBe(50001); + expect(body.msg).toContain('unreachable'); + }); +}); diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index bef77c30d4..97dc68fa96 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -43,6 +43,7 @@ import type { PluginUpdateStatus, ReloadSummary, } from '@moonshot-ai/agent-core-v2/app/plugin/types'; +import type { CapabilityStatus } from '@moonshot-ai/agent-core-v2/app/capability/types'; /** Low-level caller the klient factory builds: routes + validates one service call. */ export type Caller = (service: string, method: string, args: unknown[]) => Promise; @@ -177,6 +178,12 @@ export interface GlobalFlagsFacade { snapshot(): Promise>; } +export interface GlobalCapabilitiesFacade { + list(): Promise; + get(id: string): Promise; + install(id: string): Promise; +} + export interface GlobalPluginsFacade { list(): Promise; info(id: string): Promise; @@ -218,6 +225,7 @@ export interface GlobalFacade { readonly auth: GlobalAuthFacade; readonly flags: GlobalFlagsFacade; readonly plugins: GlobalPluginsFacade; + readonly capabilities: GlobalCapabilitiesFacade; readonly hostFs: GlobalHostFsFacade; env(): Promise; } @@ -409,6 +417,13 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr call('pluginService', 'listPluginCommands', []) as Promise, }, + capabilities: { + list: () => call('capabilityService', 'listCapabilities', []) as Promise, + get: (id) => call('capabilityService', 'getCapability', [{ id }]) as Promise, + install: (id) => + call('capabilityService', 'installCapability', [{ id }]) as Promise, + }, + hostFs: { browse: (absPath) => call('hostFolderBrowser', 'browse', [absPath]) as Promise, diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 31f5c64d10..2d78835ea3 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -248,6 +248,7 @@ import { import type { AddAdditionalDirInput, AddAdditionalDirResult, + CapabilityStatus, BackgroundTaskInfo, CompactOptions, ConfigDiagnostics, @@ -662,6 +663,24 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return this.klient.global.plugins.info(id); } + /** + * Capability surface (v2-only): built-in product capabilities (kimi-cu, + * kimi-webbridge) with layered readiness and idempotent installs. v1 has + * no capability domain, so these stay off the shared base — callers + * feature-detect via `in` before use. + */ + async listCapabilities(): Promise { + return this.klient.global.capabilities.list(); + } + + async getCapability(id: string): Promise { + return this.klient.global.capabilities.get(id); + } + + async installCapability(id: string): Promise { + return this.klient.global.capabilities.install(id); + } + /** * Scope gap: v1 answers from the session's creation-time snapshot of the * enabled plugin commands, while the v2 engine only exposes the app-global diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 9222112832..25fb73aee6 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -11,6 +11,7 @@ import type { SDKRpcClientBase } from '#/rpc'; import type { AddAdditionalDirOptions, AddAdditionalDirResult, + CapabilityStatus, BackgroundTaskInfo, CompactOptions, CreateGoalInput, @@ -49,6 +50,30 @@ export interface SessionOptions { readonly onClose?: (() => void | Promise) | undefined; } +/** + * The capability surface (built-in product capabilities: kimi-cu, + * kimi-webbridge) exists only on the v2 engine — v1 has no capability + * domain. Feature-detect structurally so a Session backed by v1 fails with + * a clear message instead of a confusing missing-method error. + */ +interface CapabilityRpcSurface { + listCapabilities(): Promise; + getCapability(id: string): Promise; + installCapability(id: string): Promise; +} + +function capabilityRpc(rpc: SDKRpcClientBase): CapabilityRpcSurface { + const candidate = rpc as Partial; + if ( + typeof candidate.listCapabilities !== 'function' || + typeof candidate.getCapability !== 'function' || + typeof candidate.installCapability !== 'function' + ) { + throw new TypeError('The capability surface is unavailable on this engine (requires v2).'); + } + return candidate as CapabilityRpcSurface; +} + export class Session { readonly id: string; readonly workDir: string; @@ -524,6 +549,27 @@ export class Session { await this.rpc.setPluginEnabled(id, enabled); } + /** Built-in capabilities with layered readiness (v2 engine only). */ + async listCapabilities(): Promise { + this.ensureOpen(); + return capabilityRpc(this.rpc).listCapabilities(); + } + + /** One capability's layered readiness + live install progress. */ + async getCapability(id: string): Promise { + this.ensureOpen(); + return capabilityRpc(this.rpc).getCapability(id); + } + + /** + * Start an idempotent capability install (binary runtime + wiring) in the + * background; poll `getCapability` for progress. + */ + async installCapability(id: string): Promise { + this.ensureOpen(); + return capabilityRpc(this.rpc).installCapability(id); + } + async setPluginMcpServerEnabled( id: string, server: string, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d65e5d30c6..b0d3f4c47c 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -16,6 +16,8 @@ export type JsonObject = { readonly [key: string]: JsonValue }; export type Unsubscribe = () => void; +export type { CapabilityStatus } from '@moonshot-ai/agent-core-v2/app/capability/types'; + export type { AgentReplayRecord, AgentBackgroundTaskInfo, diff --git a/plugins/marketplace.json b/plugins/marketplace.json index ce41a8c831..86e2e1aad2 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -10,6 +10,23 @@ "keywords": ["data", "mcp"], "source": "./official/kimi-datasource" }, + { + "id": "kimi-cu", + "tier": "official", + "displayName": "Kimi Computer Use", + "description": "macOS GUI automation in the background — click, type, scroll, and drag without taking over your mouse. Requires the KimiCU.app runtime (macOS only); current clients set it up automatically on install.", + "keywords": ["computer-use", "macos", "accessibility", "automation", "gui"], + "source": "https://cdn.kimi.com/kimi-computer-use/latest/kimi-cu-plugin.zip" + }, + { + "id": "kimi-webbridge", + "tier": "official", + "displayName": "Kimi WebBridge", + "description": "Control your real browser from Kimi Code — navigate, click, type, and screenshot. Requires the WebBridge daemon + browser extension.", + "homepage": "https://www.kimi.com/features/webbridge", + "keywords": ["browser", "webbridge", "cdp", "automation", "web"], + "source": "./official/kimi-webbridge" + }, { "id": "superpowers", "tier": "curated", diff --git a/plugins/official/kimi-webbridge/kimi.plugin.json b/plugins/official/kimi-webbridge/kimi.plugin.json new file mode 100644 index 0000000000..98660ddf2f --- /dev/null +++ b/plugins/official/kimi-webbridge/kimi.plugin.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://kimi.com/schemas/kimi.plugin.schema.json", + "name": "kimi-webbridge", + "version": "1.11.3", + "description": "Control your real browser (with your login sessions) from Kimi Code via the local Kimi WebBridge daemon — navigate, click, type, read pages, and screenshot any website.", + "keywords": [ + "browser", + "webbridge", + "cdp", + "automation", + "web", + "scraping" + ], + "author": "Moonshot AI", + "license": "Proprietary", + "skills": "./skills/", + "interface": { + "displayName": "Kimi WebBridge", + "shortDescription": "Control your real browser from Kimi Code — navigate, click, type, and screenshot", + "longDescription": "Kimi WebBridge lets AI control the user's real browser — navigate, click, type, read, screenshot, and interact with any website using the user's actual login sessions. The skill talks to the local WebBridge daemon (http://127.0.0.1:10086), which drives Chrome/Edge through a browser extension over CDP. Everything runs locally; login state and page content never leave the device.\n\nRequires the Kimi WebBridge daemon and browser extension: https://www.kimi.com/features/webbridge", + "developerName": "Moonshot AI", + "websiteURL": "https://www.kimi.com/features/webbridge" + } +} diff --git a/plugins/official/kimi-webbridge/skills/kimi-webbridge/SKILL.md b/plugins/official/kimi-webbridge/skills/kimi-webbridge/SKILL.md new file mode 100644 index 0000000000..88271105e5 --- /dev/null +++ b/plugins/official/kimi-webbridge/skills/kimi-webbridge/SKILL.md @@ -0,0 +1,177 @@ +--- +name: kimi-webbridge +description: | + Kimi WebBridge lets AI control the user's real browser — navigate, click, type, read, screenshot, and interact with any website using the user's actual login sessions. Use this skill whenever the user wants to interact with websites, automate browser tasks, scrape web content, or perform any action requiring a real browser. Also use when the user mentions "browser", "webpage", "open URL", "screenshot", or asks to read/interact with any website. Use even for simple-sounding browser requests — the daemon handles all complexity. +metadata: + version: "1.11.3" +--- + +# Kimi WebBridge + +Control the user's real browser (with their login sessions) via a local daemon at `http://127.0.0.1:10086`. + +## Tools + +| Tool | Args | Returns | Note | +|------|------|---------|------| +| `navigate` | `url`, `newTab`(bool), `group_title` | `{success, url, tabId}` | First call opens a tab — see [Tabs](#tabs-and-the-current-tab). `group_title` sets the group's visible label | +| `find_tab` | `url`, `active`(bool) | `{success, url, tabId, borrowed}` | Re-select a tab **this session** opened; `active:true` borrows the tab the **user** is viewing — see [Tabs](#tabs-and-the-current-tab) | +| `snapshot` | — | `{url, title, tree}` with `@e` refs | **Accessibility tree** (text) — use this to read page content and locate elements | +| `click` | `selector` (@e ref or CSS) | `{success, tag, text}` | Synthetic `el.click()` | +| `fill` | `selector`, `value` | `{success, tag, mode}` | Works on ``/`