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

313 lines
10 KiB
TypeScript

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<string, string>;
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/<ref>`, 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/<ref> escape hatch', async () => {
mockSequence([
{ status: 502, statusText: 'Bad Gateway' },
]);
await expect(
resolveGithubSource({ kind: 'github', owner: 'obra', repo: 'superpowers' }),
).rejects.toThrow(/\/tree\/<branch\|tag\|sha>/);
});
it('does not call api.github.com at all on bare URL', async () => {
const calls: string[] = [];
globalThis.fetch = vi.fn(async (input: Parameters<typeof fetch>[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);
});
});