diff --git a/.changeset/plugin-install-from-github.md b/.changeset/plugin-install-from-github.md new file mode 100644 index 00000000000..28e50602c23 --- /dev/null +++ b/.changeset/plugin-install-from-github.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Install plugins directly from GitHub repository URLs, and surface each install's origin and trust level (kimi-official, curated, third-party) in the plugin manager. diff --git a/.changeset/restrict-plugin-trust-badges.md b/.changeset/restrict-plugin-trust-badges.md new file mode 100644 index 00000000000..fbccf1f9135 --- /dev/null +++ b/.changeset/restrict-plugin-trust-badges.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/kimi-code": patch +--- + +Restrict plugin trust badges to Kimi-hosted plugin CDN URL patterns. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 28ccf8bc0eb..3bd878b0837 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -109,6 +109,7 @@ export interface SlashCommandHost { // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; + showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme applyTheme(theme: Theme, resolved?: ResolvedTheme): void; diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 929afce93b5..4a9423508bf 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -19,6 +19,7 @@ import { } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; +import { formatPluginSourceLabel } from '../utils/plugin-source-label'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; import type { SlashCommandHost } from './dispatch'; @@ -61,7 +62,14 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins install '); return; } - await installPluginFromSource(host, source); + const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); + try { + await installPluginFromSource(host, source); + spinner.stop({ ok: true, label: `Install finished — see details below.` }); + } catch (error) { + spinner.stop({ ok: false, label: `Install failed: ${formatErrorMessage(error)}` }); + throw error; + } return; } if (sub === 'marketplace') { @@ -382,19 +390,35 @@ async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { - const summary = await host.requireSession().installPlugin( + const session = host.requireSession(); + const beforeList = await session.listPlugins(); + const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), ); + showPluginInstallResult(host, beforeList, summary, options); +} + +function showPluginInstallResult( + host: SlashCommandHost, + beforeList: readonly PluginSummary[], + summary: PluginSummary, + options?: { + readonly successNotice?: 'marketplace'; + }, +): void { + const previous = beforeList.find((entry) => entry.id === summary.id); const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; const mcpHint = summary.mcpServerCount > 0 ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` : ''; - const installVerb = options?.successNotice === 'marketplace' ? 'Installed or updated' : 'Installed'; + const action = describeInstallAction(previous, summary); host.showStatus( - `${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`, + `${action} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`, ); if (options?.successNotice === 'marketplace') { host.showNotice( @@ -404,6 +428,37 @@ async function installPluginFromSource( } } +function describeInstallAction( + previous: PluginSummary | undefined, + next: PluginSummary, +): string { + const sourceLabel = formatPluginSourceLabel(next); + const versionFromTo = (prev?: string, cur?: string): string => { + if (prev === undefined || prev === cur) return cur === undefined ? '' : ` ${cur}`; + return ` ${prev} → ${cur ?? '-'}`; + }; + if (previous === undefined) { + return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`; + } + if (sourceIdentity(previous) !== sourceIdentity(next)) { + const prevSourceLabel = formatPluginSourceLabel(previous); + return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`; + } + return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`; +} + +function sourceIdentity(plugin: PluginSummary): string { + if (plugin.source === 'github' && plugin.github !== undefined) { + return `github:${plugin.github.owner}/${plugin.github.repo}`; + } + return plugin.source; +} + +function truncateForStatus(input: string): string { + const max = 80; + return input.length > max ? `${input.slice(0, max - 1)}…` : input; +} + async function reloadPlugins(host: SlashCommandHost): Promise { const summary = await host.requireSession().reloadPlugins(); const line = `Reload: +${summary.added.length} -${summary.removed.length}` + diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index 4ee027b95c1..f3600b5c2c8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -10,6 +10,7 @@ import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-a import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; +import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; import type { PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; @@ -480,7 +481,9 @@ function overviewPluginDescription(plugin: PluginSummary): string { ? ` · MCP ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount}` : ''; const diagnostics = plugin.hasErrors ? ' · diagnostics available' : ''; - return `id ${plugin.id} · ${skills}${mcp}${state}${diagnostics}`; + const source = ` · ${formatPluginSourceLabel(plugin)}`; + const trust = ` · ${pluginTrustLabel(plugin)}`; + return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`; } function pluginStatus(plugin: PluginSummary): string { @@ -509,7 +512,7 @@ function buildMarketplaceItems( kind: 'plugin', label: entry.displayName, status: installedIds.has(entry.id) ? 'installed' : installStatus(entry), - description: marketplaceEntryDescription(entry, installedIds.has(entry.id)), + description: marketplaceEntryDescription(entry), })); items.push({ value: 'back', @@ -553,8 +556,7 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined { return item.value.slice(MCP_SERVER_PREFIX.length); } -function marketplaceEntryDescription(entry: PluginMarketplaceEntry, installed: boolean): string { - const action = installed ? 'Enter/Space update' : 'Enter/Space install'; +function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string { const tier = marketplaceTierLabel(entry.tier); const description = entry.description ?? tier; const version = entry.version !== undefined ? ` · v${entry.version}` : ''; @@ -563,7 +565,7 @@ function marketplaceEntryDescription(entry: PluginMarketplaceEntry, installed: b ? ` · ${entry.keywords.join(', ')}` : ''; const tierSuffix = entry.description !== undefined ? ` · ${tier}` : ''; - return `${action} · ${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; + return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; } function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { diff --git a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts index e7eb86c6d30..2158aecd099 100644 --- a/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/plugins-status-panel.ts @@ -2,6 +2,14 @@ import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; import type { ColorPalette } from '../../theme/colors'; +import { + CURATED_BADGE, + OFFICIAL_BADGE, + THIRD_PARTY_BADGE, + type PluginTrustLabel, + formatPluginSourceLabel, + pluginTrustLabel, +} from '../../utils/plugin-source-label'; export interface PluginsListPanelInput { readonly colors: ColorPalette; @@ -12,6 +20,7 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st const muted = chalk.hex(input.colors.textDim); const value = chalk.hex(input.colors.text); const success = chalk.hex(input.colors.success); + const primary = chalk.hex(input.colors.primary); const warning = chalk.hex(input.colors.warning); if (input.plugins.length === 0) { return [ @@ -20,13 +29,22 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st value('Run /plugins to install one.'), ]; } + const renderTrustBadge = (label: PluginTrustLabel): string => { + if (label === 'official') return success(`[${OFFICIAL_BADGE}]`); + if (label === 'curated') return primary(`[${CURATED_BADGE}]`); + return muted(`[${THIRD_PARTY_BADGE}]`); + }; const lines: string[] = []; for (const plugin of input.plugins) { const enabled = plugin.enabled ? success('enabled') : muted('disabled'); const state = plugin.state === 'ok' ? '' : ` [${plugin.state}]`; const version = plugin.version ?? '-'; const diagnostics = plugin.hasErrors ? warning(' | diagnostics: see /plugins info') : ''; - lines.push(`${value(plugin.displayName)} (${muted(plugin.id)}) ${muted(version)} | ${enabled}${state}`); + const sourceTag = muted(`[${formatPluginSourceLabel(plugin)}]`); + const trustBadge = ` ${renderTrustBadge(pluginTrustLabel(plugin))}`; + lines.push( + `${value(plugin.displayName)} (${muted(plugin.id)}) ${muted(version)} ${sourceTag}${trustBadge} | ${enabled}${state}`, + ); const mcp = plugin.mcpServerCount > 0 ? ` | ${plugin.enabledMcpServerCount}/${plugin.mcpServerCount} mcp` @@ -36,6 +54,7 @@ export function buildPluginsListLines(input: PluginsListPanelInput): readonly st return lines; } + export interface PluginsInfoPanelInput { readonly colors: ColorPalette; readonly info: PluginInfo; @@ -48,14 +67,37 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st const success = chalk.hex(input.colors.success); const warning = chalk.hex(input.colors.warning); const error = chalk.hex(input.colors.error); + const primary = chalk.hex(input.colors.primary); const status = info.enabled ? success('enabled') : muted('disabled'); + const trustLine = (() => { + const label = pluginTrustLabel(info); + if (label === 'official') { + return `${muted('Trust:')} ${success(OFFICIAL_BADGE)} ${muted('(Kimi-built and -maintained)')}`; + } + if (label === 'curated') { + return `${muted('Trust:')} ${primary(CURATED_BADGE)} ${muted('(Kimi-reviewed, upstream-maintained)')}`; + } + return `${muted('Trust:')} ${muted(THIRD_PARTY_BADGE)}`; + })(); const lines: string[] = [ `${value(info.displayName)} (${muted(info.id)}) ${muted(info.version ?? '')}`.trim(), `${muted('Status:')} ${status} | ${muted('state:')} ${stateText(info.state, input.colors)}`, + trustLine, `${muted('Source:')} ${value(info.source)}`, `${muted('Root:')} ${value(info.root)}`, ]; + if (info.source === 'github' && info.github !== undefined) { + const refLabel = `${info.github.ref.kind}:${info.github.ref.value}`; + lines.push(`${muted('GitHub:')} ${value(`${info.github.owner}/${info.github.repo}`)} ${muted(`@${refLabel}`)}`); + if (info.github.installedSha !== undefined) { + lines.push(`${muted('Installed SHA:')} ${value(info.github.installedSha)}`); + } + } if (info.originalSource !== undefined) lines.push(`${muted('Original source:')} ${value(info.originalSource)}`); + lines.push(`${muted('Installed at:')} ${value(info.installedAt)}`); + if (info.updatedAt !== undefined && info.updatedAt !== info.installedAt) { + lines.push(`${muted('Last updated:')} ${value(info.updatedAt)}`); + } if (info.manifestPath !== undefined) { const kindSuffix = info.manifestKind !== undefined ? ` ${muted(`(${info.manifestKind})`)}` : ''; lines.push(`${muted('Manifest:')} ${value(info.manifestPath)}${kindSuffix}`); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index db5bf7b8a64..68258aaa79f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1309,6 +1309,10 @@ export class KimiTUI { } showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { + return this.showProgressSpinner(label); + } + + showProgressSpinner(label: string): LoginProgressSpinnerHandle { const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); this.state.transcriptContainer.addChild(new Spacer(1)); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 7d18bead8ec..fe73a884bfb 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -177,3 +177,5 @@ export interface PendingExit { export interface LoginProgressSpinnerHandle { stop(opts: { ok: boolean; label: string }): void; } + +export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts new file mode 100644 index 00000000000..eaddeae6500 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -0,0 +1,61 @@ +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +export const OFFICIAL_BADGE = 'official'; +export const CURATED_BADGE = 'curated'; +export const THIRD_PARTY_BADGE = 'third-party'; + +export type PluginTrustLabel = 'official' | 'curated' | 'third-party'; + +/** + * Human-readable provenance label for a plugin, suitable for inline display + * in `/plugins` overviews and lists. + * + * - github source → `github /@` + * - zip-url with parseable URL → `via ` + * - everything else → raw source kind (`local-path`, `zip-url`) + */ +export function formatPluginSourceLabel(plugin: PluginSummary): string { + if (plugin.source === 'github' && plugin.github !== undefined) { + return `github ${plugin.github.owner}/${plugin.github.repo}@${plugin.github.ref.value}`; + } + if (plugin.source === 'zip-url' && plugin.originalSource !== undefined) { + const host = hostFromUrl(plugin.originalSource); + if (host !== undefined) return `via ${host}`; + } + return plugin.source; +} + +/** + * Returns one of three trust labels for a plugin. Only Kimi-hosted plugin zip + * paths receive official or curated badges. Everything else is third-party. + */ +export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { + if (plugin.source !== 'zip-url' || plugin.originalSource === undefined) { + return 'third-party'; + } + try { + const url = new URL(plugin.originalSource); + if (url.protocol !== 'https:' || url.hostname !== 'code.kimi.com') { + return 'third-party'; + } + if (url.pathname.startsWith('/kimi-code/plugins/official/')) { + return 'official'; + } + if (url.pathname.startsWith('/kimi-code/plugins/curated/')) { + return 'curated'; + } + return 'third-party'; + } catch { + return 'third-party'; + } +} + +function hostFromUrl(raw: string): string | undefined { + try { + const url = new URL(raw); + if (url.port.length > 0) return `${url.hostname}:${url.port}`; + return url.hostname; + } catch { + return undefined; + } +} diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 7e3a810600f..078feceda15 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -10,6 +10,7 @@ import { type PluginRemoveConfirmResult, } from '#/tui/components/dialogs/plugins-selector'; import { darkColors } from '#/tui/theme/colors'; +import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; const ANSI_SGR = /\[[0-9;]*m/g; const SGR_SEQUENCE = String.raw`\[[0-9;]*m`; @@ -43,6 +44,57 @@ function dangerShortcut(text: string): string { } describe('plugins selector dialogs', () => { + it('trusts only built-in Kimi CDN plugin paths', () => { + expect(pluginTrustLabel({ + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe('official'); + expect(pluginTrustLabel({ + id: 'superpowers', + displayName: 'Superpowers', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip', + })).toBe('curated'); + expect(pluginTrustLabel({ + id: 'demo', + displayName: 'Demo', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/demo.zip', + })).toBe('third-party'); + expect(pluginTrustLabel({ + id: 'local', + displayName: 'Local', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hasErrors: false, + source: 'local-path', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/local', + })).toBe('third-party'); + }); + it('renders installed plugins as selectable overview entries', () => { const onSelect = vi.fn(); const picker = new PluginsOverviewSelectorComponent({ @@ -57,6 +109,7 @@ describe('plugins selector dialogs', () => { mcpServerCount: 1, enabledMcpServerCount: 1, hasErrors: false, + source: 'local-path', }, ], colors: darkColors, @@ -110,7 +163,7 @@ describe('plugins selector dialogs', () => { expect(out).toContain('Marketplace (1)'); expect(out).toContain('? Superpowers install v5.1.0'); expect(out).toContain( - `Enter/Space install ${MID} Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, + `Workflow skills ${MID} id superpowers ${MID} v5.1.0 ${MID} Curated plugin ${MID} workflow`, ); expect(raw).toContain(primaryShortcut('Enter')); expect(raw).toContain(primaryShortcut('Space')); @@ -143,7 +196,7 @@ describe('plugins selector dialogs', () => { const out = picker.render(120).map(strip).join('\n'); expect(out).toContain('? Superpowers installed'); - expect(out).toContain(`Enter/Space update ${MID} Plugin ${MID} id superpowers`); + expect(out).toContain(`Plugin ${MID} id superpowers`); picker.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ @@ -166,6 +219,7 @@ describe('plugins selector dialogs', () => { mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', }, ], colors: darkColors, @@ -196,6 +250,7 @@ describe('plugins selector dialogs', () => { mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', }, ], colors: darkColors, @@ -222,6 +277,7 @@ describe('plugins selector dialogs', () => { mcpServerCount: 1, enabledMcpServerCount: 1, hasErrors: false, + source: 'local-path', }, ], colors: darkColors, @@ -248,6 +304,7 @@ describe('plugins selector dialogs', () => { enabledMcpServerCount: 1, hasErrors: false, source: 'local-path', + installedAt: '2026-05-29T00:00:00.000Z', root: '/plugins/kimi-datasource', manifest: undefined, mcpServers: [ @@ -297,6 +354,7 @@ describe('plugins selector dialogs', () => { mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', }, ], selectedId: 'kimi-datasource', diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index ce198f28340..1cd6ace3524 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -11,6 +11,7 @@ import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; +import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { PluginMcpSelectorComponent, @@ -26,12 +27,12 @@ import { runModelSelector, } from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; +import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, promptFeedbackInput: vi.fn() }; }); -import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -1446,6 +1447,44 @@ describe('KimiTUI message flow', () => { }); }); + it('installs default marketplace entries through plain install', async () => { + const originalFetch = globalThis.fetch; + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + plugins: [ + { + id: 'kimi-datasource', + tier: 'official', + displayName: 'Kimi Datasource', + description: 'Datasource plugin', + source: './official/kimi-datasource.zip', + }, + ], + })))); + const session = makeSession(); + const { driver } = await makeDriver(session); + + try { + driver.handleUserInput('/plugins marketplace'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginMarketplaceSelectorComponent, + ); + }); + const picker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; + picker.handleInput(' '); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith( + 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); + }); + expect(globalThis.fetch).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL); + } finally { + vi.stubGlobal('fetch', originalFetch); + } + }); + it('toggles plugins from the overview with space', async () => { let enabled = true; const session = makeSession({ diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 9a0944d8f61..3237651650e 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -23,7 +23,7 @@ Most users only need the interactive manager. You can also use these slash comma | --- | --- | | `/plugins` | Open the interactive plugin manager. | | `/plugins list` | List installed plugins. | -| `/plugins install ` | Install from a local directory (relative paths and `~/` supported) or a zip URL. | +| `/plugins install ` | Install from a local directory (relative paths and `~/` supported), a zip URL, or a GitHub repository URL. | | `/plugins marketplace [source]` | Browse the official marketplace; optionally pass a marketplace JSON path or URL. | | `/plugins info ` | Show plugin details and diagnostics; opens the manager when `` is omitted. | | `/plugins ` | Show details for a plugin; same as `/plugins info `. | @@ -36,6 +36,10 @@ Most users only need the interactive manager. You can also use these slash comma For general slash command behavior, see [Slash commands](../reference/slash-commands.md). +GitHub URLs accept four forms. The bare URL `https://github.com//` installs the repository's latest GitHub release; if the repo has no release, the default branch is installed instead. `https://github.com///tree/` installs a specific branch, tag, or short commit SHA. `https://github.com///releases/tag/` and `https://github.com///commit/` pin to an explicit tag or commit. Network calls go to `github.com` redirects and `codeload.github.com` archive downloads only; `api.github.com` is not used. + +The plugin manager shows each install's source and a trust badge. `kimi-official` marks plugin zips downloaded from `https://code.kimi.com/kimi-code/plugins/official/`; `curated` marks plugin zips downloaded from `https://code.kimi.com/kimi-code/plugins/curated/`. `third-party` marks anything else, including GitHub installs, local directories, custom marketplace sources, and other URLs. + Kimi Code CLI currently installs plugins per user. Records are stored under `$KIMI_CODE_HOME/plugins/` and apply across all projects. Project-local, repository-shared, admin-managed, and `--scope` installs are not supported yet. Plugin changes apply to new sessions only. After installing, enabling, disabling, removing, or reloading a plugin, or changing an MCP server toggle, start a fresh session with `/new`. The current session is not updated; new skills, session-start behavior, and MCP servers load only in new sessions. diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index 3145d71f42b..379fc9b9e32 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -23,7 +23,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元。一个 pl | --- | --- | | `/plugins` | 打开交互式 plugin 管理器。 | | `/plugins list` | 列出已安装 plugins。 | -| `/plugins install ` | 从本地目录(支持相对路径和 `~/`)或 zip URL 安装。 | +| `/plugins install ` | 从本地目录(支持相对路径和 `~/`)、zip URL 或 GitHub 仓库 URL 安装。 | | `/plugins marketplace [source]` | 浏览官方 marketplace;可选传入 marketplace JSON 的路径或 URL。 | | `/plugins info ` | 查看 plugin 详情和 diagnostics;省略 `` 时打开管理器。 | | `/plugins ` | 查看指定 plugin 详情,等同于 `/plugins info `。 | @@ -36,6 +36,10 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元。一个 pl 斜杠命令的通用行为见 [斜杠命令](../reference/slash-commands.md)。 +GitHub URL 支持四种形式。裸 URL `https://github.com//` 会安装该仓库最新的 GitHub release;仓库没有 release 时回落到默认分支。`https://github.com///tree/` 用于安装指定分支、tag 或短 commit SHA。`https://github.com///releases/tag/` 和 `https://github.com///commit/` 用于钉死具体的 tag 或 commit。网络请求只走 `github.com` 重定向和 `codeload.github.com` 下载,**不会**调用 `api.github.com`。 + +Plugin 管理器会展示每个安装的来源以及一个信任徽章。`kimi-official` 表示 plugin zip 来自 `https://code.kimi.com/kimi-code/plugins/official/`;`curated` 表示 plugin zip 来自 `https://code.kimi.com/kimi-code/plugins/curated/`。`third-party` 表示其它所有情况,包括 GitHub 安装、本地目录、自定义 marketplace source 和其它 URL。 + Kimi Code CLI 目前按用户安装 plugins,记录在 `$KIMI_CODE_HOME/plugins/` 下,对所有项目生效。暂不支持项目级、仓库级、管理员分发,以及带 `--scope` 的安装方式。 Plugin 变更只对新会话生效。安装、启用/禁用、移除、重载 plugin,或修改 MCP server 开关后,需要通过 `/new` 开启新会话;当前会话不会更新,新的 Skills、会话启动行为和 MCP servers 只会在新会话中加载。 diff --git a/packages/agent-core/src/plugin/github-resolver.ts b/packages/agent-core/src/plugin/github-resolver.ts new file mode 100644 index 00000000000..4e7c867b7d7 --- /dev/null +++ b/packages/agent-core/src/plugin/github-resolver.ts @@ -0,0 +1,155 @@ +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 (60/hour per egress IP) is + * shared with the user's browser, gh CLI, IDE integrations, etc., and + * first-time install failing because some other tool burned the budget is + * unacceptable for our UX. + * + * Strategy: + * 1. Explicit ref → straight to codeload, zero network calls beforehand. + * 2. Bare URL: + * a. GET `github.com/{owner}/{repo}/releases/latest` with manual + * redirect. 302 → extract tag from `Location` header. This is a + * documented-by-behavior GitHub UI route used by Homebrew, gh, etc. + * It is *not* part of the API quota. + * b. 404 or 302 to `/releases` (fork without own releases) → fall back + * to `codeload.github.com/{o}/{r}/zip/HEAD`, which streams the + * default branch tip without us needing to know its name. + * c. codeload 404 on HEAD → the repo itself does not exist. + */ +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 }, + }; + } + + // No release we could resolve. Fall back to the default branch via codeload. + 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' }, + }; +} + +/** + * Returns: + * - tag string → a real latest release was advertised + * - undefined → the repo definitively has no own latest release; + * caller should fall back to the default branch + * + * Throws on any unexpected HTTP status (5xx, 403, 429, ...). We deliberately + * do *not* fold those into "no release" — silently installing the default + * branch on a transient GitHub error is worse than failing loudly: the user + * would end up with content different from what they asked for and we would + * not tell them. + */ +async function tryResolveLatestReleaseTag( + owner: string, + repo: string, +): Promise { + const url = `https://github.com/${owner}/${repo}/releases/latest`; + const resp = await fetch(url, { redirect: 'manual' }); + + // Definitive "no own latest release". Distinct from transient errors. + 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; + + // Forks without their own releases redirect to bare `/releases` (the page + // that lists tags inherited from upstream) instead of a specific tag URL. + // Treat that as "no own latest release" and fall back to the default branch. + 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}`; + // For a ref we confirmed is a tag (came from /releases/tag/...), use the + // explicit refs/tags/ path so the download is unambiguous even if a branch + // with the same name exists in the repo. + if (ref.kind === 'tag') return `${base}/refs/tags/${encoded}`; + // For a `branch`-kind ref we cannot tell whether the user-typed value names + // a branch or a tag (e.g. `/tree/v5.1.0`). Use codeload's short form to let + // the GitHub backend resolve it the same way `github.com/.../tree/` does. + return `${base}/${encoded}`; +} + +/** + * Percent-encode a ref name for safe interpolation into a codeload URL path. + * + * Git permits characters in ref names that have special meaning in URLs. + * The reviewer-flagged case is `#`: a valid Git tag character (e.g. a release + * named `release#1`) but a URL fragment delimiter. Pasted naively into + * `…/refs/tags/release#1`, the `#1` is parsed as a fragment and the HTTP + * request reaches the server as `…/refs/tags/release` — which 404s, or worse, + * delivers a different ref. + * + * Refs may also legitimately contain `/` (a branch named `feat/foo`, or a + * tag named `series/v1`). We must preserve those as real path separators. + * So: split on `/`, percent-encode each segment, and rejoin. + */ +function encodeCodeloadRefPath(value: string): string { + return value.split('/').map(encodeURIComponent).join('/'); +} diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index e42eb7317ff..a1badcc31c6 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -5,12 +5,14 @@ import path from 'node:path'; import type { McpServerConfig } from '../config/schema'; import { discoverSkills, type SkillRoot } from '../skill'; import { downloadZip, extractZip } from './archive'; +import { resolveGithubSource } from './github-resolver'; import { parseManifest, type ParsedManifestResult } from './manifest'; import { readInstalled, writeInstalled, type InstalledRecord } from './store'; import { resolveInstallSource } from './source'; import { type EnabledPluginSessionStart, type PluginCapabilityState, + type PluginGithubMetadata, type PluginInfo, type PluginMcpServerInfo, type PluginRecord, @@ -62,6 +64,7 @@ export class PluginManager { let sourceType: PluginSource; let parsed: ParsedManifestResult; let id: string; + let github: PluginGithubMetadata | undefined; if (resolved.kind === 'local-path') { const sourceRoot = await normalizeInstallRoot(resolved.path); @@ -76,15 +79,30 @@ export class PluginManager { normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, sourceRoot); parsed = await parseManifest(normalizedRoot); } else { - // zip-url - const buffer = await downloadZip(resolved.path); + let zipUrl: string; + if (resolved.kind === 'github') { + const githubResolution = await resolveGithubSource(resolved); + zipUrl = githubResolution.tarballUrl; + originalSource = source.trim(); + sourceType = 'github'; + github = { + owner: resolved.owner, + repo: resolved.repo, + ref: githubResolution.ref, + }; + } else { + zipUrl = resolved.path; + originalSource = resolved.path; + sourceType = 'zip-url'; + } + const buffer = await downloadZip(zipUrl); const tmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-')); try { const detectedRoot = await extractZip(buffer, tmpDir); parsed = await parseManifest(detectedRoot); if (parsed.manifest === undefined) { const msg = parsed.diagnostics.find((d) => d.severity === 'error')?.message ?? 'no manifest'; - throw new Error(`Cannot install plugin from ${resolved.path}: ${msg}`); + throw new Error(`Cannot install plugin from ${originalSource}: ${msg}`); } id = normalizePluginId(parsed.manifest.name); normalizedRoot = await copyPluginToManagedRoot(this.kimiHomeDir, id, detectedRoot); @@ -92,8 +110,6 @@ export class PluginManager { } finally { await rm(tmpDir, { recursive: true, force: true }); } - originalSource = resolved.path; - sourceType = 'zip-url'; } if (parsed.manifest === undefined) { @@ -112,6 +128,7 @@ export class PluginManager { originalSource, source: sourceType, capabilities: existing?.capabilities, + github, parsed, }); this.records.set(id, record); @@ -241,6 +258,7 @@ export class PluginManager { updatedAt: record.updatedAt, originalSource: record.originalSource, capabilities: record.capabilities, + github: record.github, })); await writeInstalled(this.kimiHomeDir, { version: 1, plugins: installed }); } @@ -255,6 +273,7 @@ export class PluginManager { updatedAt: entry.updatedAt, originalSource: entry.originalSource, capabilities: entry.capabilities, + github: entry.github, source: entry.source, parsed, }); @@ -306,6 +325,7 @@ async function recordFrom(input: { updatedAt?: string; originalSource?: string; capabilities?: PluginCapabilityState; + github?: PluginGithubMetadata; source?: PluginSource; parsed: ParsedManifestResult; }): Promise { @@ -321,6 +341,7 @@ async function recordFrom(input: { updatedAt: input.updatedAt, originalSource: input.originalSource, capabilities: input.capabilities, + github: input.github, skillCount: await countDiscoveredPluginSkills(input.id, parsed.manifest), manifest: parsed.manifest, manifestKind: parsed.manifestKind, @@ -342,6 +363,9 @@ function recordToSummary(record: PluginRecord): PluginSummary { mcpServerCount: Object.keys(record.manifest?.mcpServers ?? {}).length, enabledMcpServerCount: pluginMcpServersInfo(record).filter((server) => server.enabled).length, hasErrors: record.diagnostics.some((d) => d.severity === 'error'), + source: record.source, + originalSource: record.originalSource, + github: record.github, }; } @@ -362,9 +386,9 @@ async function countDiscoveredPluginSkills( function recordToInfo(record: PluginRecord): PluginInfo { return { ...recordToSummary(record), - source: record.source, root: record.root, - originalSource: record.originalSource, + installedAt: record.installedAt, + updatedAt: record.updatedAt, manifestKind: record.manifestKind, manifestPath: record.manifestPath, manifest: record.manifest, diff --git a/packages/agent-core/src/plugin/source.ts b/packages/agent-core/src/plugin/source.ts index d964a16b532..38a02ece44d 100644 --- a/packages/agent-core/src/plugin/source.ts +++ b/packages/agent-core/src/plugin/source.ts @@ -1,16 +1,26 @@ import path from 'node:path'; -export type InstallSource = +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: 'zip-url'; path: string } + | { kind: 'github'; owner: string; repo: string; ref?: GithubRef }; -export interface ResolvedSource { - readonly kind: 'local-path' | 'zip-url'; - readonly path: string; -} +// Kept as a back-compat alias for downstream code that imported the old name. +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 }; } @@ -19,3 +29,84 @@ export function resolveInstallSource(source: string): ResolvedSource { } 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) { + // `url.pathname` preserves percent-encoding (e.g. `release%231`). Decode + // each segment so the stored ref value is the human-readable Git ref name. + // The resolver re-encodes when building the codeload URL. + const refValue = decodeRefSegments(rest.slice(1)); + // We cannot tell branch from tag at parse time. For SHA-shaped values use + // kind: 'sha'; otherwise label as 'branch'. The resolver compensates by + // using codeload's short-form URL for 'branch' kinds, so codeload itself + // picks branch-or-tag — matching how `/tree/` resolves in the GitHub UI. + 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) { + // Recognize the canonical "this is a specific release" URL form. Earlier + // versions rejected it and pointed users at /tree/, but /tree/ + // could not be parsed as a tag (only branch), which produced a 404 when + // codeload was asked for refs/heads/. + const tag = decodeRefSegments(rest.slice(2)); + return { kind: 'github', owner, repo, ref: { kind: 'tag', value: tag } }; + } + + if (head === 'commit' && rest.length >= 2) { + // Mirror the /releases/tag/ change for symmetry: a commit URL pinpoints a + // SHA, so accept it directly instead of bouncing users to /tree/. + const sha = decodeRefSegments(rest.slice(1)); + return { kind: 'github', owner, repo, ref: { kind: 'sha', value: sha } }; + } + + // /archive/refs/{heads,tags}/X.zip and any other path — fall through to zip-url. + return undefined; +} + +/** + * Join path segments and percent-decode them into a single ref name. + * + * `URL.pathname` keeps `%xx` sequences as-is (e.g. `release%231`), but + * downstream code treats the ref value as a raw Git ref. Decoding here keeps + * one canonical representation: human-readable in storage and display, and + * re-encoded by the resolver when it builds a codeload URL. + * + * Malformed percent-encoding (`%ZZ`) is tolerated: we keep the raw segments + * so the user sees a meaningful error downstream rather than a parse crash. + */ +function decodeRefSegments(segments: readonly string[]): string { + return segments + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join('/'); +} diff --git a/packages/agent-core/src/plugin/store.ts b/packages/agent-core/src/plugin/store.ts index 029cfcaae86..1a2f74672db 100644 --- a/packages/agent-core/src/plugin/store.ts +++ b/packages/agent-core/src/plugin/store.ts @@ -1,7 +1,11 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import type { PluginCapabilityState, PluginSource } from './types'; +import type { + PluginCapabilityState, + PluginGithubMetadata, + PluginSource, +} from './types'; const INSTALLED_REL = path.join('plugins', 'installed.json'); @@ -14,6 +18,7 @@ export interface InstalledRecord { readonly updatedAt?: string; readonly originalSource?: string; readonly capabilities?: PluginCapabilityState; + readonly github?: PluginGithubMetadata; } export interface InstalledFile { diff --git a/packages/agent-core/src/plugin/types.ts b/packages/agent-core/src/plugin/types.ts index f6d3942ae93..ad4fc30867a 100644 --- a/packages/agent-core/src/plugin/types.ts +++ b/packages/agent-core/src/plugin/types.ts @@ -61,9 +61,21 @@ export interface PluginMcpServerInfo { } export type PluginManifestKind = 'kimi-plugin-root' | 'kimi-plugin-dir'; -export type PluginSource = 'local-path' | 'zip-url'; +export type PluginSource = 'local-path' | 'zip-url' | 'github'; export type PluginState = 'ok' | 'error'; +export interface PluginGithubRef { + readonly kind: 'branch' | 'tag' | 'sha'; + readonly value: string; +} + +export interface PluginGithubMetadata { + readonly owner: string; + readonly repo: string; + readonly ref: PluginGithubRef; + readonly installedSha?: string; +} + export interface PluginRecord { readonly id: string; readonly root: string; @@ -74,6 +86,7 @@ export interface PluginRecord { readonly updatedAt?: string; readonly originalSource?: string; readonly capabilities?: PluginCapabilityState; + readonly github?: PluginGithubMetadata; readonly skillInstructions?: string; readonly skillCount: number; readonly manifest?: PluginManifest; @@ -93,12 +106,15 @@ export interface PluginSummary { readonly mcpServerCount: number; readonly enabledMcpServerCount: number; readonly hasErrors: boolean; + readonly source: PluginSource; + readonly originalSource?: string; + readonly github?: PluginGithubMetadata; } export interface PluginInfo extends PluginSummary { - readonly source: PluginSource; readonly root: string; - readonly originalSource?: string; + readonly installedAt: string; + readonly updatedAt?: string; readonly manifestKind?: PluginManifestKind; readonly manifestPath?: string; readonly manifest?: PluginManifest; diff --git a/packages/agent-core/test/plugin/github-resolver.test.ts b/packages/agent-core/test/plugin/github-resolver.test.ts new file mode 100644 index 00000000000..2b2f90593ce --- /dev/null +++ b/packages/agent-core/test/plugin/github-resolver.test.ts @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveGithubSource } from '../../src/plugin/github-resolver'; + +const REAL_FETCH = globalThis.fetch; + +interface MockResponse { + status: number; + body?: unknown; + headers?: Record; + statusText?: string; +} + +function mockSequence(queue: MockResponse[]): void { + const remaining = [...queue]; + globalThis.fetch = vi.fn(async () => { + const next = remaining.shift(); + if (next === undefined) throw new Error('mockFetch: queue exhausted'); + const body = next.body === undefined ? null : JSON.stringify(next.body); + return new Response(body, { + status: next.status, + statusText: next.statusText, + headers: next.headers, + }) as unknown as Response; + }) as typeof fetch; +} + +describe('resolveGithubSource', () => { + beforeEach(() => { + globalThis.fetch = REAL_FETCH; + }); + afterEach(() => { + globalThis.fetch = REAL_FETCH; + }); + + it('explicit branch-kind ref uses codeload short form (matches /tree/ semantics)', async () => { + const fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await resolveGithubSource({ + kind: 'github', + owner: 'wbxl2000', + repo: 'superpowers', + ref: { kind: 'branch', value: 'main' }, + }); + + expect(result).toEqual({ + tarballUrl: 'https://codeload.github.com/wbxl2000/superpowers/zip/main', + displayVersion: 'main', + ref: { kind: 'branch', value: 'main' }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('branch-kind ref carrying a tag value (e.g. /tree/v5.1.0) still resolves via short form', async () => { + // Reproduces the P1 reviewer caught: parser cannot distinguish branch from + // tag in `/tree/`, but codeload's short form resolves either. + const result = await resolveGithubSource({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'branch', value: 'v5.1.0' }, + }); + + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/obra/superpowers/zip/v5.1.0', + ); + }); + + it('explicit tag ref uses /refs/tags/ path', async () => { + const fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await resolveGithubSource({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'tag', value: 'v5.1.0' }, + }); + + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/obra/superpowers/zip/refs/tags/v5.1.0', + ); + }); + + it('explicit sha ref uses raw sha in the path', async () => { + const sha = '45b441d62b81b5f27d3bfd8700e04436cd4de5b3'; + const result = await resolveGithubSource({ + kind: 'github', + owner: 'wbxl2000', + repo: 'superpowers', + ref: { kind: 'sha', value: sha }, + }); + + expect(result.tarballUrl).toBe( + `https://codeload.github.com/wbxl2000/superpowers/zip/${sha}`, + ); + }); + + it('encodes URL-reserved characters in tag refs so codeload sees the full ref (P2 regression)', async () => { + // Git allows `#` in tag names. Without encoding, `#1` becomes a URL + // fragment and codeload only sees `refs/tags/release`. + const result = await resolveGithubSource({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'tag', value: 'release#1' }, + }); + + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/owner/repo/zip/refs/tags/release%231', + ); + // Sanity: parsing this URL should report the encoded form in the path, + // no fragment leakage. + const parsed = new URL(result.tarballUrl); + expect(parsed.hash).toBe(''); + expect(parsed.pathname.endsWith('release%231')).toBe(true); + }); + + it('encodes URL-reserved characters in branch refs too', async () => { + const result = await resolveGithubSource({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'feat#1' }, + }); + + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/owner/repo/zip/feat%231', + ); + }); + + it('preserves `/` as path separator when encoding multi-segment refs', async () => { + // A branch named `feat/has space` must encode the space but keep the `/`. + const result = await resolveGithubSource({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'feat/has space' }, + }); + + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/owner/repo/zip/feat/has%20space', + ); + }); + + it('bare URL: 302 with /releases/tag/X resolves to that tag', async () => { + mockSequence([ + { + status: 302, + 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('bare URL: 302 with url-encoded tag in path decodes correctly', async () => { + mockSequence([ + { + status: 302, + headers: { location: 'https://github.com/o/r/releases/tag/feat%2Frelease' }, + }, + ]); + + const result = await resolveGithubSource({ kind: 'github', owner: 'o', repo: 'r' }); + expect(result.ref).toEqual({ kind: 'tag', value: 'feat/release' }); + }); + + it('bare URL: latest release tag with `#` round-trips through to a properly encoded codeload URL (P2 regression)', async () => { + // GitHub redirects with the tag percent-encoded. We decode for storage, + // then must re-encode when building the codeload URL. + mockSequence([ + { + status: 302, + headers: { location: 'https://github.com/o/r/releases/tag/release%231' }, + }, + ]); + + const result = await resolveGithubSource({ kind: 'github', owner: 'o', repo: 'r' }); + + expect(result.ref).toEqual({ kind: 'tag', value: 'release#1' }); + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/o/r/zip/refs/tags/release%231', + ); + // Sanity: no fragment hijacking. + expect(new URL(result.tarballUrl).hash).toBe(''); + }); + + it('bare URL: 404 from /releases/latest falls back to codeload HEAD', async () => { + mockSequence([ + { status: 404 }, // releases/latest + { status: 200 }, // codeload HEAD probe + ]); + + const result = await resolveGithubSource({ + kind: 'github', + owner: 'wbxl2000', + repo: 'superpowers', + }); + + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/wbxl2000/superpowers/zip/HEAD', + ); + expect(result.displayVersion).toBe('HEAD'); + expect(result.ref).toEqual({ kind: 'branch', value: 'HEAD' }); + }); + + it('bare URL: 302 to /releases (fork with inherited tags but no own release) falls back to HEAD', async () => { + mockSequence([ + { + status: 302, + headers: { location: 'https://github.com/wbxl2000/superpowers/releases' }, + }, + { status: 200 }, // codeload HEAD probe + ]); + + const result = await resolveGithubSource({ + kind: 'github', + owner: 'wbxl2000', + repo: 'superpowers', + }); + + expect(result.tarballUrl).toBe( + 'https://codeload.github.com/wbxl2000/superpowers/zip/HEAD', + ); + expect(result.ref).toEqual({ kind: 'branch', value: 'HEAD' }); + }); + + it('bare URL: 404 on both /releases/latest and codeload HEAD ⇒ repo not found', async () => { + mockSequence([ + { status: 404 }, // releases/latest + { status: 404 }, // codeload HEAD probe + ]); + + await expect( + resolveGithubSource({ kind: 'github', owner: 'nobody', repo: 'nothing' }), + ).rejects.toThrow(/`nobody\/nothing` not found or not accessible/); + }); + + it('bare URL: 5xx on /releases/latest throws instead of silently falling back', async () => { + mockSequence([ + { status: 503, statusText: 'Service Unavailable' }, + ]); + + await expect( + resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }), + ).rejects.toThrow(/Could not look up latest release.*HTTP 503/); + }); + + it('bare URL: 429 (rate-limit-style) on /releases/latest throws, not falls back', async () => { + mockSequence([ + { status: 429, statusText: 'Too Many Requests' }, + ]); + + await expect( + resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }), + ).rejects.toThrow(/Could not look up latest release.*HTTP 429/); + }); + + it('bare URL: 403 (WAF/abuse-detection-style) on /releases/latest throws, not falls back', async () => { + mockSequence([ + { status: 403, statusText: 'Forbidden' }, + ]); + + await expect( + resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }), + ).rejects.toThrow(/Could not look up latest release.*HTTP 403/); + }); + + it('release-lookup error message hints at the /tree/ escape hatch', async () => { + mockSequence([ + { status: 502, statusText: 'Bad Gateway' }, + ]); + + await expect( + resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }), + ).rejects.toThrow(/\/tree\//); + }); + + it('does not call api.github.com at all on bare URL', async () => { + const calls: string[] = []; + globalThis.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' }, + }) as unknown as Response; + } + 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); + }); +}); diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts index 0f3ca038924..65644ff1843 100644 --- a/packages/agent-core/test/plugin/manager.test.ts +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import yazl from 'yazl'; import { PluginManager } from '../../src/plugin/manager'; @@ -651,8 +651,241 @@ describe('PluginManager', () => { await expect(manager.install(url)).rejects.toThrow(/manifest/i); }); + + it('install() from github URL resolves latest release and records github metadata', async () => { + const home = await makeKimiHome(); + const zipBuffer = await createZipBuffer([ + { + name: 'wbxl2000-superpowers-abc/kimi.plugin.json', + data: JSON.stringify({ name: 'gh-demo', version: '1.0.0' }), + }, + ]); + + using _ = mockGithubFetch({ + releaseTag: 'v1.0.0', + tarball: zipBuffer, + }); + + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const record = await manager.install('https://github.com/wbxl2000/superpowers'); + + expect(record.id).toBe('gh-demo'); + expect(record.source).toBe('github'); + expect(record.originalSource).toBe('https://github.com/wbxl2000/superpowers'); + expect(record.github).toEqual({ + owner: 'wbxl2000', + repo: 'superpowers', + ref: { kind: 'tag', value: 'v1.0.0' }, + }); + + const reloaded = new PluginManager({ kimiHomeDir: home }); + await reloaded.load(); + expect(reloaded.get('gh-demo')?.source).toBe('github'); + expect(reloaded.get('gh-demo')?.github?.ref).toEqual({ kind: 'tag', value: 'v1.0.0' }); + }); + + it('install() from /tree/ downloads via short form, not refs/heads/ (P1 regression)', async () => { + // A repo whose only ref `v5.1.0` is a tag (no branch by that name). The + // previous resolver wrote `zip/refs/heads/v5.1.0` and 404'd. Verify the + // mock now sees the short-form request `zip/v5.1.0`. + const home = await makeKimiHome(); + const zipBuffer = await createZipBuffer([ + { + name: 'obra-superpowers-v5.1.0/kimi.plugin.json', + data: JSON.stringify({ name: 'pin-tag-demo', version: '5.1.0' }), + }, + ]); + + let codeloadPath = ''; + const original = globalThis.fetch; + globalThis.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; + + try { + 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' }); + } finally { + globalThis.fetch = original; + } + }); + + it('install() from /releases/tag/ resolves precisely via refs/tags/', async () => { + const home = await makeKimiHome(); + const zipBuffer = await createZipBuffer([ + { + name: 'obra-superpowers-v5.1.0/kimi.plugin.json', + data: JSON.stringify({ name: 'pin-tag-demo', version: '5.1.0' }), + }, + ]); + + let codeloadPath = ''; + const original = globalThis.fetch; + globalThis.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; + + try { + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const record = await manager.install( + 'https://github.com/obra/superpowers/releases/tag/v5.1.0', + ); + // Explicit tag origin → kind is 'tag', URL uses refs/tags/ for + // disambiguation against same-named branches. + expect(codeloadPath).toBe('/obra/superpowers/zip/refs/tags/v5.1.0'); + expect(record.github?.ref).toEqual({ kind: 'tag', value: 'v5.1.0' }); + } finally { + globalThis.fetch = original; + } + }); + + it('install() from github /tree/ bypasses the GitHub API', async () => { + const home = await makeKimiHome(); + const zipBuffer = await createZipBuffer([ + { + name: 'wbxl2000-superpowers-main/kimi.plugin.json', + data: JSON.stringify({ name: 'gh-demo', version: '5.1.0' }), + }, + ]); + + let releaseLookups = 0; + using _ = 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' }); + }); + + 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' }, + }) as Awaited>; + + 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(); + + // Original CDN install. + const cdnZip = await createZipBuffer([ + { name: 'pkg/kimi.plugin.json', data: JSON.stringify({ name: 'superpowers', version: '5.0.0' }) }, + ]); + const cdnUrl = await serveOnce(cdnZip); + + 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); + + // Now migrate via GitHub URL. + const ghZip = await createZipBuffer([ + { name: 'pkg/kimi.plugin.json', data: JSON.stringify({ name: 'superpowers', version: '5.1.0' }) }, + ]); + using _ = mockGithubFetch({ + releaseTag: 'v5.1.0', + tarball: ghZip, + }); + 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); // preserved + expect(updated.installedAt).toBe(first.installedAt); // preserved + 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); + }); }); +interface MockGithubFetchOptions { + /** Tag name to advertise via the github.com/.../releases/latest redirect. */ + releaseTag?: string; + tarball: Buffer; + /** Optional hook to count requests against `github.com`. */ + onReleaseLookup?: () => void; +} + +function mockGithubFetch(options: MockGithubFetchOptions): { [Symbol.dispose](): void } { + const original = globalThis.fetch; + globalThis.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/')) { + // HEAD probe used by the no-release fallback path returns headers only. + 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; + return { + [Symbol.dispose]() { + globalThis.fetch = original; + }, + }; +} + async function createZipBuffer(entries: Array<{ name: string; data: string | Buffer }>): Promise { return new Promise((resolve, reject) => { const zipfile = new yazl.ZipFile(); diff --git a/packages/agent-core/test/plugin/source.test.ts b/packages/agent-core/test/plugin/source.test.ts index d379151af5c..12b76bc451d 100644 --- a/packages/agent-core/test/plugin/source.test.ts +++ b/packages/agent-core/test/plugin/source.test.ts @@ -30,4 +30,199 @@ describe('resolveInstallSource', () => { it('throws for empty string', () => { expect(() => resolveInstallSource('')).toThrow(/absolute path/i); }); + + describe('GitHub URL recognition', () => { + it('recognizes bare github URL', () => { + const result = resolveInstallSource('https://github.com/wbxl2000/superpowers'); + expect(result).toEqual({ + kind: 'github', + owner: 'wbxl2000', + repo: 'superpowers', + }); + }); + + it('recognizes www.github.com as a synonym', () => { + const result = resolveInstallSource('https://www.github.com/wbxl2000/superpowers'); + expect(result).toEqual({ + kind: 'github', + owner: 'wbxl2000', + repo: 'superpowers', + }); + }); + + it('strips trailing slash on bare URL', () => { + const result = resolveInstallSource('https://github.com/wbxl2000/superpowers/'); + expect(result).toEqual({ + kind: 'github', + owner: 'wbxl2000', + repo: 'superpowers', + }); + }); + + it('recognizes /tree/', () => { + const result = resolveInstallSource('https://github.com/obra/superpowers/tree/main'); + expect(result).toEqual({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'branch', value: 'main' }, + }); + }); + + it('recognizes /tree/ as branch (cannot distinguish without API)', () => { + const result = resolveInstallSource('https://github.com/obra/superpowers/tree/v5.1.0'); + expect(result).toEqual({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'branch', value: 'v5.1.0' }, + }); + }); + + it('recognizes /tree/ as sha', () => { + const result = resolveInstallSource('https://github.com/obra/superpowers/tree/45b441d'); + expect(result).toEqual({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'sha', value: '45b441d' }, + }); + }); + + it('recognizes /tree/ as sha', () => { + const sha = '45b441d62b81b5f27d3bfd8700e04436cd4de5b3'; + const result = resolveInstallSource(`https://github.com/obra/superpowers/tree/${sha}`); + expect(result).toEqual({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'sha', value: sha }, + }); + }); + + it('preserves slashes inside branch names under /tree/', () => { + const result = resolveInstallSource('https://github.com/owner/repo/tree/feat/foo-bar'); + expect(result).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'feat/foo-bar' }, + }); + }); + + it('strips trailing slash on /tree/', () => { + const result = resolveInstallSource('https://github.com/owner/repo/tree/main/'); + expect(result).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'main' }, + }); + }); + + it('drops query and fragment from /tree/', () => { + const result = resolveInstallSource('https://github.com/owner/repo/tree/main?x=1#y'); + expect(result).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'main' }, + }); + }); + + it('accepts /releases/tag/ as a tag-kind ref', () => { + const result = resolveInstallSource( + 'https://github.com/obra/superpowers/releases/tag/v5.1.0', + ); + expect(result).toEqual({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { kind: 'tag', value: 'v5.1.0' }, + }); + }); + + it('accepts /commit/ as a sha-kind ref', () => { + const result = resolveInstallSource( + 'https://github.com/obra/superpowers/commit/45b441d62b81b5f27d3bfd8700e04436cd4de5b3', + ); + expect(result).toEqual({ + kind: 'github', + owner: 'obra', + repo: 'superpowers', + ref: { + kind: 'sha', + value: '45b441d62b81b5f27d3bfd8700e04436cd4de5b3', + }, + }); + }); + + it('does not recognize /archive/refs/tags/X.zip as github source (falls through to zip-url)', () => { + const url = 'https://github.com/obra/superpowers/archive/refs/tags/v5.1.0.zip'; + const result = resolveInstallSource(url); + expect(result).toEqual({ kind: 'zip-url', path: url }); + }); + + it('does not recognize /archive/refs/heads/main.zip as github source (falls through to zip-url)', () => { + const url = 'https://github.com/obra/superpowers/archive/refs/heads/main.zip'; + const result = resolveInstallSource(url); + expect(result).toEqual({ kind: 'zip-url', path: url }); + }); + + it('treats http:// (non-https) github URL as plain zip-url', () => { + const url = 'http://github.com/wbxl2000/superpowers'; + const result = resolveInstallSource(url); + expect(result).toEqual({ kind: 'zip-url', path: url }); + }); + + it('percent-decodes %23 in /releases/tag/ so storage is human-readable', () => { + // Git allows `#` in tag names. GitHub UI URLs encode it as %23. + const result = resolveInstallSource( + 'https://github.com/owner/repo/releases/tag/release%231', + ); + expect(result).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'tag', value: 'release#1' }, + }); + }); + + it('percent-decodes /tree/ ref values', () => { + const result = resolveInstallSource( + 'https://github.com/owner/repo/tree/feat%231', + ); + expect(result).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'feat#1' }, + }); + }); + + it('preserves slashes when decoding multi-segment refs (e.g. feat/foo with %20 in middle)', () => { + const result = resolveInstallSource( + 'https://github.com/owner/repo/tree/feat/has%20space', + ); + expect(result).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'feat/has space' }, + }); + }); + + it('keeps malformed percent-encoding verbatim instead of crashing', () => { + // `%ZZ` is invalid; decodeURIComponent throws. Don't propagate. + const result = resolveInstallSource( + 'https://github.com/owner/repo/tree/bad%ZZname', + ); + expect(result).toEqual({ + kind: 'github', + owner: 'owner', + repo: 'repo', + ref: { kind: 'branch', value: 'bad%ZZname' }, + }); + }); + }); }); diff --git a/packages/agent-core/test/plugin/store.test.ts b/packages/agent-core/test/plugin/store.test.ts index 1b73a453734..a4900e52e9d 100644 --- a/packages/agent-core/test/plugin/store.test.ts +++ b/packages/agent-core/test/plugin/store.test.ts @@ -61,4 +61,60 @@ describe('plugin store', () => { await writeFile(path.join(home, 'plugins', 'installed.json'), '{ not json', 'utf8'); await expect(readInstalled(home)).rejects.toThrow(/parse/i); }); + + it('round-trips a github-sourced record', async () => { + const home = await makeKimiHome(); + const data: InstalledFile = { + version: 1, + plugins: [ + { + id: 'superpowers', + root: '/tmp/superpowers', + source: 'github', + enabled: true, + installedAt: '2026-05-29T12:00:00Z', + updatedAt: '2026-05-29T12:00:00Z', + originalSource: 'https://github.com/wbxl2000/superpowers/tree/main', + github: { + owner: 'wbxl2000', + repo: 'superpowers', + ref: { kind: 'branch', value: 'main' }, + installedSha: '45b441d62b81b5f27d3bfd8700e04436cd4de5b3', + }, + }, + ], + }; + await writeInstalled(home, data); + const result = await readInstalled(home); + expect(result).toEqual(data); + }); + + it('reads a legacy record without github field unchanged', async () => { + const home = await makeKimiHome(); + await writeInstalled(home, { version: 1, plugins: [] }); + await writeFile( + path.join(home, 'plugins', 'installed.json'), + JSON.stringify({ + version: 1, + plugins: [ + { + id: 'demo', + root: '/tmp/demo', + source: 'zip-url', + enabled: true, + installedAt: '2026-05-01T00:00:00Z', + originalSource: 'https://example.com/demo.zip', + }, + ], + }), + 'utf8', + ); + const result = await readInstalled(home); + expect(result.plugins).toHaveLength(1); + const record = result.plugins[0]; + expect(record).toBeDefined(); + expect(record?.id).toBe('demo'); + expect(record?.source).toBe('zip-url'); + expect((record as { github?: unknown } | undefined)?.github).toBeUndefined(); + }); }); diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index 4b019e58b92..f3068f2023a 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -34,6 +34,26 @@ describe('KimiCore plugin RPCs', () => { await expect(core.listPlugins({})).resolves.toEqual([]); }); + it('installPlugin ignores forged marketplace context from public RPC callers', async () => { + const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-')); + const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-')); + await writeFile( + path.join(pluginRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'demo', version: '1.0.0' }), + 'utf8', + ); + + const core = new KimiCore(async () => ({}) as never, { homeDir: home }); + await new Promise((r) => setImmediate(r)); + + const installed = await core.installPlugin({ + source: pluginRoot, + marketplace: { id: 'demo', tier: 'official' }, + } as never); + + expect((installed as { marketplace?: unknown }).marketplace).toBeUndefined(); + }); + it('setPluginMcpServerEnabled toggles plugin MCP state', async () => { const home = await mkdtemp(path.join(tmpdir(), 'kimi-home-')); const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-')); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index c1e952541ae..d9948e960de 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -31,8 +31,11 @@ export type { ModelAlias, MoonshotServiceConfig, OAuthRef, + PluginGithubMetadata, + PluginGithubRef, PluginInfo, PluginMcpServerInfo, + PluginSource, PluginSummary, PromptOrigin, ProviderConfig,