kimi-code/packages/agent-core/test/plugin/source.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

228 lines
7.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { resolveInstallSource } from '../../src/plugin/source';
describe('resolveInstallSource', () => {
it('recognizes https:// as zip-url', () => {
const result = resolveInstallSource('https://example.com/plugin.zip');
expect(result).toEqual({ kind: 'zip-url', path: 'https://example.com/plugin.zip' });
});
it('recognizes http:// as zip-url', () => {
const result = resolveInstallSource('http://example.com/plugin.zip');
expect(result).toEqual({ kind: 'zip-url', path: 'http://example.com/plugin.zip' });
});
it('recognizes absolute path as local-path', () => {
const result = resolveInstallSource('/home/user/plugin');
expect(result).toEqual({ kind: 'local-path', path: '/home/user/plugin' });
});
it('trims whitespace from local paths', () => {
const result = resolveInstallSource(' /home/user/plugin ');
expect(result).toEqual({ kind: 'local-path', path: '/home/user/plugin' });
});
it('throws for relative local paths', () => {
expect(() => resolveInstallSource('relative/path')).toThrow(/absolute path/i);
});
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/<branch>', () => {
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/<tag-like-value> 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/<short-sha> 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/<full-sha> 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/<ref>', () => {
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/<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/<sha> 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/<encoded> 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' },
});
});
});
});