mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
* 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>
313 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|