diff --git a/packages/agent-core-v2/src/contextInjector/pluginSessionStart.ts b/packages/agent-core-v2/src/contextInjector/pluginSessionStart.ts new file mode 100644 index 000000000..ae5d35ea1 --- /dev/null +++ b/packages/agent-core-v2/src/contextInjector/pluginSessionStart.ts @@ -0,0 +1,158 @@ +/** + * `contextInjector` domain (L4) — plugin session-start reminder injection. + * + * Production equivalent of v1's `agent/injection/plugin-session-start.ts`. + * Registers a turn-cadence `plugin_session_start` injection with + * `IAgentContextInjectorService` so enabled plugins' `sessionStart` skills are + * rendered into the main agent's context once per turn (deduped against + * replayed history by the context-injector). On `IPluginService.onDidReload`, + * force-appends a fresh reminder — or a neutralizing reminder when no session + * start is resolvable but stale guidance may linger — mirroring the `/reload` + * re-injection flow. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { escapeXmlAttr } from '#/_base/utils/xml-escape'; +import { IAgentContextMemoryService } from '#/contextMemory'; +import { ILogService } from '#/log'; +import { IPluginService } from '#/plugin'; +import type { EnabledPluginSessionStart } from '#/plugin/types'; +import { ISessionSkillCatalog } from '#/skill'; +import type { SkillCatalog, SkillDefinition } from '#/skill/types'; +import { IAgentSystemReminderService } from '#/systemReminder'; + +import { IAgentContextInjectorService } from './contextInjector'; + +const INJECTION_VARIANT = 'plugin_session_start'; + +export interface IPluginSessionStartInjectorService { + readonly _serviceBrand: undefined; +} + +export const IPluginSessionStartInjectorService: ServiceIdentifier = + createDecorator('pluginSessionStartInjectorService'); + +export class PluginSessionStartInjectorService + extends Disposable + implements IPluginSessionStartInjectorService +{ + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentContextInjectorService private readonly injector: IAgentContextInjectorService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IPluginService private readonly plugins: IPluginService, + @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @ILogService private readonly log: ILogService, + ) { + super(); + this._register( + this.injector.register(INJECTION_VARIANT, () => this.renderReminder(), { cadence: 'turn' }), + ); + this._register( + this.plugins.onDidReload(() => { + void this.appendReminderOnReload(); + }), + ); + } + + private async renderReminder(): Promise { + const sessionStarts = await this.plugins.enabledSessionStarts(); + if (sessionStarts.length === 0) return undefined; + await this.skillCatalog.ready; + return renderPluginSessionStartReminder({ + sessionStarts, + catalog: this.skillCatalog.catalog, + log: this.log, + }); + } + + private async appendReminderOnReload(): Promise { + const sessionStarts = await this.plugins.enabledSessionStarts(); + await this.skillCatalog.ready; + const reminder = renderPluginSessionStartReminder({ + sessionStarts, + catalog: this.skillCatalog.catalog, + log: this.log, + }); + if (reminder !== undefined) { + this.reminders.appendSystemReminder( + `${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`, + { kind: 'injection', variant: INJECTION_VARIANT }, + ); + } else if (shouldNeutralizePluginSessionStart(this.context.get())) { + this.reminders.appendSystemReminder( + 'There are currently no active plugin session starts. ' + + 'This supersedes any earlier plugin_session_start reminder in this session.', + { kind: 'injection', variant: INJECTION_VARIANT }, + ); + } + } +} + +export interface RenderPluginSessionStartReminderInput { + readonly sessionStarts: readonly EnabledPluginSessionStart[]; + readonly catalog: SkillCatalog | undefined; + readonly log?: { warn(message: string, payload?: unknown): void }; +} + +export function renderPluginSessionStartReminder( + input: RenderPluginSessionStartReminderInput, +): string | undefined { + const { sessionStarts, catalog, log } = input; + if (sessionStarts.length === 0) return undefined; + if (catalog === undefined) return undefined; + const blocks: string[] = []; + for (const sessionStart of sessionStarts) { + const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); + if (skill === undefined) { + log?.warn('plugin sessionStart skill not found', { + pluginId: sessionStart.pluginId, + skillName: sessionStart.skillName, + }); + continue; + } + blocks.push(renderSessionStartBlock(sessionStart, skill, catalog.renderSkillPrompt(skill, ''))); + } + return blocks.length > 0 ? blocks.join('\n') : undefined; +} + +/** + * True when the context may still carry stale plugin guidance — an earlier + * `` reminder or a compaction summary that may have + * folded one in — so a neutralizing reminder should replace it. + */ +export function shouldNeutralizePluginSessionStart( + history: readonly { readonly origin?: { readonly kind: string; readonly variant?: string } }[], +): boolean { + return history.some((message) => { + const kind = message.origin?.kind; + if (kind === 'injection') { + return message.origin?.variant === INJECTION_VARIANT; + } + return kind === 'compaction_summary'; + }); +} + +function renderSessionStartBlock( + sessionStart: EnabledPluginSessionStart, + skill: SkillDefinition, + skillContent: string, +): string { + return ( + `\n${skillContent}\n` + ); +} + +registerScopedService( + LifecycleScope.Agent, + IPluginSessionStartInjectorService, + PluginSessionStartInjectorService, + InstantiationType.Delayed, + 'contextInjector', +); diff --git a/packages/agent-core-v2/src/plugin/archive.ts b/packages/agent-core-v2/src/plugin/archive.ts new file mode 100644 index 000000000..eae97f1a5 --- /dev/null +++ b/packages/agent-core-v2/src/plugin/archive.ts @@ -0,0 +1,148 @@ +import { createWriteStream } from 'node:fs'; +import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +import { type Entry, fromBuffer as yauzlFromBuffer } from 'yauzl'; + +export async function downloadZip(url: string, signal?: AbortSignal): Promise { + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => { + controller.abort(); + }, 5 * 60 * 1000); + try { + const resp = await fetch(url, { signal: signal ?? controller.signal }); + if (!resp.ok) { + throw new Error(`Failed to download zip: HTTP ${resp.status} ${resp.statusText}`); + } + return Buffer.from(await resp.arrayBuffer()); + } finally { + clearTimeout(timeoutHandle); + } +} + +export async function extractZip(buffer: Buffer, destDir: string): Promise { + await mkdir(destDir, { recursive: true }); + const destDirResolved = path.resolve(destDir); + let settled = false; + + await new Promise((resolve, reject) => { + yauzlFromBuffer(buffer, { lazyEntries: true }, (openErr, zipfile) => { + if (openErr !== null || zipfile === undefined) { + reject(new Error(`Failed to open zip: ${openErr?.message ?? 'unknown error'}`)); + return; + } + + const onEntry = (entry: Entry): void => { + const fileName = entry.fileName; + const destPath = path.resolve(destDir, fileName); + + if (destPath !== destDirResolved && !destPath.startsWith(destDirResolved + path.sep)) { + if (!settled) { + settled = true; + reject(new Error(`Path traversal detected in zip entry: ${fileName}`)); + } + zipfile.close(); + return; + } + + if (fileName.endsWith('/')) { + mkdir(destPath, { recursive: true }) + .then(() => { + zipfile.readEntry(); + }) + .catch((error) => { + if (!settled) { + settled = true; + reject(error); + } + zipfile.close(); + }); + return; + } + + zipfile.openReadStream(entry, (streamErr, stream) => { + if (streamErr !== null || stream === undefined) { + if (!settled) { + settled = true; + reject( + new Error( + `Failed to read ${fileName} from archive: ${streamErr?.message ?? 'unknown error'}`, + ), + ); + } + zipfile.close(); + return; + } + + mkdir(path.dirname(destPath), { recursive: true }) + .then(() => pipeline(stream, createWriteStream(destPath))) + .then(() => restoreFilePermissions(destPath, entry)) + .then(() => { + zipfile.readEntry(); + }) + .catch((error) => { + if (!settled) { + settled = true; + reject(error); + } + zipfile.close(); + }); + }); + }; + + zipfile.on('entry', onEntry); + zipfile.on('end', () => { + if (!settled) { + settled = true; + resolve(); + } + }); + zipfile.on('error', (err: Error) => { + if (!settled) { + settled = true; + reject(err); + } + }); + zipfile.readEntry(); + }); + }); + + return detectPluginRoot(destDir); +} + +async function restoreFilePermissions(destPath: string, entry: Entry): Promise { + const mode = entry.externalFileAttributes >>> 16; + if (mode === 0) return; + const permissions = mode & 0o777; + if (permissions === 0) return; + await chmod(destPath, permissions); +} + +async function detectPluginRoot(dir: string): Promise { + if (await hasManifest(dir)) return dir; + + const entries = await readdir(dir, { withFileTypes: true }); + const childDirs = entries.filter((entry) => entry.isDirectory()); + const childDir = childDirs.length === 1 ? childDirs[0] : undefined; + if (childDir !== undefined) { + const child = path.join(dir, childDir.name); + if (await hasManifest(child)) return child; + } + + return dir; +} + +async function hasManifest(dir: string): Promise { + const rootManifest = path.join(dir, 'kimi.plugin.json'); + const dirManifest = path.join(dir, '.kimi-plugin', 'plugin.json'); + return (await isFile(rootManifest)) || (await isFile(dirManifest)); +} + +async function isFile(p: string): Promise { + try { + return (await stat(p)).isFile(); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/plugin/commands.ts b/packages/agent-core-v2/src/plugin/commands.ts new file mode 100644 index 000000000..5850babba --- /dev/null +++ b/packages/agent-core-v2/src/plugin/commands.ts @@ -0,0 +1,79 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { parseFrontmatter } from '#/skill/parser'; + +import type { PluginCommandDef } from './types'; + +export function parseCommandText(input: { + readonly text: string; + readonly commandPath: string; + readonly pluginId: string; + readonly fallbackName?: string; +}): PluginCommandDef { + const { text, commandPath, pluginId } = input; + const parsed = parseFrontmatter(text); + const frontmatter = isRecord(parsed.data) ? parsed.data : {}; + + const baseName = input.fallbackName ?? path.basename(commandPath).replace(/\.md$/i, ''); + const name = nonEmptyString(frontmatter['name']) ?? baseName; + + const body = parsed.body.trim(); + const description = nonEmptyString(frontmatter['description']) ?? descriptionFromBody(body); + + return { + pluginId, + name, + description, + body, + path: path.resolve(commandPath), + }; +} + +export async function loadPluginCommand(input: { + readonly commandPath: string; + readonly pluginId: string; + readonly fallbackName?: string; +}): Promise { + try { + const text = await readFile(input.commandPath, 'utf8'); + return parseCommandText({ + text, + commandPath: input.commandPath, + pluginId: input.pluginId, + fallbackName: input.fallbackName, + }); + } catch { + return undefined; + } +} + +/** + * Expand `$ARGUMENTS` placeholders in a plugin command body with the typed args. + * If the body has no placeholder but args are present, append them so nothing + * is silently dropped. + */ +export function expandCommandArguments(body: string, args: string): string { + const replaced = body.replaceAll('$ARGUMENTS', args); + if (!body.includes('$ARGUMENTS') && args.length > 0) { + return `${replaced}\n\nARGUMENTS: ${args}`; + } + return replaced; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function descriptionFromBody(body: string): string { + const firstLine = body + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (firstLine === undefined) return 'No description provided.'; + return firstLine.length > 240 ? `${firstLine.slice(0, 239)}…` : firstLine; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/agent-core-v2/src/plugin/github-resolver.ts b/packages/agent-core-v2/src/plugin/github-resolver.ts new file mode 100644 index 000000000..264ffa741 --- /dev/null +++ b/packages/agent-core-v2/src/plugin/github-resolver.ts @@ -0,0 +1,100 @@ +import type { GithubRef } from './source'; +import type { PluginGithubRef } from './types'; + +export interface GithubSourceInput { + readonly kind: 'github'; + readonly owner: string; + readonly repo: string; + readonly ref?: GithubRef; +} + +export interface GithubSourceResolution { + readonly tarballUrl: string; + readonly displayVersion: string; + readonly ref: PluginGithubRef; +} + +/** + * Resolve a `github` source descriptor to a downloadable zip URL. + * + * Hot path is the bare-URL case (no explicit ref). We deliberately avoid + * `api.github.com` because its anonymous quota is shared with the user's + * browser, gh CLI, IDE integrations, etc. + */ +export async function resolveGithubSource( + input: GithubSourceInput, +): Promise { + const { owner, repo } = input; + + if (input.ref !== undefined) { + return { + tarballUrl: codeloadUrl(owner, repo, input.ref), + displayVersion: input.ref.value, + ref: { kind: input.ref.kind, value: input.ref.value }, + }; + } + + const latestTag = await tryResolveLatestReleaseTag(owner, repo); + if (latestTag !== undefined) { + return { + tarballUrl: codeloadUrl(owner, repo, { kind: 'tag', value: latestTag }), + displayVersion: latestTag, + ref: { kind: 'tag', value: latestTag }, + }; + } + + const headProbe = await fetch(`https://codeload.github.com/${owner}/${repo}/zip/HEAD`, { + method: 'HEAD', + }); + if (headProbe.status === 404) { + throw new Error(`Repository \`${owner}/${repo}\` not found or not accessible.`); + } + if (!headProbe.ok) { + throw new Error( + `Could not access \`${owner}/${repo}\`: HTTP ${headProbe.status} ${headProbe.statusText}.`, + ); + } + return { + tarballUrl: `https://codeload.github.com/${owner}/${repo}/zip/HEAD`, + displayVersion: 'HEAD', + ref: { kind: 'branch', value: 'HEAD' }, + }; +} + +async function tryResolveLatestReleaseTag(owner: string, repo: string): Promise { + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetch(url, { redirect: 'manual' }); + + if (resp.status === 404) return undefined; + + if (resp.status !== 301 && resp.status !== 302) { + throw new Error( + `Could not look up latest release of \`${owner}/${repo}\`: ` + + `HTTP ${resp.status} ${resp.statusText} (${url}). ` + + `Pin a specific ref with \`/tree/\` to bypass release lookup.`, + ); + } + + const location = resp.headers.get('location'); + if (location === null) return undefined; + + const match = /\/releases\/tag\/([^/?#]+)/.exec(location); + if (match === null) return undefined; + try { + return decodeURIComponent(match[1]!); + } catch { + return match[1]; + } +} + +function codeloadUrl(owner: string, repo: string, ref: GithubRef): string { + const base = `https://codeload.github.com/${owner}/${repo}/zip`; + const encoded = encodeCodeloadRefPath(ref.value); + if (ref.kind === 'sha') return `${base}/${encoded}`; + if (ref.kind === 'tag') return `${base}/refs/tags/${encoded}`; + return `${base}/${encoded}`; +} + +function encodeCodeloadRefPath(value: string): string { + return value.split('/').map(encodeURIComponent).join('/'); +} diff --git a/packages/agent-core-v2/src/plugin/index.ts b/packages/agent-core-v2/src/plugin/index.ts index fcb073fef..425b60194 100644 --- a/packages/agent-core-v2/src/plugin/index.ts +++ b/packages/agent-core-v2/src/plugin/index.ts @@ -1 +1,10 @@ export * from './types'; +export * from './commands'; +export * from './manifest'; +export * from './store'; +export * from './source'; +export * from './github-resolver'; +export * from './archive'; +export * from './manager'; +export * from './plugin'; +export * from './pluginService'; diff --git a/packages/agent-core-v2/src/plugin/manager.ts b/packages/agent-core-v2/src/plugin/manager.ts new file mode 100644 index 000000000..e314e2aeb --- /dev/null +++ b/packages/agent-core-v2/src/plugin/manager.ts @@ -0,0 +1,557 @@ +import { cp, mkdir, mkdtemp, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import type { HookDef } from '#/externalHooks/types'; +import type { McpServerConfig } from '#/mcp/config-schema'; +import type { SkillRoot } from '#/skill/types'; + +import { downloadZip, extractZip } from './archive'; +import { loadPluginCommand } from './commands'; +import { resolveGithubSource } from './github-resolver'; +import { resolveInstallSource } from './source'; +import { parseManifest, type ParsedManifestResult } from './manifest'; +import { readInstalled, writeInstalled, type InstalledRecord } from './store'; +import { + normalizePluginId, + type EnabledPluginSessionStart, + type PluginCapabilityState, + type PluginCommandDef, + type PluginGithubMetadata, + type PluginInfo, + type PluginMcpServerInfo, + type PluginRecord, + type PluginSource, + type PluginSummary, + type PluginUpdateStatus, + type ReloadSummary, +} from './types'; + +export interface PluginManagerOptions { + readonly kimiHomeDir: string; +} + +export class PluginManager { + private readonly kimiHomeDir: string; + private records = new Map(); + + constructor(options: PluginManagerOptions) { + this.kimiHomeDir = options.kimiHomeDir; + } + + async load(): Promise { + const file = await readInstalled(this.kimiHomeDir); + const next = new Map(); + for (const entry of file.plugins) { + next.set(entry.id, await this.materialize(entry)); + } + this.records = next; + } + + list(): readonly PluginRecord[] { + return [...this.records.values()].toSorted((a, b) => a.id.localeCompare(b.id)); + } + + get(id: string): PluginRecord | undefined { + return this.records.get(normalizePluginId(id)); + } + + async install(source: string): Promise { + const resolved = resolveInstallSource(source); + + let sourceRoot: string; + let originalSource: string; + let sourceType: PluginSource; + let parsed: ParsedManifestResult; + let zipTmpDir: string | undefined; + let github: PluginGithubMetadata | undefined; + + if (resolved.kind === 'local-path') { + sourceRoot = await normalizeInstallRoot(resolved.path); + originalSource = resolved.path; + sourceType = 'local-path'; + parsed = await parseManifest(sourceRoot); + } else { + originalSource = source.trim(); + sourceType = resolved.kind === 'github' ? 'github' : 'zip-url'; + const zipUrl = + resolved.kind === 'github' + ? await (async () => { + const resolution = await resolveGithubSource(resolved); + github = { + owner: resolved.owner, + repo: resolved.repo, + ref: resolution.ref, + }; + return resolution.tarballUrl; + })() + : resolved.path; + const buffer = await downloadZip(zipUrl); + zipTmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-')); + sourceRoot = await extractZip(buffer, zipTmpDir); + parsed = await parseManifest(sourceRoot); + } + + try { + if (parsed.manifest === undefined) { + const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest'; + throw new Error(`Cannot install plugin at ${sourceRoot}: ${msg}`); + } + + const id = normalizePluginId(parsed.manifest.name); + const normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot); + const managedParsed = await parseManifest(normalizedRoot); + const existing = this.records.get(id); + const now = new Date().toISOString(); + const record = await recordFrom({ + id, + root: normalizedRoot, + enabled: existing?.enabled ?? true, + installedAt: existing?.installedAt ?? now, + updatedAt: now, + originalSource, + source: sourceType, + capabilities: existing?.capabilities, + github, + parsed: managedParsed, + }); + this.records.set(id, record); + await this.persist(); + return record; + } finally { + if (zipTmpDir !== undefined) { + await rm(zipTmpDir, { recursive: true, force: true }); + } + } + } + + async setEnabled(id: string, enabled: boolean): Promise { + const key = normalizePluginId(id); + const current = this.records.get(key); + if (current === undefined) throw new Error(`Plugin "${id}" is not installed`); + if (current.enabled === enabled) return; + this.records.set(key, { ...current, enabled, updatedAt: new Date().toISOString() }); + await this.persist(); + } + + async setMcpServerEnabled(id: string, server: string, enabled: boolean): Promise { + const key = normalizePluginId(id); + const current = this.records.get(key); + if (current === undefined) throw new Error(`Plugin "${id}" is not installed`); + if (current.manifest?.mcpServers?.[server] === undefined) { + throw new Error(`Plugin "${id}" does not declare MCP server "${server}"`); + } + const currentMcpServers = current.capabilities?.mcpServers ?? {}; + const nextCapabilities: PluginCapabilityState = { + ...current.capabilities, + mcpServers: { + ...currentMcpServers, + [server]: { enabled }, + }, + }; + this.records.set(key, { + ...current, + capabilities: nextCapabilities, + updatedAt: new Date().toISOString(), + }); + await this.persist(); + } + + async remove(id: string): Promise { + const key = normalizePluginId(id); + if (!this.records.delete(key)) { + throw new Error(`Plugin "${id}" is not installed`); + } + await this.persist(); + } + + async checkUpdates(): Promise { + const out: PluginUpdateStatus[] = []; + for (const record of this.records.values()) { + if (record.source !== 'github' || record.github === undefined) continue; + const latest = await resolveGithubSource({ + kind: 'github', + owner: record.github.owner, + repo: record.github.repo, + }); + const current = record.github.ref; + const updateAvailable = + current === undefined || + current.kind !== latest.ref.kind || + current.value !== latest.ref.value; + out.push({ + id: record.id, + source: record.source, + current, + latest: latest.ref, + displayVersion: latest.displayVersion, + updateAvailable, + }); + } + return out.toSorted((a, b) => a.id.localeCompare(b.id)); + } + + async reload(): Promise { + const prevIds = new Set(this.records.keys()); + const file = await readInstalled(this.kimiHomeDir); + const next = new Map(); + const errors: Array<{ id: string; message: string }> = []; + for (const entry of file.plugins) { + try { + next.set(entry.id, await this.materialize(entry)); + } catch (error) { + errors.push({ id: entry.id, message: (error as Error).message }); + } + } + const added: string[] = []; + for (const id of next.keys()) if (!prevIds.has(id)) added.push(id); + const removed: string[] = []; + for (const id of prevIds) if (!next.has(id)) removed.push(id); + this.records = next; + return { added, removed, errors }; + } + + enabledHooks(): readonly HookDef[] { + const out: HookDef[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const hook of record.manifest.hooks ?? []) { + out.push({ + ...hook, + cwd: record.root, + env: { + KIMI_CODE_HOME: this.kimiHomeDir, + KIMI_PLUGIN_ROOT: record.root, + }, + }); + } + } + return out; + } + + async enabledCommands(): Promise { + const out: PluginCommandDef[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const entry of record.manifest.commands ?? []) { + const def = await loadPluginCommand({ + commandPath: entry.path, + pluginId: record.id, + fallbackName: entry.name, + }); + if (def !== undefined) out.push(def); + } + } + return out; + } + + pluginSkillRoots(): readonly SkillRoot[] { + const roots: SkillRoot[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const dir of record.manifest.skills ?? []) { + roots.push({ + path: dir, + source: 'extra', + plugin: { id: record.id, instructions: record.skillInstructions }, + }); + } + } + return roots; + } + + enabledSessionStarts(): readonly EnabledPluginSessionStart[] { + const out: EnabledPluginSessionStart[] = []; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok') continue; + const skill = record.manifest?.sessionStart?.skill; + if (skill === undefined) continue; + out.push({ pluginId: record.id, skillName: skill }); + } + return out; + } + + enabledMcpServers(): Record { + const out: Record = {}; + for (const record of this.records.values()) { + if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue; + for (const [name, config] of Object.entries(record.manifest.mcpServers ?? {})) { + if (!isMcpServerEnabled(record, name, config)) continue; + out[pluginMcpRuntimeName(record.id, name)] = withPluginMcpRuntime( + withMcpServerEnabled(config, true), + record.root, + this.kimiHomeDir, + ); + } + } + return out; + } + + summaries(): readonly PluginSummary[] { + return this.list().map((record) => recordToSummary(record)); + } + + info(id: string): PluginInfo | undefined { + const record = this.get(id); + return record === undefined ? undefined : recordToInfo(record); + } + + private async persist(): Promise { + const installed: InstalledRecord[] = [...this.records.values()].map((record) => ({ + id: record.id, + root: record.root, + source: record.source, + enabled: record.enabled, + installedAt: record.installedAt, + updatedAt: record.updatedAt, + originalSource: record.originalSource, + capabilities: record.capabilities, + github: record.github, + })); + await writeInstalled(this.kimiHomeDir, { version: 1, plugins: installed }); + } + + private async materialize(entry: InstalledRecord): Promise { + const parsed = await parseManifest(entry.root); + return recordFrom({ + id: entry.id, + root: entry.root, + enabled: entry.enabled, + installedAt: entry.installedAt, + updatedAt: entry.updatedAt, + originalSource: entry.originalSource, + capabilities: entry.capabilities, + github: entry.github, + source: entry.source, + parsed, + }); + } +} + +async function normalizeInstallRoot(rootPath: string): Promise { + const trimmed = rootPath.trim(); + if (!path.isAbsolute(trimmed)) { + throw new Error(`Plugin root must be an absolute path (got "${rootPath}")`); + } + let resolved: string; + try { + resolved = await realpath(trimmed); + } catch (error) { + throw new Error(`Plugin root does not exist: ${trimmed}`, { cause: error }); + } + if (!(await stat(resolved)).isDirectory()) { + throw new Error(`Plugin root is not a directory: ${trimmed}`); + } + return resolved; +} + +async function copyPluginToManagedRoot( + kimiHomeDir: string, + id: string, + sourceRoot: string, +): Promise { + const managedRoot = path.join(kimiHomeDir, 'plugins', 'managed', id); + const managedDir = path.dirname(managedRoot); + await mkdir(managedDir, { recursive: true }); + const stagingRoot = await mkdtemp(path.join(managedDir, `${id}-`)); + try { + await cp(sourceRoot, stagingRoot, { recursive: true }); + await rm(managedRoot, { recursive: true, force: true }); + await rename(stagingRoot, managedRoot); + } catch (error) { + await rm(stagingRoot, { recursive: true, force: true }); + throw error; + } + return realpath(managedRoot); +} + +async function recordFrom(input: { + id: string; + root: string; + enabled: boolean; + installedAt: string; + updatedAt?: string; + originalSource?: string; + capabilities?: PluginCapabilityState; + github?: PluginGithubMetadata; + source?: PluginSource; + parsed: ParsedManifestResult; +}): Promise { + const { parsed } = input; + const hasError = parsed.diagnostics.some((d) => d.severity === 'error'); + return { + id: input.id, + root: input.root, + source: input.source ?? 'local-path', + enabled: input.enabled, + state: hasError || parsed.manifest === undefined ? 'error' : 'ok', + installedAt: input.installedAt, + updatedAt: input.updatedAt, + originalSource: input.originalSource, + capabilities: input.capabilities, + github: input.github, + skillCount: await countDiscoveredPluginSkills(parsed.manifest), + manifest: parsed.manifest, + manifestKind: parsed.manifestKind, + manifestPath: parsed.manifestPath, + shadowedManifestPath: parsed.shadowedManifestPath, + diagnostics: parsed.diagnostics, + skillInstructions: parsed.manifest?.skillInstructions, + }; +} + +function recordToSummary(record: PluginRecord): PluginSummary { + return { + id: record.id, + displayName: record.manifest?.interface?.displayName ?? record.id, + version: record.manifest?.version, + enabled: record.enabled, + state: record.state, + skillCount: record.skillCount, + mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length, + enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length, + hookCount: record.manifest?.hooks?.length ?? 0, + commandCount: record.manifest?.commands?.length ?? 0, + hasErrors: record.diagnostics.some((d) => d.severity === 'error'), + source: record.source, + originalSource: record.originalSource, + github: record.github, + }; +} + +function recordToInfo(record: PluginRecord): PluginInfo { + return { + ...recordToSummary(record), + root: record.root, + installedAt: record.installedAt, + updatedAt: record.updatedAt, + manifestKind: record.manifestKind, + manifestPath: record.manifestPath, + manifest: record.manifest, + mcpServers: pluginMcpServersInfo(record), + shadowedManifestPath: record.shadowedManifestPath, + diagnostics: record.diagnostics, + }; +} + +function isMcpServerEnabled(record: PluginRecord, name: string, config: McpServerConfig): boolean { + return record.capabilities?.mcpServers?.[name]?.enabled ?? config.enabled !== false; +} + +function pluginMcpServersInfo(record: PluginRecord): readonly PluginMcpServerInfo[] { + return Object.entries(record.manifest?.mcpServers ?? {}) + .map(([name, config]) => pluginMcpServerInfo(record, name, config)) + .toSorted((a, b) => a.name.localeCompare(b.name)); +} + +function pluginMcpServerInfo( + record: PluginRecord, + name: string, + config: McpServerConfig, +): PluginMcpServerInfo { + if (config.transport === 'http' || config.transport === 'sse') { + return { + name, + runtimeName: pluginMcpRuntimeName(record.id, name), + enabled: isMcpServerEnabled(record, name, config), + transport: config.transport, + url: config.url, + headerKeys: config.headers === undefined ? undefined : Object.keys(config.headers).toSorted(), + }; + } + return { + name, + runtimeName: pluginMcpRuntimeName(record.id, name), + enabled: isMcpServerEnabled(record, name, config), + transport: 'stdio', + command: config.command, + args: config.args, + cwd: config.cwd, + envKeys: config.env === undefined ? undefined : Object.keys(config.env).toSorted(), + }; +} + +function pluginMcpRuntimeName(pluginId: string, serverName: string): string { + return `plugin-${pluginId}:${serverName}`; +} + +// Hidden Kimi CLI subcommand that re-enters as a Node interpreter. Used as a +// fallback when an MCP server declares `"command": "node"` but the user is +// running a single-binary Kimi build that doesn't have `node` on PATH. +const KIMI_NODE_FALLBACK_SUBCOMMAND = '__plugin_run_node'; + +function withMcpServerEnabled(config: McpServerConfig, enabled: boolean): McpServerConfig { + return { ...config, enabled }; +} + +function withPluginMcpRuntime( + config: McpServerConfig, + pluginRoot: string, + kimiHomeDir: string, +): McpServerConfig { + if (config.transport === 'http' || config.transport === 'sse') return config; + + const env = { + ...config.env, + KIMI_CODE_HOME: kimiHomeDir, + KIMI_PLUGIN_ROOT: pluginRoot, + }; + + if (config.command === 'node' && isKimiNativeBinary()) { + return { + ...config, + command: process.execPath, + args: [KIMI_NODE_FALLBACK_SUBCOMMAND, ...(config.args ?? [])], + cwd: config.cwd ?? pluginRoot, + env, + }; + } + + return { ...config, cwd: config.cwd ?? pluginRoot, env }; +} + +function isKimiNativeBinary(): boolean { + return !path.basename(process.execPath).toLowerCase().startsWith('node'); +} + +async function countDiscoveredPluginSkills( + manifest: PluginRecord['manifest'], +): Promise { + const roots = manifest?.skills ?? []; + if (roots.length === 0) return 0; + let count = 0; + for (const root of roots) { + count += await countSkillBundles(root); + } + return count; +} + +async function countSkillBundles(root: string): Promise { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return 0; + } + let count = 0; + for (const entry of entries) { + if (entry.isDirectory()) { + if (await isFile(path.join(root, entry.name, 'SKILL.md'))) count++; + } else if ( + entry.isFile() && + entry.name.endsWith('.md') && + entry.name !== 'SKILL.md' + ) { + count++; + } + } + return count; +} + +async function isFile(p: string): Promise { + try { + return (await stat(p)).isFile(); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/plugin/manifest.ts b/packages/agent-core-v2/src/plugin/manifest.ts new file mode 100644 index 000000000..31fa0c561 --- /dev/null +++ b/packages/agent-core-v2/src/plugin/manifest.ts @@ -0,0 +1,487 @@ +import { readdir, readFile, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import { HookDefSchema, type HookDefConfig } from '#/externalHooks/configSection'; +import { McpServerConfigSchema, type McpServerConfig } from '#/mcp/config-schema'; + +import { + PLUGIN_NAME_REGEX, + type PluginCommandEntry, + type PluginDiagnostic, + type PluginInterface, + type PluginManifest, + type PluginManifestKind, +} from './types'; + +const KIMI_PLUGIN_ROOT_PATH = 'kimi.plugin.json'; +const KIMI_PLUGIN_DIR_PATH = '.kimi-plugin/plugin.json'; + +// Fields that look like third-party runtime extensions (Claude / Codex / old +// Kimi CLI). We do not run them; emit an info diagnostic so plugin authors and +// users can see why a field is silently ignored. +const UNSUPPORTED_RUNTIME_FIELDS = [ + 'tools', + 'apps', + 'inject', + 'configFile', + 'config_file', + 'bootstrap', +] as const; + +export interface ParsedManifestResult { + readonly manifest?: PluginManifest; + readonly manifestKind?: PluginManifestKind; + readonly manifestPath?: string; + readonly shadowedManifestPath?: string; + readonly diagnostics: readonly PluginDiagnostic[]; +} + +export async function parseManifest(pluginRoot: string): Promise { + const rootJsonPath = path.join(pluginRoot, KIMI_PLUGIN_ROOT_PATH); + const dirJsonPath = path.join(pluginRoot, KIMI_PLUGIN_DIR_PATH); + const rootJsonExists = await isFile(rootJsonPath); + const dirJsonExists = await isFile(dirJsonPath); + + if (!rootJsonExists && !dirJsonExists) { + return { + diagnostics: [ + { + severity: 'error', + message: `No manifest at ${KIMI_PLUGIN_ROOT_PATH} or ${KIMI_PLUGIN_DIR_PATH}`, + }, + ], + }; + } + + const manifestPath = rootJsonExists ? rootJsonPath : dirJsonPath; + const manifestKind: PluginManifestKind = rootJsonExists ? 'kimi-plugin-root' : 'kimi-plugin-dir'; + const shadowedManifestPath = rootJsonExists && dirJsonExists ? dirJsonPath : undefined; + + let raw: unknown; + try { + raw = JSON.parse(await readFile(manifestPath, 'utf8')); + } catch (error) { + return { + manifestKind, + manifestPath, + shadowedManifestPath, + diagnostics: [ + { + severity: 'error', + message: `Failed to parse ${path.relative(pluginRoot, manifestPath)}: ${(error as Error).message}`, + }, + ], + }; + } + + if (!isObject(raw)) { + return { + manifestKind, + manifestPath, + shadowedManifestPath, + diagnostics: [{ severity: 'error', message: 'manifest must be a JSON object' }], + }; + } + + const diagnostics: PluginDiagnostic[] = []; + + const name = typeof raw['name'] === 'string' ? raw['name'].trim() : ''; + if (name.length === 0) { + diagnostics.push({ severity: 'error', message: '"name" is required' }); + return { manifestKind, manifestPath, shadowedManifestPath, diagnostics }; + } + if (!PLUGIN_NAME_REGEX.test(name)) { + diagnostics.push({ + severity: 'error', + message: `"name" must match ${PLUGIN_NAME_REGEX} (got "${name}")`, + }); + return { manifestKind, manifestPath, shadowedManifestPath, diagnostics }; + } + + let skills = await resolveSkillsField(pluginRoot, raw['skills'], diagnostics); + if (raw['skills'] === undefined) { + const rootSkillMd = path.join(pluginRoot, 'SKILL.md'); + if (await isFile(rootSkillMd)) { + skills = [pluginRoot]; + } + } + + const skillInstructions = + typeof raw['skillInstructions'] === 'string' ? raw['skillInstructions'] : undefined; + + recordUnsupportedRuntimeFields(raw, diagnostics); + + const manifest: PluginManifest = { + name, + version: stringField(raw, 'version'), + description: stringField(raw, 'description'), + keywords: stringArrayField(raw, 'keywords'), + homepage: stringField(raw, 'homepage'), + license: stringField(raw, 'license'), + author: readAuthor(raw['author']), + skills, + sessionStart: readSessionStart(raw['sessionStart'], diagnostics), + mcpServers: await readMcpServers(pluginRoot, raw['mcpServers'], diagnostics), + hooks: readHooks(raw['hooks'], diagnostics), + commands: await readCommands(pluginRoot, raw['commands'], diagnostics), + interface: readInterface(raw['interface']), + skillInstructions, + }; + + return { manifest, manifestKind, manifestPath, shadowedManifestPath, diagnostics }; +} + +function recordUnsupportedRuntimeFields( + raw: Record, + diagnostics: PluginDiagnostic[], +): void { + for (const field of UNSUPPORTED_RUNTIME_FIELDS) { + if (raw[field] === undefined) continue; + diagnostics.push({ + severity: 'info', + message: `"${field}" is present but not supported by Kimi plugins`, + }); + } +} + +async function resolveSkillsField( + pluginRoot: string, + raw: unknown, + diagnostics: PluginDiagnostic[], +): Promise { + if (raw === undefined) return []; + const entries: string[] = []; + if (typeof raw === 'string') { + entries.push(raw); + } else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) { + entries.push(...raw); + } else { + diagnostics.push({ severity: 'error', message: '"skills" must be a string or string[]' }); + return []; + } + + const resolved: string[] = []; + for (const entry of entries) { + if (!entry.startsWith('./')) { + diagnostics.push({ + severity: 'error', + message: `"skills" path must start with "./" (got "${entry}")`, + }); + continue; + } + const absolute = path.resolve(pluginRoot, entry); + let real: string; + try { + real = await realpath(absolute); + } catch { + real = absolute; + } + const rootReal = await realpath(pluginRoot).catch(() => pluginRoot); + if (!isWithin(real, rootReal)) { + diagnostics.push({ + severity: 'error', + message: `"skills" path resolves outside the plugin (${entry})`, + }); + continue; + } + if (!(await isDir(real))) { + diagnostics.push({ + severity: 'warn', + message: `"skills" path is not a directory (${entry})`, + }); + continue; + } + resolved.push(real); + } + return resolved; +} + +async function resolvePluginPathField(input: { + readonly pluginRoot: string; + readonly field: string; + readonly value: string; + readonly diagnostics: PluginDiagnostic[]; +}): Promise { + if (!input.value.startsWith('./')) { + input.diagnostics.push({ + severity: 'warn', + message: `"${input.field}" path must start with "./" (got "${input.value}")`, + }); + return undefined; + } + const absolute = path.resolve(input.pluginRoot, input.value); + let real: string; + try { + real = await realpath(absolute); + } catch { + real = absolute; + } + const rootReal = await realpath(input.pluginRoot).catch(() => input.pluginRoot); + if (!isWithin(real, rootReal)) { + input.diagnostics.push({ + severity: 'warn', + message: `"${input.field}" path resolves outside the plugin (${input.value})`, + }); + return undefined; + } + return real; +} + +function readSessionStart( + raw: unknown, + diagnostics: PluginDiagnostic[], +): PluginManifest['sessionStart'] { + if (raw === undefined) return undefined; + if (!isObject(raw)) { + diagnostics.push({ severity: 'warn', message: '"sessionStart" must be an object' }); + return undefined; + } + const skill = typeof raw['skill'] === 'string' ? raw['skill'].trim() : ''; + if (skill.length === 0) { + diagnostics.push({ + severity: 'warn', + message: '"sessionStart.skill" is required when sessionStart is present', + }); + return undefined; + } + return { skill }; +} + +async function readMcpServers( + pluginRoot: string, + raw: unknown, + diagnostics: PluginDiagnostic[], +): Promise { + if (raw === undefined) return undefined; + if (!isObject(raw)) { + diagnostics.push({ severity: 'warn', message: '"mcpServers" must be an object' }); + return undefined; + } + + const out: Record = {}; + for (const [name, value] of Object.entries(raw)) { + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + diagnostics.push({ + severity: 'warn', + message: '"mcpServers" entries must have a non-empty name', + }); + continue; + } + const parsed = McpServerConfigSchema.safeParse(value); + if (!parsed.success) { + diagnostics.push({ + severity: 'warn', + message: `Invalid MCP server "${trimmedName}": ${parsed.error.message}`, + }); + continue; + } + const normalized = await normalizePluginMcpServer({ + pluginRoot, + name: trimmedName, + config: parsed.data, + diagnostics, + }); + if (normalized !== undefined) out[trimmedName] = normalized; + } + return Object.keys(out).length === 0 ? undefined : out; +} + +function readHooks( + raw: unknown, + diagnostics: PluginDiagnostic[], +): readonly HookDefConfig[] | undefined { + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) { + diagnostics.push({ severity: 'warn', message: '"hooks" must be an array' }); + return undefined; + } + const out: HookDefConfig[] = []; + raw.forEach((entry, i) => { + const parsed = HookDefSchema.safeParse(entry); + if (!parsed.success) { + diagnostics.push({ + severity: 'warn', + message: `Invalid hook at index ${i}: ${parsed.error.message}`, + }); + } else { + out.push(parsed.data); + } + }); + return out.length === 0 ? undefined : out; +} + +async function readCommands( + pluginRoot: string, + raw: unknown, + diagnostics: PluginDiagnostic[], +): Promise { + if (raw === undefined) return undefined; + const entries: string[] = []; + if (typeof raw === 'string') { + entries.push(raw); + } else if (Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')) { + entries.push(...raw); + } else { + diagnostics.push({ severity: 'warn', message: '"commands" must be a string or string[]' }); + return undefined; + } + + const files: PluginCommandEntry[] = []; + for (const entry of entries) { + const resolved = await resolvePluginPathField({ + pluginRoot, + field: 'commands', + value: entry, + diagnostics, + }); + if (resolved === undefined) continue; + if (await isDir(resolved)) { + files.push(...(await listMarkdownFilesRecursive(resolved))); + } else if ((await isFile(resolved)) && resolved.endsWith('.md')) { + files.push({ path: resolved, name: commandNameFromFile(resolved, path.dirname(resolved)) }); + } else { + diagnostics.push({ + severity: 'warn', + message: `"commands" entry must be a directory or .md file (${entry})`, + }); + } + } + return files.length === 0 ? undefined : files.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +async function listMarkdownFilesRecursive(root: string): Promise { + const out: PluginCommandEntry[] = []; + await walkMarkdown(root, root, out); + return out; +} + +async function walkMarkdown( + root: string, + dir: string, + out: PluginCommandEntry[], +): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walkMarkdown(root, full, out); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push({ path: full, name: commandNameFromFile(full, root) }); + } + } +} + +function commandNameFromFile(file: string, root: string): string { + const relative = path.relative(root, file).replace(/\.md$/i, ''); + return relative.split(path.sep).join('/'); +} + +async function normalizePluginMcpServer(input: { + readonly pluginRoot: string; + readonly name: string; + readonly config: McpServerConfig; + readonly diagnostics: PluginDiagnostic[]; +}): Promise { + const { config } = input; + if (config.transport === 'http' || config.transport === 'sse') return config; + + let command = config.command; + if (command.startsWith('./')) { + const resolvedCommand = await resolvePluginPathField({ + pluginRoot: input.pluginRoot, + field: `mcpServers.${input.name}.command`, + value: command, + diagnostics: input.diagnostics, + }); + if (resolvedCommand === undefined) return undefined; + command = resolvedCommand; + } else if (command.includes('/') || path.isAbsolute(command)) { + input.diagnostics.push({ + severity: 'warn', + message: `"mcpServers.${input.name}.command" must be a PATH command or start with "./"`, + }); + return undefined; + } + + let cwd = config.cwd; + if (cwd !== undefined) { + const resolvedCwd = await resolvePluginPathField({ + pluginRoot: input.pluginRoot, + field: `mcpServers.${input.name}.cwd`, + value: cwd, + diagnostics: input.diagnostics, + }); + if (resolvedCwd === undefined) return undefined; + cwd = resolvedCwd; + } + + return { ...config, command, cwd }; +} + +function readAuthor(raw: unknown): PluginManifest['author'] { + if (typeof raw === 'string') return { name: raw }; + if (!isObject(raw)) return undefined; + const name = stringField(raw, 'name'); + const email = stringField(raw, 'email'); + if (name === undefined && email === undefined) return undefined; + return { name, email }; +} + +function readInterface(raw: unknown): PluginInterface | undefined { + if (!isObject(raw)) return undefined; + const out: PluginInterface = { + displayName: stringField(raw, 'displayName'), + shortDescription: stringField(raw, 'shortDescription'), + longDescription: stringField(raw, 'longDescription'), + developerName: stringField(raw, 'developerName'), + websiteURL: stringField(raw, 'websiteURL'), + }; + const hasAny = Object.values(out).some((value) => value !== undefined); + return hasAny ? out : undefined; +} + +function stringField(raw: Record, key: string): string | undefined { + const value = raw[key]; + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function stringArrayField(raw: Record, key: string): readonly string[] | undefined { + const value = raw[key]; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + return undefined; + } + return value as readonly string[]; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isWithin(child: string, parent: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +async function isFile(p: string): Promise { + try { + return (await stat(p)).isFile(); + } catch { + return false; + } +} + +async function isDir(p: string): Promise { + try { + return (await stat(p)).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/plugin/plugin.ts b/packages/agent-core-v2/src/plugin/plugin.ts new file mode 100644 index 000000000..21814d4d9 --- /dev/null +++ b/packages/agent-core-v2/src/plugin/plugin.ts @@ -0,0 +1,67 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { Event } from '#/_base/event'; +import type { HookDef } from '#/externalHooks/types'; +import type { McpServerConfig } from '#/mcp/config-schema'; +import type { SkillRoot } from '#/skill/types'; + +import type { + EnabledPluginSessionStart, + PluginCommandDef, + PluginInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from './types'; + +export interface InstallPluginInput { + readonly source: string; +} + +export interface SetPluginEnabledInput { + readonly id: string; + readonly enabled: boolean; +} + +export interface SetPluginMcpServerEnabledInput { + readonly id: string; + readonly server: string; + readonly enabled: boolean; +} + +export interface RemovePluginInput { + readonly id: string; +} + +export interface GetPluginInfoInput { + readonly id: string; +} + +export interface IPluginService { + readonly _serviceBrand: undefined; + listPlugins(): Promise; + installPlugin(input: InstallPluginInput): Promise; + setPluginEnabled(input: SetPluginEnabledInput): Promise; + setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise; + removePlugin(input: RemovePluginInput): Promise; + reloadPlugins(): Promise; + getPluginInfo(input: GetPluginInfoInput): Promise; + listPluginCommands(): Promise; + checkUpdates(): Promise; + + // --- consumption plane (loaded from enabled, error-free plugins) --------- + + /** Skill roots contributed by enabled plugins (fed into skill discovery). */ + pluginSkillRoots(): Promise; + /** Session-start reminders declared by enabled plugins. */ + enabledSessionStarts(): Promise; + /** MCP servers contributed by enabled plugins, keyed by runtime name. */ + enabledMcpServers(): Promise>; + /** Hooks contributed by enabled plugins (cwd + env already resolved). */ + enabledHooks(): Promise; + + /** Fires after a successful `reloadPlugins()` with the reload summary. */ + readonly onDidReload: Event; +} + +export const IPluginService: ServiceIdentifier = + createDecorator('pluginService'); diff --git a/packages/agent-core-v2/src/plugin/pluginService.ts b/packages/agent-core-v2/src/plugin/pluginService.ts new file mode 100644 index 000000000..242fd7c47 --- /dev/null +++ b/packages/agent-core-v2/src/plugin/pluginService.ts @@ -0,0 +1,123 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { Emitter, type Event } from '#/_base/event'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IBootstrapService } from '#/bootstrap'; +import type { HookDef } from '#/externalHooks/types'; +import type { McpServerConfig } from '#/mcp/config-schema'; +import type { SkillRoot } from '#/skill/types'; + +import { PluginManager } from './manager'; +import { + type GetPluginInfoInput, + type InstallPluginInput, + IPluginService, + type RemovePluginInput, + type SetPluginEnabledInput, + type SetPluginMcpServerEnabledInput, +} from './plugin'; +import type { + EnabledPluginSessionStart, + PluginCommandDef, + PluginInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from './types'; + +export class PluginService extends Disposable implements IPluginService { + declare readonly _serviceBrand: undefined; + + private readonly manager: PluginManager; + private loaded = false; + private readonly onDidReloadEmitter = this._register(new Emitter()); + + readonly onDidReload: Event = this.onDidReloadEmitter.event; + + constructor(@IBootstrapService bootstrap: IBootstrapService) { + super(); + this.manager = new PluginManager({ kimiHomeDir: bootstrap.homeDir }); + } + + async listPlugins(): Promise { + await this.ensureLoaded(); + return this.manager.summaries(); + } + + async installPlugin(input: InstallPluginInput): Promise { + await this.ensureLoaded(); + const record = await this.manager.install(input.source); + return this.manager.info(record.id) as PluginSummary; + } + + async setPluginEnabled(input: SetPluginEnabledInput): Promise { + await this.ensureLoaded(); + await this.manager.setEnabled(input.id, input.enabled); + } + + async setPluginMcpServerEnabled(input: SetPluginMcpServerEnabledInput): Promise { + await this.ensureLoaded(); + await this.manager.setMcpServerEnabled(input.id, input.server, input.enabled); + } + + async removePlugin(input: RemovePluginInput): Promise { + await this.ensureLoaded(); + await this.manager.remove(input.id); + } + + async reloadPlugins(): Promise { + const summary = await this.manager.reload(); + this.loaded = true; + this.onDidReloadEmitter.fire(summary); + return summary; + } + + async getPluginInfo(input: GetPluginInfoInput): Promise { + await this.ensureLoaded(); + return this.manager.info(input.id); + } + + async listPluginCommands(): Promise { + await this.ensureLoaded(); + return this.manager.enabledCommands(); + } + + async checkUpdates(): Promise { + await this.ensureLoaded(); + return this.manager.checkUpdates(); + } + + async pluginSkillRoots(): Promise { + await this.ensureLoaded(); + return this.manager.pluginSkillRoots(); + } + + async enabledSessionStarts(): Promise { + await this.ensureLoaded(); + return this.manager.enabledSessionStarts(); + } + + async enabledMcpServers(): Promise> { + await this.ensureLoaded(); + return this.manager.enabledMcpServers(); + } + + async enabledHooks(): Promise { + await this.ensureLoaded(); + return this.manager.enabledHooks(); + } + + private async ensureLoaded(): Promise { + if (this.loaded) return; + await this.manager.load(); + this.loaded = true; + } +} + +registerScopedService( + LifecycleScope.App, + IPluginService, + PluginService, + InstantiationType.Delayed, + 'plugin', +); diff --git a/packages/agent-core-v2/src/plugin/source.ts b/packages/agent-core-v2/src/plugin/source.ts new file mode 100644 index 000000000..a2092c087 --- /dev/null +++ b/packages/agent-core-v2/src/plugin/source.ts @@ -0,0 +1,86 @@ +import path from 'node:path'; + +export interface GithubRef { + readonly kind: 'branch' | 'tag' | 'sha'; + readonly value: string; +} + +export type ResolvedSource = + | { kind: 'local-path'; path: string } + | { kind: 'zip-url'; path: string } + | { kind: 'github'; owner: string; repo: string; ref?: GithubRef }; + +export type InstallSource = ResolvedSource; + +const SHA_RE = /^[0-9a-f]{7,40}$/; + +export function resolveInstallSource(source: string): ResolvedSource { + const trimmed = source.trim(); + + const github = parseGithubUrl(trimmed); + if (github !== undefined) return github; + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return { kind: 'zip-url', path: trimmed }; + } + if (!path.isAbsolute(trimmed)) { + throw new Error(`Plugin root must be an absolute path (got "${source}")`); + } + return { kind: 'local-path', path: trimmed }; +} + +function parseGithubUrl(raw: string): ResolvedSource | undefined { + let url: URL; + try { + url = new URL(raw); + } catch { + return undefined; + } + if (url.protocol !== 'https:') return undefined; + if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined; + + const segments = url.pathname.split('/').filter((s) => s.length > 0); + const owner = segments[0]; + const repoRaw = segments[1]; + if (owner === undefined || repoRaw === undefined) return undefined; + + const repo = repoRaw.endsWith('.git') ? repoRaw.slice(0, -4) : repoRaw; + const rest = segments.slice(2); + + if (rest.length === 0) { + return { kind: 'github', owner, repo }; + } + + const head = rest[0]; + const second = rest[1]; + + if (head === 'tree' && rest.length >= 2) { + const refValue = decodeRefSegments(rest.slice(1)); + const kind: GithubRef['kind'] = SHA_RE.test(refValue) ? 'sha' : 'branch'; + return { kind: 'github', owner, repo, ref: { kind, value: refValue } }; + } + + if (head === 'releases' && second === 'tag' && rest.length >= 3) { + const tag = decodeRefSegments(rest.slice(2)); + return { kind: 'github', owner, repo, ref: { kind: 'tag', value: tag } }; + } + + if (head === 'commit' && rest.length >= 2) { + const sha = decodeRefSegments(rest.slice(1)); + return { kind: 'github', owner, repo, ref: { kind: 'sha', value: sha } }; + } + + return undefined; +} + +function decodeRefSegments(segments: readonly string[]): string { + return segments + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join('/'); +} diff --git a/packages/agent-core-v2/src/plugin/store.ts b/packages/agent-core-v2/src/plugin/store.ts new file mode 100644 index 000000000..6439ae235 --- /dev/null +++ b/packages/agent-core-v2/src/plugin/store.ts @@ -0,0 +1,54 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import type { PluginCapabilityState, PluginGithubMetadata, PluginSource } from './types'; + +const INSTALLED_REL = path.join('plugins', 'installed.json'); + +export interface InstalledRecord { + readonly id: string; + readonly root: string; + readonly source: PluginSource; + readonly enabled: boolean; + readonly installedAt: string; + readonly updatedAt?: string; + readonly originalSource?: string; + readonly capabilities?: PluginCapabilityState; + readonly github?: PluginGithubMetadata; +} + +export interface InstalledFile { + readonly version: 1; + readonly plugins: readonly InstalledRecord[]; +} + +const EMPTY: InstalledFile = { version: 1, plugins: [] }; + +export async function readInstalled(kimiHomeDir: string): Promise { + const filePath = path.join(kimiHomeDir, INSTALLED_REL); + let text: string; + try { + text = await readFile(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return EMPTY; + throw error; + } + try { + const parsed = JSON.parse(text) as InstalledFile; + if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.plugins)) { + throw new Error('installed.json is not a valid InstalledFile object'); + } + return parsed; + } catch (error) { + throw new Error(`Failed to parse ${filePath}: ${(error as Error).message}`, { cause: error }); + } +} + +export async function writeInstalled(kimiHomeDir: string, data: InstalledFile): Promise { + const dir = path.join(kimiHomeDir, 'plugins'); + await mkdir(dir, { recursive: true }); + const final = path.join(dir, 'installed.json'); + const tmp = `${final}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8'); + await rename(tmp, final); +} diff --git a/packages/agent-core-v2/src/plugin/types.ts b/packages/agent-core-v2/src/plugin/types.ts index 2e88aa5dd..30e6921ce 100644 --- a/packages/agent-core-v2/src/plugin/types.ts +++ b/packages/agent-core-v2/src/plugin/types.ts @@ -1,3 +1,4 @@ +import type { HookDefConfig } from '#/externalHooks/configSection'; import type { McpServerConfig } from '#/mcp/config-schema'; export type PluginDiagnosticSeverity = 'error' | 'warn' | 'info'; @@ -35,6 +36,8 @@ export interface PluginManifest { readonly skills?: readonly string[]; // resolved absolute paths readonly sessionStart?: PluginSessionStart; readonly mcpServers?: Readonly>; + readonly hooks?: readonly HookDefConfig[]; + readonly commands?: readonly PluginCommandEntry[]; readonly interface?: PluginInterface; readonly skillInstructions?: string; } @@ -60,6 +63,27 @@ export interface PluginMcpServerInfo { readonly headerKeys?: readonly string[]; } +export interface PluginCommandDef { + readonly pluginId: string; + readonly name: string; + readonly description: string; + readonly body: string; + readonly path: string; +} + +/** + * A resolved command file plus its namespace-preserving name. + * + * `name` is the path of the file relative to the declared `commands` entry + * (without the `.md` extension, using `/` separators), so a file at + * `commands/frontend/component.md` yields the name `frontend/component`. + * Frontmatter `name` in the file itself takes precedence over this at load time. + */ +export interface PluginCommandEntry { + readonly path: string; + readonly name: string; +} + export type PluginManifestKind = 'kimi-plugin-root' | 'kimi-plugin-dir'; export type PluginSource = 'local-path' | 'zip-url' | 'github'; export type PluginState = 'ok' | 'error'; @@ -105,6 +129,8 @@ export interface PluginSummary { readonly skillCount: number; readonly mcpServerCount: number; readonly enabledMcpServerCount: number; + readonly hookCount: number; + readonly commandCount: number; readonly hasErrors: boolean; readonly source: PluginSource; readonly originalSource?: string; @@ -134,6 +160,15 @@ export interface ReloadSummary { readonly errors: ReadonlyArray<{ readonly id: string; readonly message: string }>; } +export interface PluginUpdateStatus { + readonly id: string; + readonly source: PluginSource; + readonly current?: PluginGithubRef; + readonly latest: PluginGithubRef; + readonly displayVersion: string; + readonly updateAvailable: boolean; +} + export const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/; export function normalizePluginId(name: string): string { diff --git a/packages/agent-core-v2/src/rpc/prompt-metadata.ts b/packages/agent-core-v2/src/rpc/prompt-metadata.ts new file mode 100644 index 000000000..8d1479cd4 --- /dev/null +++ b/packages/agent-core-v2/src/rpc/prompt-metadata.ts @@ -0,0 +1,78 @@ +import type { ContentPart } from '@moonshot-ai/kosong'; + +import type { + ActivatePluginCommandPayload, + ActivateSkillPayload, + PromptPayload, +} from './core-api'; + +const MAX_TITLE_LENGTH = 200; +const MAX_LAST_PROMPT_LENGTH = 4000; + +export function titleFromPromptMetadataText(text: string): string { + return text.slice(0, MAX_TITLE_LENGTH); +} + +export function promptMetadataTextFromPayload(payload: PromptPayload): string | undefined { + const parts: string[] = []; + for (const part of payload.input) { + const text = promptPartText(part); + if (text !== undefined) parts.push(text); + } + return sanitizeAndTruncatePromptText(parts.join('\n'), MAX_LAST_PROMPT_LENGTH); +} + +export function promptMetadataTextFromSkill(payload: ActivateSkillPayload): string | undefined { + const args = payload.args?.trim(); + return sanitizeAndTruncatePromptText( + args === undefined || args.length === 0 ? `/${payload.name}` : `/${payload.name} ${args}`, + MAX_LAST_PROMPT_LENGTH, + ); +} + +export function promptMetadataTextFromPluginCommand( + payload: ActivatePluginCommandPayload, +): string | undefined { + const args = payload.args?.trim(); + const command = `/${payload.pluginId}:${payload.commandName}`; + return sanitizeAndTruncatePromptText( + args === undefined || args.length === 0 ? command : `${command} ${args}`, + MAX_LAST_PROMPT_LENGTH, + ); +} + +function promptPartText(part: ContentPart): string | undefined { + switch (part.type) { + case 'text': + return part.text; + case 'image_url': + return '[image]'; + case 'audio_url': + return '[audio]'; + case 'video_url': + return '[video]'; + case 'think': + return undefined; + } +} + +function sanitizeAndTruncatePromptText(text: string, maxLength: number): string | undefined { + const sanitized = text + .replaceAll( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, + '[redacted]', + ) + .replaceAll(/\b(authorization)\s*:\s*bearer\s+\S+/gi, '$1: Bearer [redacted]') + .replaceAll( + /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, + '$1=[redacted]', + ) + .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') + .replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]') + .replaceAll(/\p{Cc}+/gu, ' ') + .replaceAll(/\s+/g, ' ') + .trim(); + + if (sanitized.length === 0) return undefined; + return sanitized.slice(0, maxLength); +} diff --git a/packages/agent-core-v2/test/plugin/archive.test.ts b/packages/agent-core-v2/test/plugin/archive.test.ts new file mode 100644 index 000000000..476f98c92 --- /dev/null +++ b/packages/agent-core-v2/test/plugin/archive.test.ts @@ -0,0 +1,36 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { extractZip } from '../../src/plugin/archive'; + +describe('plugin archive extraction', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'plugin-archive-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('extracts a zip and detects a nested plugin root', async () => { + const source = join(dir, 'source'); + const nested = join(source, 'plugin'); + await mkdir(nested, { recursive: true }); + await writeFile(join(nested, 'kimi.plugin.json'), JSON.stringify({ name: 'zip-demo' }), 'utf8'); + const zipPath = join(dir, 'plugin.zip'); + execFileSync('zip', ['-qr', zipPath, '.'], { cwd: source }); + + const outDir = join(dir, 'out'); + const detectedRoot = await extractZip(await readFile(zipPath), outDir); + + expect(detectedRoot).toBe(join(outDir, 'plugin')); + await expect(readFile(join(detectedRoot, 'kimi.plugin.json'), 'utf8')).resolves.toContain('zip-demo'); + }); +}); diff --git a/packages/agent-core-v2/test/plugin/commands.test.ts b/packages/agent-core-v2/test/plugin/commands.test.ts new file mode 100644 index 000000000..cda812b05 --- /dev/null +++ b/packages/agent-core-v2/test/plugin/commands.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + expandCommandArguments, + loadPluginCommand, + parseCommandText, +} from '../../src/plugin/commands'; + +describe('plugin command parser', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'plugin-command-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('parses frontmatter name and description', () => { + const commandPath = join(dir, 'deploy.md'); + const result = parseCommandText({ + text: '---\nname: deploy\ndescription: Deploy the app\n---\n\nRun deploy.', + commandPath, + pluginId: 'demo', + }); + + expect(result).toEqual({ + pluginId: 'demo', + name: 'deploy', + description: 'Deploy the app', + body: 'Run deploy.', + path: commandPath, + }); + }); + + it('falls back to the file name and first body line', () => { + const commandPath = join(dir, 'frontend/component.md'); + const result = parseCommandText({ + text: 'Build the component\n\nMore detail.', + commandPath, + pluginId: 'demo', + fallbackName: 'frontend/component', + }); + + expect(result.name).toBe('frontend/component'); + expect(result.description).toBe('Build the component'); + expect(result.body).toBe('Build the component\n\nMore detail.'); + }); + + it('loads a command file and returns undefined for missing files', async () => { + const commandPath = join(dir, 'deploy.md'); + await writeFile(commandPath, '---\ndescription: Deploy\n---\n\nBody', 'utf8'); + + await expect(loadPluginCommand({ commandPath, pluginId: 'demo' })).resolves.toMatchObject({ + pluginId: 'demo', + name: 'deploy', + description: 'Deploy', + body: 'Body', + }); + await expect(loadPluginCommand({ commandPath: join(dir, 'missing.md'), pluginId: 'demo' })).resolves.toBeUndefined(); + }); + + it('expands $ARGUMENTS and appends args when no placeholder exists', () => { + expect(expandCommandArguments('deploy $ARGUMENTS now', 'prod')).toBe('deploy prod now'); + expect(expandCommandArguments('deploy now', 'prod')).toBe('deploy now\n\nARGUMENTS: prod'); + expect(expandCommandArguments('deploy now', '')).toBe('deploy now'); + }); +}); diff --git a/packages/agent-core-v2/test/plugin/github-resolver.test.ts b/packages/agent-core-v2/test/plugin/github-resolver.test.ts new file mode 100644 index 000000000..8b2009fd2 --- /dev/null +++ b/packages/agent-core-v2/test/plugin/github-resolver.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resolveGithubSource } from '../../src/plugin/github-resolver'; + +describe('resolveGithubSource', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resolves explicit refs without network and encodes ref paths', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + resolveGithubSource({ kind: 'github', owner: 'owner', repo: 'repo', ref: { kind: 'tag', value: 'release#1' } }), + ).resolves.toEqual({ + tarballUrl: 'https://codeload.github.com/owner/repo/zip/refs/tags/release%231', + displayVersion: 'release#1', + ref: { kind: 'tag', value: 'release#1' }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('uses latest release redirect for bare github urls', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 302, + headers: new Headers({ location: 'https://github.com/owner/repo/releases/tag/v1.2.3' }), + }), + ); + + await expect(resolveGithubSource({ kind: 'github', owner: 'owner', repo: 'repo' })).resolves.toEqual({ + tarballUrl: 'https://codeload.github.com/owner/repo/zip/refs/tags/v1.2.3', + displayVersion: 'v1.2.3', + ref: { kind: 'tag', value: 'v1.2.3' }, + }); + }); + + it('falls back to HEAD when there is no latest release', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ status: 404, ok: false, headers: new Headers() }) + .mockResolvedValueOnce({ status: 200, ok: true, headers: new Headers() }), + ); + + await expect(resolveGithubSource({ kind: 'github', owner: 'owner', repo: 'repo' })).resolves.toEqual({ + tarballUrl: 'https://codeload.github.com/owner/repo/zip/HEAD', + displayVersion: 'HEAD', + ref: { kind: 'branch', value: 'HEAD' }, + }); + }); + + it('branch-kind ref carrying a tag value (e.g. /tree/v5.1.0) still resolves via short form', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await resolveGithubSource({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'branch', value: 'v5.1.0' }, + }); + + // Parser cannot distinguish branch from tag in `/tree/`, but codeload's + // short form resolves either — so no `/refs/heads/` 404. + expect(result.tarballUrl).toBe('https://codeload.github.com/obra/superpowers/zip/v5.1.0'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('bare URL: 302 with /releases/tag/X resolves to that tag', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 302, + headers: new Headers({ + location: 'https://github.com/obra/superpowers/releases/tag/v5.1.0', + }), + }), + ); + + const result = await resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }); + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/obra/superpowers/zip/refs/tags/v5.1.0', + ); + expect(result.ref).toEqual({ kind: 'tag', value: 'v5.1.0' }); + expect(result.displayVersion).toBe('v5.1.0'); + }); + + it('does not call api.github.com on bare URL (API bypass)', async () => { + const calls: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: Parameters[0]) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + calls.push(url); + if (url.includes('github.com') && url.includes('/releases/latest')) { + return new Response(null, { + status: 302, + headers: { location: 'https://github.com/obra/superpowers/releases/tag/v5.1.0' }, + }); + } + throw new Error(`unexpected url: ${url}`); + }) as typeof fetch, + ); + + await resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }); + expect(calls.every((u) => !u.startsWith('https://api.github.com'))).toBe(true); + }); + + it('release-lookup error message hints at the /tree/ escape hatch', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ status: 502, statusText: 'Bad Gateway', headers: new Headers() }), + ); + + await expect( + resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }), + ).rejects.toThrow(/\/tree\//); + }); +}); diff --git a/packages/agent-core-v2/test/plugin/manager-consumption.test.ts b/packages/agent-core-v2/test/plugin/manager-consumption.test.ts new file mode 100644 index 000000000..f16a855a2 --- /dev/null +++ b/packages/agent-core-v2/test/plugin/manager-consumption.test.ts @@ -0,0 +1,521 @@ +import { execFileSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { PluginManager } from '../../src/plugin/manager'; + +async function makeKimiHome(): Promise { + return mkdtemp(path.join(tmpdir(), 'kimi-home-')); +} + +async function managedPluginRoot(home: string, id: string): Promise { + return realpath(path.join(home, 'plugins', 'managed', id)); +} + +async function makePlugin( + name: string, + options: { + skills?: boolean; + skillNames?: readonly string[]; + version?: string; + sessionStartSkill?: string; + mcpServers?: Record; + hooks?: readonly unknown[]; + commands?: Record; + } = {}, +): Promise { + const root = await mkdtemp(path.join(tmpdir(), `plugin-${name}-`)); + const manifest: Record = { name }; + if (options.version !== undefined) { + manifest['version'] = options.version; + } + const skillNames = options.skillNames ?? (options.skills === true ? ['demo-skill'] : []); + if (skillNames.length > 0) { + manifest['skills'] = './skills/'; + await mkdir(path.join(root, 'skills'), { recursive: true }); + for (const skillName of skillNames) { + await mkdir(path.join(root, 'skills', skillName), { recursive: true }); + await writeFile( + path.join(root, 'skills', skillName, 'SKILL.md'), + `---\nname: ${skillName}\ndescription: A demo\n---\nbody`, + 'utf8', + ); + } + } + if (options.sessionStartSkill !== undefined) { + manifest['sessionStart'] = { skill: options.sessionStartSkill }; + } + if (options.mcpServers !== undefined) { + manifest['mcpServers'] = options.mcpServers; + } + if (options.hooks !== undefined) { + manifest['hooks'] = options.hooks; + } + if (options.commands !== undefined) { + manifest['commands'] = ['./commands']; + await mkdir(path.join(root, 'commands'), { recursive: true }); + for (const [file, body] of Object.entries(options.commands)) { + const filePath = path.join(root, 'commands', file); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, body, 'utf8'); + } + } + await writeFile(path.join(root, 'kimi.plugin.json'), JSON.stringify(manifest), 'utf8'); + return realpath(root); +} + +async function zipDir(sourceRoot: string): Promise { + const zipPath = path.join(tmpdir(), `plugin-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`); + execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot }); + const buffer = await readFile(zipPath); + await rm(zipPath, { force: true }); + return buffer; +} + +async function serveOnce(buffer: Buffer): Promise { + const server = createServer((_, res) => { + res.writeHead(200, { 'Content-Type': 'application/zip' }); + res.end(buffer); + server.close(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('bad server address'); + return `http://127.0.0.1:${address.port}/plugin.zip`; +} + +interface MockGithubFetchOptions { + releaseTag?: string; + tarball: Buffer; + onReleaseLookup?: () => void; +} + +function mockGithubFetch(options: MockGithubFetchOptions): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: Parameters[0], init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (/^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/latest$/.test(url)) { + options.onReleaseLookup?.(); + if (options.releaseTag === undefined) { + return new Response(null, { status: 404 }); + } + const tagUrl = url.replace(/\/releases\/latest$/, `/releases/tag/${options.releaseTag}`); + return new Response(null, { status: 302, headers: { location: tagUrl } }); + } + if (url.startsWith('https://codeload.github.com/')) { + if (init?.method === 'HEAD') return new Response(null, { status: 200 }); + return new Response(options.tarball, { status: 200 }); + } + throw new Error(`mockGithubFetch: unexpected url ${url}`); + }) as typeof fetch, + ); +} + +describe('PluginManager consumption plane', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('pluginSkillRoots() returns only enabled plugins skills paths', async () => { + const home = await makeKimiHome(); + const a = await makePlugin('a', { skills: true }); + const b = await makePlugin('b', { skills: true }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(a); + await manager.install(b); + await manager.setEnabled('b', false); + const managedA = await managedPluginRoot(home, 'a'); + const managedB = await managedPluginRoot(home, 'b'); + expect(manager.pluginSkillRoots()).toContainEqual({ + path: path.join(managedA, 'skills'), + source: 'extra', + plugin: { id: 'a', instructions: undefined }, + }); + expect(manager.pluginSkillRoots()).not.toContainEqual({ + path: path.join(managedB, 'skills'), + source: 'extra', + plugin: { id: 'b', instructions: undefined }, + }); + }); + + it('pluginSkillRoots() excludes plugins in error state', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo'); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + await writeFile( + path.join(await managedPluginRoot(home, 'demo'), 'kimi.plugin.json'), + '{ not json', + 'utf8', + ); + await manager.reload(); + expect(manager.get('demo')?.state).toBe('error'); + expect(manager.pluginSkillRoots()).toEqual([]); + }); + + it('summaries count discovered skills inside plugin skill roots', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('superpowers', { + skillNames: ['brainstorming', 'systematic-debugging', 'writing-plans'], + }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + expect(manager.summaries()).toContainEqual( + expect.objectContaining({ id: 'superpowers', skillCount: 3 }), + ); + expect(manager.info('superpowers')?.skillCount).toBe(3); + }); + + it('enabledSessionStarts() returns only enabled plugin sessionStart declarations', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { skills: true, sessionStartSkill: 'demo-skill' }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + expect(manager.enabledSessionStarts()).toEqual([{ pluginId: 'demo', skillName: 'demo-skill' }]); + await manager.setEnabled('demo', false); + expect(manager.enabledSessionStarts()).toEqual([]); + }); + + it('setMcpServerEnabled() persists explicit MCP server state with cwd + env + runtime name', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { + mcpServers: { + finance: { command: 'finance-mcp' }, + docs: { url: 'https://example.com/mcp' }, + events: { transport: 'sse', url: 'https://example.com/sse' }, + }, + }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + const managedRoot = await managedPluginRoot(home, 'demo'); + + expect(manager.info('demo')?.mcpServers).toContainEqual( + expect.objectContaining({ + name: 'finance', + runtimeName: 'plugin-demo:finance', + enabled: true, + command: 'finance-mcp', + }), + ); + expect(manager.info('demo')?.mcpServers).toContainEqual( + expect.objectContaining({ + name: 'events', + runtimeName: 'plugin-demo:events', + transport: 'sse', + url: 'https://example.com/sse', + }), + ); + expect(manager.summaries()[0]).toEqual( + expect.objectContaining({ mcpServerCount: 3, enabledMcpServerCount: 3 }), + ); + + expect(manager.enabledMcpServers()).toEqual( + expect.objectContaining({ + 'plugin-demo:finance': expect.objectContaining({ + command: 'finance-mcp', + cwd: managedRoot, + env: expect.objectContaining({ KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: managedRoot }), + }), + 'plugin-demo:docs': expect.objectContaining({ url: 'https://example.com/mcp' }), + 'plugin-demo:events': expect.objectContaining({ + transport: 'sse', + url: 'https://example.com/sse', + }), + }), + ); + + await manager.setMcpServerEnabled('demo', 'finance', false); + expect(manager.enabledMcpServers()).not.toHaveProperty('plugin-demo:finance'); + expect(manager.summaries()[0]).toEqual( + expect.objectContaining({ mcpServerCount: 3, enabledMcpServerCount: 2 }), + ); + + const reloaded = new PluginManager({ kimiHomeDir: home }); + await reloaded.load(); + expect(reloaded.info('demo')?.mcpServers).toContainEqual( + expect.objectContaining({ name: 'finance', enabled: false }), + ); + }); + + it('merges manifest MCP enabled defaults with explicit user state', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { + mcpServers: { finance: { command: 'finance-mcp', enabled: false } }, + }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + expect(manager.info('demo')?.mcpServers).toContainEqual( + expect.objectContaining({ name: 'finance', enabled: false }), + ); + expect(manager.summaries()[0]).toEqual( + expect.objectContaining({ mcpServerCount: 1, enabledMcpServerCount: 0 }), + ); + expect(manager.enabledMcpServers()).toEqual({}); + + await manager.setMcpServerEnabled('demo', 'finance', true); + expect(manager.enabledMcpServers()).toEqual( + expect.objectContaining({ + 'plugin-demo:finance': expect.objectContaining({ command: 'finance-mcp', enabled: true }), + }), + ); + + const reloaded = new PluginManager({ kimiHomeDir: home }); + await reloaded.load(); + expect(reloaded.info('demo')?.mcpServers).toContainEqual( + expect.objectContaining({ name: 'finance', enabled: true }), + ); + expect(reloaded.enabledMcpServers()).toHaveProperty('plugin-demo:finance'); + }); + + it('uses unambiguous runtime names for plugin MCP servers', async () => { + const home = await makeKimiHome(); + const first = await makePlugin('a-b', { mcpServers: { c: { command: 'first-mcp' } } }); + const second = await makePlugin('a', { mcpServers: { 'b-c': { command: 'second-mcp' } } }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(first); + await manager.install(second); + expect(manager.info('a-b')?.mcpServers).toContainEqual( + expect.objectContaining({ name: 'c', runtimeName: 'plugin-a-b:c' }), + ); + expect(manager.info('a')?.mcpServers).toContainEqual( + expect.objectContaining({ name: 'b-c', runtimeName: 'plugin-a:b-c' }), + ); + const servers = manager.enabledMcpServers(); + expect(servers).toEqual( + expect.objectContaining({ + 'plugin-a-b:c': expect.objectContaining({ command: 'first-mcp' }), + 'plugin-a:b-c': expect.objectContaining({ command: 'second-mcp' }), + }), + ); + expect(Object.keys(servers)).toHaveLength(2); + }); + + it('enabledMcpServers() excludes disabled plugins', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { mcpServers: { finance: { command: 'finance-mcp' } } }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + await manager.setMcpServerEnabled('demo', 'finance', true); + await manager.setEnabled('demo', false); + expect(manager.enabledMcpServers()).toEqual({}); + }); + + it('setMcpServerEnabled() rejects unknown MCP servers', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo'); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + await expect(manager.setMcpServerEnabled('demo', 'missing', true)).rejects.toThrow( + /does not declare MCP server/i, + ); + }); + + it('reload() picks up edits to the managed plugin copy', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo'); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + const managedRoot = await managedPluginRoot(home, 'demo'); + await writeFile( + path.join(managedRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'demo', version: '2.0.0' }), + 'utf8', + ); + const summary = await manager.reload(); + expect(summary.errors).toEqual([]); + expect(manager.get('demo')?.manifest?.version).toBe('2.0.0'); + }); + + it('remove() clears the entry but does not delete the source directory', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { skills: true }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + await manager.remove('demo'); + expect(manager.get('demo')).toBeUndefined(); + expect((await stat(root)).isDirectory()).toBe(true); + }); + + it('enabledHooks() returns hooks from enabled plugins with cwd and env injected', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { + hooks: [{ event: 'PreToolUse', command: './hooks/guard.sh', timeout: 10 }], + }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + const installedRoot = await managedPluginRoot(home, 'demo'); + expect(manager.enabledHooks()).toEqual([ + { + event: 'PreToolUse', + command: './hooks/guard.sh', + timeout: 10, + cwd: installedRoot, + env: { KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: installedRoot }, + }, + ]); + }); + + it('enabledHooks() excludes disabled plugins', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('demo', { hooks: [{ event: 'PreToolUse', command: './x.sh' }] }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + await manager.install(root); + await manager.setEnabled('demo', false); + expect(manager.enabledHooks()).toEqual([]); + }); + + it('install() from /tree/ downloads via short form, not refs/heads/', async () => { + const home = await makeKimiHome(); + const sourceRoot = await mkdtemp(path.join(tmpdir(), 'plugin-gh-tag-')); + await writeFile( + path.join(sourceRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'pin-tag-demo', version: '5.1.0' }), + 'utf8', + ); + const zipBuffer = await zipDir(sourceRoot); + + let codeloadPath = ''; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: Parameters[0]) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.startsWith('https://codeload.github.com/')) { + codeloadPath = new URL(url).pathname; + return new Response(zipBuffer, { status: 200 }); + } + throw new Error(`unexpected url ${url}`); + }) as typeof fetch, + ); + + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const record = await manager.install('https://github.com/obra/superpowers/tree/v5.1.0'); + expect(codeloadPath).toBe('/obra/superpowers/zip/v5.1.0'); + expect(record.github?.ref).toEqual({ kind: 'branch', value: 'v5.1.0' }); + await rm(sourceRoot, { recursive: true, force: true }); + }); + + it('install() from /releases/tag/ resolves precisely via refs/tags/', async () => { + const home = await makeKimiHome(); + const sourceRoot = await mkdtemp(path.join(tmpdir(), 'plugin-gh-release-')); + await writeFile( + path.join(sourceRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'pin-tag-demo', version: '5.1.0' }), + 'utf8', + ); + const zipBuffer = await zipDir(sourceRoot); + + let codeloadPath = ''; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: Parameters[0]) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.startsWith('https://codeload.github.com/')) { + codeloadPath = new URL(url).pathname; + return new Response(zipBuffer, { status: 200 }); + } + throw new Error(`unexpected url ${url}`); + }) as typeof fetch, + ); + + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const record = await manager.install('https://github.com/obra/superpowers/releases/tag/v5.1.0'); + expect(codeloadPath).toBe('/obra/superpowers/zip/refs/tags/v5.1.0'); + expect(record.github?.ref).toEqual({ kind: 'tag', value: 'v5.1.0' }); + await rm(sourceRoot, { recursive: true, force: true }); + }); + + it('install() from github /tree/ bypasses the GitHub API', async () => { + const home = await makeKimiHome(); + const sourceRoot = await mkdtemp(path.join(tmpdir(), 'plugin-gh-branch-')); + await writeFile( + path.join(sourceRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'gh-demo', version: '5.1.0' }), + 'utf8', + ); + const zipBuffer = await zipDir(sourceRoot); + + let releaseLookups = 0; + mockGithubFetch({ tarball: zipBuffer, onReleaseLookup: () => releaseLookups++ }); + + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const record = await manager.install('https://github.com/wbxl2000/superpowers/tree/main'); + expect(releaseLookups).toBe(0); + expect(record.source).toBe('github'); + expect(record.github?.ref).toEqual({ kind: 'branch', value: 'main' }); + await rm(sourceRoot, { recursive: true, force: true }); + }); + + it('install() ignores forged marketplace context from legacy callers', async () => { + const home = await makeKimiHome(); + const root = await makePlugin('rando', { version: '1.0.0' }); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const record = await (manager.install as (source: string, options?: unknown) => Promise)( + root, + { marketplace: { id: 'rando', tier: 'official' } }, + ); + expect((record as { marketplace?: unknown }).marketplace).toBeUndefined(); + }); + + it('install() from github URL overwrites an existing zip-url install (CDN migration)', async () => { + const home = await makeKimiHome(); + + const cdnSource = await mkdtemp(path.join(tmpdir(), 'plugin-cdn-')); + await writeFile( + path.join(cdnSource, 'kimi.plugin.json'), + JSON.stringify({ name: 'superpowers', version: '5.0.0' }), + 'utf8', + ); + const cdnUrl = await serveOnce(await zipDir(cdnSource)); + + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const first = await manager.install(cdnUrl); + expect(first.source).toBe('zip-url'); + await manager.setEnabled('superpowers', false); + + const ghSource = await mkdtemp(path.join(tmpdir(), 'plugin-gh-migrate-')); + await writeFile( + path.join(ghSource, 'kimi.plugin.json'), + JSON.stringify({ name: 'superpowers', version: '5.1.0' }), + 'utf8', + ); + mockGithubFetch({ releaseTag: 'v5.1.0', tarball: await zipDir(ghSource) }); + + const updated = await manager.install('https://github.com/wbxl2000/superpowers'); + expect(updated.source).toBe('github'); + expect(updated.manifest?.version).toBe('5.1.0'); + expect(updated.enabled).toBe(false); + expect(updated.installedAt).toBe(first.installedAt); + expect(updated.originalSource).toBe('https://github.com/wbxl2000/superpowers'); + expect(updated.github?.ref).toEqual({ kind: 'tag', value: 'v5.1.0' }); + expect(manager.list()).toHaveLength(1); + + await rm(cdnSource, { recursive: true, force: true }); + await rm(ghSource, { recursive: true, force: true }); + }); +}); diff --git a/packages/agent-core-v2/test/plugin/manager.test.ts b/packages/agent-core-v2/test/plugin/manager.test.ts new file mode 100644 index 000000000..cc0611155 --- /dev/null +++ b/packages/agent-core-v2/test/plugin/manager.test.ts @@ -0,0 +1,200 @@ +import { execFileSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, readFile, 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 { PluginManager } from '../../src/plugin/manager'; + +describe('PluginManager', () => { + let home: string; + let root: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'plugin-manager-home-')); + root = await mkdtemp(join(tmpdir(), 'plugin-manager-root-')); + await mkdir(join(home, 'plugins'), { recursive: true }); + await mkdir(join(root, 'commands'), { recursive: true }); + await writeFile(join(root, 'commands', 'deploy.md'), '---\ndescription: Deploy\n---\n\nBody', 'utf8'); + await writeFile( + join(root, 'kimi.plugin.json'), + JSON.stringify({ + name: 'demo', + commands: ['./commands'], + hooks: [{ event: 'Stop', command: 'echo stop' }], + }), + 'utf8', + ); + await writeFile( + join(home, 'plugins', 'installed.json'), + JSON.stringify({ + version: 1, + plugins: [ + { + id: 'demo', + root, + source: 'local-path', + enabled: true, + installedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + 'utf8', + ); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await rm(home, { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); + }); + + it('loads installed plugins and exposes summaries, hooks, and commands', async () => { + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + + expect(manager.summaries()).toEqual([ + expect.objectContaining({ + id: 'demo', + state: 'ok', + commandCount: 1, + hookCount: 1, + }), + ]); + expect(manager.enabledHooks()).toEqual([ + { + event: 'Stop', + command: 'echo stop', + cwd: root, + env: { KIMI_CODE_HOME: home, KIMI_PLUGIN_ROOT: root }, + }, + ]); + await expect(manager.enabledCommands()).resolves.toEqual([ + expect.objectContaining({ pluginId: 'demo', name: 'deploy', description: 'Deploy' }), + ]); + }); + + it('installs a local-path plugin into the managed root', async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-install-source-')); + try { + await writeFile(join(sourceRoot, 'kimi.plugin.json'), JSON.stringify({ name: 'other' }), 'utf8'); + const manager = new PluginManager({ kimiHomeDir: home }); + + const record = await manager.install(sourceRoot); + + expect(record.id).toBe('other'); + expect(record.root).toContain(join(home, 'plugins', 'managed', 'other')); + expect(manager.get('other')?.manifest?.name).toBe('other'); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it('installs a zip-url plugin', async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-zip-source-')); + const zipPath = join(tmpdir(), `plugin-${Date.now()}.zip`); + const server = createServer((_req, res) => { + void readFile(zipPath).then((data) => res.end(data)); + }); + try { + await writeFile(join(sourceRoot, 'kimi.plugin.json'), JSON.stringify({ name: 'zip-plugin' }), 'utf8'); + execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('bad server address'); + const manager = new PluginManager({ kimiHomeDir: home }); + + const record = await manager.install(`http://127.0.0.1:${address.port}/plugin.zip`); + + expect(record.id).toBe('zip-plugin'); + expect(record.source).toBe('zip-url'); + expect(manager.get('zip-plugin')?.manifest?.name).toBe('zip-plugin'); + } finally { + await new Promise((resolve, reject) => server.close((err) => (err === undefined ? resolve() : reject(err)))); + await rm(sourceRoot, { recursive: true, force: true }); + await rm(zipPath, { force: true }); + } + }); + + it('installs a github plugin through codeload', async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-github-source-')); + const zipPath = join(tmpdir(), `plugin-github-${Date.now()}.zip`); + try { + await writeFile(join(sourceRoot, 'kimi.plugin.json'), JSON.stringify({ name: 'github-plugin' }), 'utf8'); + execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot }); + const zip = await readFile(zipPath); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(zip))); + const manager = new PluginManager({ kimiHomeDir: home }); + + const record = await manager.install('https://github.com/owner/repo/tree/v1'); + + expect(record.id).toBe('github-plugin'); + expect(record.source).toBe('github'); + expect(record.github).toEqual({ + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'v1' }, + }); + expect(manager.get('github-plugin')?.manifest?.name).toBe('github-plugin'); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + await rm(zipPath, { force: true }); + } + }); + + it('checks github plugin updates against latest release', async () => { + await writeFile( + join(home, 'plugins', 'installed.json'), + JSON.stringify({ + version: 1, + plugins: [ + { + id: 'demo', + root, + source: 'github', + enabled: true, + installedAt: '2026-01-01T00:00:00.000Z', + github: { owner: 'owner', repo: 'repo', ref: { kind: 'branch', value: 'v1' } }, + }, + ], + }), + 'utf8', + ); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 302, + ok: false, + headers: new Headers({ location: 'https://github.com/owner/repo/releases/tag/v2' }), + }), + ); + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + + await expect(manager.checkUpdates()).resolves.toEqual([ + { + id: 'demo', + source: 'github', + current: { kind: 'branch', value: 'v1' }, + latest: { kind: 'tag', value: 'v2' }, + displayVersion: 'v2', + updateAvailable: true, + }, + ]); + }); + + it('persists enabled state changes', async () => { + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + + await manager.setEnabled('demo', false); + + expect(manager.get('demo')?.enabled).toBe(false); + const stored = JSON.parse(await readFile(join(home, 'plugins', 'installed.json'), 'utf8')) as { + plugins: Array<{ id: string; enabled: boolean }>; + }; + expect(stored.plugins).toEqual([expect.objectContaining({ id: 'demo', enabled: false })]); + }); +}); diff --git a/packages/agent-core-v2/test/plugin/manifest.test.ts b/packages/agent-core-v2/test/plugin/manifest.test.ts new file mode 100644 index 000000000..27ecfc4eb --- /dev/null +++ b/packages/agent-core-v2/test/plugin/manifest.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { parseManifest } from '../../src/plugin/manifest'; + +describe('plugin manifest parser', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'plugin-manifest-test-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('reads recursive command entries and valid hooks', async () => { + await mkdir(join(dir, 'commands', 'frontend'), { recursive: true }); + await writeFile(join(dir, 'commands', 'frontend', 'component.md'), '# Component', 'utf8'); + await writeFile(join(dir, 'commands', 'deploy.md'), '# Deploy', 'utf8'); + await writeFile( + join(dir, 'kimi.plugin.json'), + JSON.stringify({ + name: 'demo', + commands: ['./commands'], + hooks: [{ event: 'Stop', command: 'echo stop' }], + }), + 'utf8', + ); + + const result = await parseManifest(dir); + const root = await realpath(dir); + + expect(result.manifest?.commands).toEqual([ + { path: join(root, 'commands', 'deploy.md'), name: 'deploy' }, + { path: join(root, 'commands', 'frontend', 'component.md'), name: 'frontend/component' }, + ]); + expect(result.manifest?.hooks).toEqual([{ event: 'Stop', command: 'echo stop' }]); + expect(result.diagnostics).toEqual([]); + }); + + it('warns on invalid hooks and command paths', async () => { + await writeFile( + join(dir, 'kimi.plugin.json'), + JSON.stringify({ + name: 'demo', + commands: ['../outside.md'], + hooks: [{ event: 'Nope', command: 'echo nope' }], + }), + 'utf8', + ); + + const result = await parseManifest(dir); + + expect(result.manifest?.commands).toBeUndefined(); + expect(result.manifest?.hooks).toBeUndefined(); + expect(result.diagnostics.map((d) => d.message)).toEqual([ + expect.stringContaining('Invalid hook at index 0'), + '"commands" path must start with "./" (got "../outside.md")', + ]); + }); +}); diff --git a/packages/agent-core-v2/test/plugin/plugin-session-start.test.ts b/packages/agent-core-v2/test/plugin/plugin-session-start.test.ts new file mode 100644 index 000000000..63c827fff --- /dev/null +++ b/packages/agent-core-v2/test/plugin/plugin-session-start.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { Emitter } from '#/_base/event'; +import { + IPluginSessionStartInjectorService, + PluginSessionStartInjectorService, +} from '#/contextInjector/pluginSessionStart'; +import { IPluginService } from '#/plugin'; +import type { EnabledPluginSessionStart, ReloadSummary } from '#/plugin/types'; +import { InMemorySkillCatalog } from '#/skill/registry'; +import type { SkillDefinition } from '#/skill/types'; + +import { agentService, appService, createTestAgent, skillServices, type TestAgentContext } from '../harness'; + +function pluginSkill(): SkillDefinition { + return { + name: 'demo-skill', + description: 'A plugin skill', + path: '/plugins/demo/skills/demo-skill/SKILL.md', + dir: '/plugins/demo/skills/demo-skill', + content: 'Do the demo thing.', + metadata: {}, + source: 'extra', + plugin: { id: 'demo', instructions: 'Always be helpful.' }, + }; +} + +interface PluginServiceStubOptions { + readonly sessionStarts: readonly EnabledPluginSessionStart[]; + readonly reloadEmitter?: Emitter; +} + +function pluginServiceStub(options: PluginServiceStubOptions): IPluginService { + const reloadEmitter = options.reloadEmitter; + return { + _serviceBrand: undefined, + onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async (): Promise => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => undefined, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => [], + enabledSessionStarts: async () => options.sessionStarts, + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} + +function findPluginSessionStartMessages(ctx: TestAgentContext) { + return ctx.contextData().history.filter( + (message) => + message.origin?.kind === 'injection' && message.origin.variant === 'plugin_session_start', + ); +} + +function messageText(message: { readonly content: readonly { readonly type: string; readonly text?: string }[] }): string { + return message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join(''); +} + +describe('PluginSessionStartInjectorService (production wiring)', () => { + let ctx: TestAgentContext | undefined; + + afterEach(async () => { + if (ctx !== undefined) await ctx.dispose(); + ctx = undefined; + }); + + it('injects the plugin session-start reminder through the real service during a turn', async () => { + const catalog = new InMemorySkillCatalog(); + catalog.register(pluginSkill()); + + ctx = createTestAgent( + { autoConfigure: true }, + appService( + IPluginService, + pluginServiceStub({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }), + ), + skillServices(catalog), + agentService( + IPluginSessionStartInjectorService, + new SyncDescriptor(PluginSessionStartInjectorService), + ), + ); + + // Force-instantiate the real injector (production does this from createMain). + ctx.get(IPluginSessionStartInjectorService); + + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); + await ctx.untilTurnEnd(); + + const injected = findPluginSessionStartMessages(ctx).at(-1); + expect(injected).toBeDefined(); + const text = injected === undefined ? '' : messageText(injected); + expect(text).toContain(''); + expect(text).toContain('Do the demo thing.'); + expect(text).toContain('Always be helpful.'); + }); + + it('does not inject when no plugin session starts are enabled', async () => { + const catalog = new InMemorySkillCatalog(); + catalog.register(pluginSkill()); + + ctx = createTestAgent( + { autoConfigure: true }, + appService(IPluginService, pluginServiceStub({ sessionStarts: [] })), + skillServices(catalog), + agentService( + IPluginSessionStartInjectorService, + new SyncDescriptor(PluginSessionStartInjectorService), + ), + ); + + ctx.get(IPluginSessionStartInjectorService); + + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); + await ctx.untilTurnEnd(); + + expect(findPluginSessionStartMessages(ctx)).toHaveLength(0); + }); + + it('re-appends a fresh reminder when plugins are reloaded', async () => { + const catalog = new InMemorySkillCatalog(); + catalog.register(pluginSkill()); + const reloadEmitter = new Emitter(); + + ctx = createTestAgent( + { autoConfigure: true }, + appService( + IPluginService, + pluginServiceStub({ + sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], + reloadEmitter, + }), + ), + skillServices(catalog), + agentService( + IPluginSessionStartInjectorService, + new SyncDescriptor(PluginSessionStartInjectorService), + ), + ); + + ctx.get(IPluginSessionStartInjectorService); + + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); + await ctx.untilTurnEnd(); + + expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); + + // Simulate `pluginService.reloadPlugins()` firing. + reloadEmitter.fire({ added: ['demo'], removed: [], errors: [] }); + // appendReminderOnReload is async (awaits skillCatalog.ready); let it settle. + await Promise.resolve(); + await Promise.resolve(); + + const messages = findPluginSessionStartMessages(ctx); + expect(messages.length).toBeGreaterThanOrEqual(2); + const latest = messageText(messages.at(-1)!); + expect(latest).toContain(''); + expect(latest).toContain('supersedes any earlier plugin_session_start reminder'); + reloadEmitter.dispose(); + }); +}); diff --git a/packages/agent-core-v2/test/plugin/source.test.ts b/packages/agent-core-v2/test/plugin/source.test.ts new file mode 100644 index 000000000..ec6ffffce --- /dev/null +++ b/packages/agent-core-v2/test/plugin/source.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveInstallSource } from '../../src/plugin/source'; + +describe('resolveInstallSource', () => { + it('resolves absolute local paths', () => { + expect(resolveInstallSource('/tmp/plugin')).toEqual({ kind: 'local-path', path: '/tmp/plugin' }); + }); + + it('resolves zip urls', () => { + expect(resolveInstallSource('https://example.com/plugin.zip')).toEqual({ + kind: 'zip-url', + path: 'https://example.com/plugin.zip', + }); + }); + + it('resolves github tree, release tag, and commit urls', () => { + expect(resolveInstallSource('https://github.com/owner/repo/tree/release%231')).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'release#1' }, + }); + expect(resolveInstallSource('https://github.com/owner/repo/releases/tag/v1.2.3')).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'tag', value: 'v1.2.3' }, + }); + expect(resolveInstallSource('https://github.com/owner/repo/commit/abc1234')).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'sha', value: 'abc1234' }, + }); + }); + + it('rejects relative paths', () => { + expect(() => resolveInstallSource('./plugin')).toThrow('absolute path'); + }); +}); diff --git a/packages/agent-core-v2/test/skill/plugin-session-start.test.ts b/packages/agent-core-v2/test/skill/plugin-session-start.test.ts index e8c2c09fb..7b323323a 100644 --- a/packages/agent-core-v2/test/skill/plugin-session-start.test.ts +++ b/packages/agent-core-v2/test/skill/plugin-session-start.test.ts @@ -74,7 +74,7 @@ function sessionStartRuntime(input: { }); ctx.configure(); if (input.history !== undefined) { - ctx.context.spliceHistory(0, 0, input.history); + ctx.context.splice(0, 0, input.history); } return { ctx, warnings }; } @@ -84,10 +84,17 @@ async function injectDynamic(ctx: ReturnType): Promise { } function lastReminder(ctx: ReturnType): string { - const last = ctx.context.getHistory().findLast((message) => message.role === 'user'); + const last = ctx.context.get().findLast((message) => message.role === 'user'); return last?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; } +function pluginSessionStartMessages(ctx: ReturnType) { + return ctx.context.get().filter( + (message) => + message.origin?.kind === 'injection' && message.origin.variant === 'plugin_session_start', + ); +} + describe('plugin session-start dynamic injection', () => { it('injects one block per declared sessionStart on first call', async () => { const { ctx } = sessionStartRuntime({ @@ -109,7 +116,7 @@ describe('plugin session-start dynamic injection', () => { expect(text).toContain('TodoList'); expect(text).toContain('body of skill'); expect(text).toContain(''); - expect(ctx.context.getHistory().at(-1)?.origin).toEqual({ + expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'plugin_session_start', }); @@ -139,7 +146,7 @@ describe('plugin session-start dynamic injection', () => { await injectDynamic(ctx); await injectDynamic(ctx); - expect(ctx.context.getHistory()).toHaveLength(1); + expect(pluginSessionStartMessages(ctx)).toHaveLength(1); }); it('does not re-inject when a replayed history already contains plugin sessionStart', async () => { @@ -158,7 +165,7 @@ describe('plugin session-start dynamic injection', () => { await injectDynamic(ctx); - expect(ctx.context.getHistory()).toHaveLength(1); + expect(pluginSessionStartMessages(ctx)).toHaveLength(1); }); it('skips a sessionStart whose skill is not registered and warns', async () => { @@ -188,7 +195,7 @@ describe('plugin session-start dynamic injection', () => { await injectDynamic(ctx); - expect(ctx.context.getHistory()).toEqual([]); + expect(ctx.context.get()).toEqual([]); }); it('resolves sessionStart skills by plugin identity when names collide', async () => {