kimi-code/packages/agent-core/test/plugin/store.test.ts
qer bab2da7b1c
feat(plugin): install plugins from a GitHub repository URL (#221)
* feat(plugin): install plugins from a GitHub repository URL

Allow `/plugins install <github-url>` (and marketplace `source` entries) to
take a GitHub repo URL directly. A new `github` source kind joins the
existing `local-path` and `zip-url` kinds.

Recognized URL forms (parsing in source.ts):

- `https://github.com/<o>/<r>`                       — bare; resolves to latest
                                                       release tag, falling back
                                                       to default branch HEAD.
- `https://github.com/<o>/<r>/tree/<ref>`            — branch / tag / SHA;
                                                       value passed to codeload
                                                       in its short form so the
                                                       backend resolves either.
- `https://github.com/<o>/<r>/releases/tag/<tag>`    — explicit tag, uses
                                                       refs/tags/<tag> to avoid
                                                       same-named-branch ambiguity.
- `https://github.com/<o>/<r>/commit/<sha>`          — explicit commit SHA.

The resolver deliberately avoids `api.github.com`: its 60/hour anonymous
quota is shared with every other tool on the egress IP (browser, gh CLI,
IDE integrations) and a first-time install failing because some other tool
ate the budget is unacceptable UX. Instead we:

- GET `github.com/<o>/<r>/releases/latest` with manual redirect and parse
  the `Location` header (302 → tag URL; 404 → no own release).
- Fall back to `codeload.github.com/<o>/<r>/zip/HEAD` for repos with no
  releases (or for forks that inherit upstream tags but have no own release
  page, which redirect to bare `/releases`).
- Only treat the explicit 404 from `/releases/latest` as "no release" — 5xx,
  403, 429, and any other non-2xx status surface a hard error rather than
  silently installing the default branch, so the user knows when transient
  GitHub issues changed the install path.

UI changes in the TUI:

- `/plugins install` now shows a live Braille spinner while resolving and
  downloading, then flips to a final status that distinguishes Installed
  (fresh) vs Updated (same repo identity, new version) vs Migrated (source
  changed, e.g. CDN zip-url → GitHub).
- `/plugins list`, the `/plugins` overview, and `/plugins info` show the
  install provenance inline. `zip-url` installs now display the URL host
  (e.g. `via code.kimi.com`, `via 127.0.0.1:port`) instead of the opaque
  `zip-url` literal. GitHub installs show `github <owner>/<repo>@<ref>`.
- Three-tier trust badge driven by the marketplace context recorded at
  install time: `official` (green) for `tier: official`, `curated` (blue) for
  `tier: curated`, `third-party` (muted) for anything not installed through
  the marketplace selector. CLI `/plugins install <url>` always records as
  third-party; the marketplace selector passes the tier through. A
  re-install replaces the marketplace context: switching to a third-party
  source clears the badge, which matches the underlying trust change.

`installed.json` gains optional `github` and `marketplace` fields
(back-compatible). PluginSummary surfaces `source`, `originalSource`,
`github`, and `marketplace` so the TUI can label installs without an extra
round trip to PluginInfo. The SDK's `session.installPlugin(source)` gains
an optional `{ marketplace }` second argument so the marketplace selector
can forward `{ id, tier }` through RPC; the CLI install path omits it.

Tests: 112 plugin-suite tests (URL parser, resolver, store round-trip,
manager integration). The manager integration tests assert codeload URLs
shape (short form for `/tree/<ref>`, explicit `refs/tags/` for
`/releases/tag/`) and verify marketplace context is persisted across
reloads and cleared on a third-party re-install.

* chore(changeset): plugin install from GitHub

* docs(plugins): document GitHub install URLs and trust badges

* fix(plugin): preserve URL-encoded characters in GitHub ref names

Git permits ref characters that have special meaning in URLs — most
notably `#`, which is a valid tag character (e.g. `release#1`) but the URL
fragment delimiter. The resolver decoded the tag from GitHub's
`/releases/latest` 302 redirect Location header and then interpolated the
raw value into the codeload URL. The literal `#1` became a fragment and
the HTTP request reached the server as `…/refs/tags/release` — a wrong or
truncated ref, leading to install failure for a release whose URL was
otherwise valid.

Two symmetric changes:

- The codeload URL builder now splits the ref on `/` (so multi-segment
  refs like `feat/foo` keep their path separators) and percent-encodes
  each segment.
- The GitHub URL parser now percent-decodes each segment from the URL's
  pathname when extracting `/tree/<ref>`, `/releases/tag/<tag>`, and
  `/commit/<sha>`. Storage and display see the human-readable Git ref
  name; the resolver re-encodes on the way out.

Malformed `%xx` sequences in user-typed URLs are tolerated: we keep the
raw segment so the caller surfaces a normal "ref not found" error
downstream instead of crashing during parse.

* fix: restrict plugin trust badges

* chore: remove fetch when show plugin list

---------

Co-authored-by: qer <Anna_Knapprfr@mail.com>
2026-05-29 22:18:16 +08:00

120 lines
3.7 KiB
TypeScript

import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
type InstalledFile,
readInstalled,
writeInstalled,
} from '../../src/plugin/store';
async function makeKimiHome(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'kimi-home-'));
}
describe('plugin store', () => {
it('returns an empty list when the file does not exist', async () => {
const home = await makeKimiHome();
const result = await readInstalled(home);
expect(result.plugins).toEqual([]);
expect(result.version).toBe(1);
});
it('writes and reads installed.json round-trip', async () => {
const home = await makeKimiHome();
const data: InstalledFile = {
version: 1,
plugins: [
{
id: 'demo',
root: '/tmp/demo',
source: 'local-path',
enabled: true,
installedAt: '2026-05-25T09:00:00Z',
updatedAt: '2026-05-25T10:00:00Z',
originalSource: '/tmp/demo',
capabilities: {
mcpServers: {
finance: { enabled: true },
},
},
},
],
};
await writeInstalled(home, data);
const result = await readInstalled(home);
expect(result).toEqual(data);
});
it('writes atomically (no .tmp left after success)', async () => {
const home = await makeKimiHome();
await writeInstalled(home, { version: 1, plugins: [] });
const after = await readFile(path.join(home, 'plugins', 'installed.json'), 'utf8');
expect(after).toContain('"version": 1');
});
it('throws on a corrupt installed.json instead of silently dropping it', async () => {
const home = await makeKimiHome();
await writeInstalled(home, { version: 1, plugins: [] });
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();
});
});