diff --git a/.changeset/tidy-gitlab-plugins.md b/.changeset/tidy-gitlab-plugins.md new file mode 100644 index 0000000000..b22f161dbf --- /dev/null +++ b/.changeset/tidy-gitlab-plugins.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Install plugins from GitLab.com and self-managed GitLab repository URLs with `/plugins install `. 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 64ec286148..87b6e88394 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -720,7 +720,9 @@ export class PluginsPanelComponent extends Container implements Focusable { private renderCustom(lines: string[], width: number): void { const colors = currentTheme.palette; - lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors)); + lines.push( + mutedHintLine(' Install from a GitHub or GitLab URL (or zip URL / local path):', colors), + ); lines.push(''); lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); } 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 4c379820ae..7c54217c90 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 @@ -482,7 +482,7 @@ describe('plugins selector dialogs', () => { it('installs from a URL typed on the Custom tab', () => { const { panel, onSelect } = makePanel({ initialTab: 'custom' }); const out = strip(renderRaw(panel)); - expect(out).toContain('Install from a GitHub URL'); + expect(out).toContain('Install from a GitHub or GitLab URL'); expect(out).toContain('╭'); for (const ch of 'https://github.com/owner/repo') { diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index a3cab97df6..78f2badc7d 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -23,7 +23,7 @@ You can also use slash commands directly: | --- | --- | | `/plugins` | Open the interactive plugin manager | | `/plugins list` | List installed plugins | -| `/plugins install ` | Install from a local directory, zip URL, or GitHub repository URL | +| `/plugins install ` | Install from a local directory, zip URL, or GitHub/GitLab repository URL | | `/plugins marketplace [source]` | Browse the official marketplace, or pass a custom marketplace JSON path or URL | | `/plugins info ` | View plugin details and diagnostics | | `/plugins enable ` | Enable a plugin | @@ -46,6 +46,17 @@ Use `/plugins install ` to install directly from a GitHub repository. Four Network requests only go through `github.com` redirects and `codeload.github.com` downloads; `api.github.com` is not called. +### Installing from GitLab + +Use `/plugins install ` to install from GitLab.com or a self-managed GitLab instance served from the root of its origin. Four URL forms are supported: + +- `https://gitlab.example.com//`: Install the latest release; falls back to the default branch if no release exists +- `https://gitlab.example.com///-/tree/`: Install a specific branch, tag, or short commit SHA +- `https://gitlab.example.com///-/releases/`: Pin to a specific tag +- `https://gitlab.example.com///-/commit/`: Pin to a specific commit + +GitLab.com and self-managed hostnames containing `gitlab` are detected automatically. For a self-managed instance with a different hostname, use its HTTPS clone URL ending in `.git`, such as `https://code.example.com//.git`. Self-managed instances configured with a relative URL root, such as `https://code.example.com/gitlab`, are not currently supported. GitLab downloads use the selected instance's `/api/v4` release and repository archive endpoints. + ### Notes - Plugin changes apply after `/reload` or in new sessions. After installing, enabling/disabling, or removing a plugin, run `/reload` or `/new`; the current session will not update. @@ -55,7 +66,7 @@ Network requests only go through `github.com` redirects and `codeload.github.com ### Custom marketplace JSON -Pass a custom marketplace JSON path or URL to `/plugins marketplace `, or set [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) to override the default catalog. Each entry in the `plugins` array needs an `id` and a `source` (local path, zip URL, or GitHub URL): +Pass a custom marketplace JSON path or URL to `/plugins marketplace `, or set [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) to override the default catalog. Each entry in the `plugins` array needs an `id` and a `source` (local path, zip URL, or GitHub/GitLab URL): ```json { @@ -316,4 +327,3 @@ Plugins have a limited loading scope. The following operations do not occur duri - All paths must remain within the plugin root directory after symbolic link resolution - MCP servers of enabled plugins start after `/reload` or in new sessions and can be disabled at any time from `/plugins` - Broken manifests or unsafe paths appear in `/plugins info ` diagnostics and do not affect other sessions - diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index f847491151..bbf7e88f6b 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -23,7 +23,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 | --- | --- | | `/plugins` | 打开交互式 plugin 管理器 | | `/plugins list` | 列出已安装 plugins | -| `/plugins install ` | 从本地目录、zip URL 或 GitHub 仓库 URL 安装 | +| `/plugins install ` | 从本地目录、zip URL 或 GitHub/GitLab 仓库 URL 安装 | | `/plugins marketplace [source]` | 浏览官方 marketplace,或传入自定义 marketplace JSON 的路径或 URL | | `/plugins info ` | 查看 plugin 详情和 diagnostics | | `/plugins enable ` | 启用 plugin | @@ -46,6 +46,17 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 网络请求只走 `github.com` 重定向和 `codeload.github.com` 下载,不调用 `api.github.com`。 +### 从 GitLab 安装 + +通过 `/plugins install ` 可以从 GitLab.com 或部署在站点根路径的自托管 GitLab 实例安装,支持四种 URL 形式: + +- `https://gitlab.example.com//`:安装最新 release;无 release 时回落到默认分支 +- `https://gitlab.example.com///-/tree/`:安装指定分支、tag 或短 commit SHA +- `https://gitlab.example.com///-/releases/`:钉死具体 tag +- `https://gitlab.example.com///-/commit/`:钉死具体 commit + +GitLab.com 和主机名中含 `gitlab` 的自托管实例会被自动识别。若自托管实例使用其他主机名,请使用以 `.git` 结尾的 HTTPS 克隆 URL,例如 `https://code.example.com//.git`。暂不支持配置了相对 URL 根路径(例如 `https://code.example.com/gitlab`)的自托管实例。GitLab 下载只调用所选实例的 `/api/v4` release 与仓库归档端点。 + ### 注意事项 - Plugin 变更需要通过 `/reload` 或新会话生效。安装、启用/禁用、移除后,运行 `/reload` 或 `/new`;当前会话不会更新。 @@ -55,7 +66,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 ### 自定义 marketplace JSON -浏览自定义目录时,把 JSON 路径或 URL 传给 `/plugins marketplace `;或通过 [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) 覆盖默认 marketplace。`plugins` 数组中每个条目需要 `id` 和 `source`(本地路径、zip URL 或 GitHub URL): +浏览自定义目录时,把 JSON 路径或 URL 传给 `/plugins marketplace `;或通过 [`KIMI_CODE_PLUGIN_MARKETPLACE_URL`](../configuration/env-vars.md) 覆盖默认 marketplace。`plugins` 数组中每个条目需要 `id` 和 `source`(本地路径、zip URL 或 GitHub/GitLab URL): ```json { @@ -316,4 +327,3 @@ Plugin 的加载范围有限,以下操作不会在安装或会话启动时发 - 所有路径在解析符号链接后仍必须位于 plugin 根目录内 - 已启用 plugin 的 MCP servers 会在 `/reload` 后或新会话中启动,且可随时从 `/plugins` 禁用 - 损坏的 manifest 或不安全路径会显示在 `/plugins info ` 的 diagnostics 中,不影响其他会话 - diff --git a/packages/agent-core-v2/src/app/plugin/gitlab-resolver.ts b/packages/agent-core-v2/src/app/plugin/gitlab-resolver.ts new file mode 100644 index 0000000000..0bd777b4cb --- /dev/null +++ b/packages/agent-core-v2/src/app/plugin/gitlab-resolver.ts @@ -0,0 +1,70 @@ +/** + * `plugin` domain (L3) — resolves GitLab plugin sources through instance APIs. + * + * Selects the latest release or requested ref and produces repository archive + * URLs for GitLab.com and self-managed GitLab instances. + */ + +import type { GitlabRef } from './source'; + +export interface GitlabSourceInput { + readonly kind: 'gitlab'; + readonly baseUrl: string; + readonly projectPath: string; + readonly ref?: GitlabRef; +} + +export interface GitlabSourceResolution { + readonly tarballUrl: string; +} + +export async function resolveGitlabSource( + input: GitlabSourceInput, +): Promise { + if (input.ref !== undefined) { + return { tarballUrl: archiveUrl(input, input.ref) }; + } + + const latestTag = await tryResolveLatestReleaseTag(input); + return { + tarballUrl: + latestTag === undefined + ? archiveUrl(input) + : archiveUrl(input, { kind: 'tag', value: latestTag }), + }; +} + +async function tryResolveLatestReleaseTag( + input: GitlabSourceInput, +): Promise { + const projectId = encodeURIComponent(input.projectPath); + const url = `${input.baseUrl}/api/v4/projects/${projectId}/releases/permalink/latest`; + const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (resp.status === 404) return undefined; + if (!resp.ok) { + throw new Error( + `Could not look up latest release of \`${input.projectPath}\` on ${input.baseUrl}: ` + + `HTTP ${resp.status} ${resp.statusText}.`, + ); + } + + const release = (await resp.json()) as { tag_name?: unknown }; + if (typeof release.tag_name !== 'string' || release.tag_name.length === 0) { + throw new Error( + `Could not determine the latest release tag of \`${input.projectPath}\` on ${input.baseUrl}.`, + ); + } + return release.tag_name; +} + +function archiveUrl(input: GitlabSourceInput, ref?: GitlabRef): string { + const projectId = encodeURIComponent(input.projectPath); + const url = new URL( + `${input.baseUrl}/api/v4/projects/${projectId}/repository/archive.zip`, + ); + if (ref !== undefined) { + url.searchParams.set('sha', ref.value); + if (ref.kind === 'tag') url.searchParams.set('ref_type', 'tags'); + } + return url.toString(); +} diff --git a/packages/agent-core-v2/src/app/plugin/manager.ts b/packages/agent-core-v2/src/app/plugin/manager.ts index b3d5000ee0..8a8814285f 100644 --- a/packages/agent-core-v2/src/app/plugin/manager.ts +++ b/packages/agent-core-v2/src/app/plugin/manager.ts @@ -19,6 +19,7 @@ import type { SkillRoot } from '#/app/skillCatalog/types'; import { downloadZip, extractZip } from './archive'; import { loadPluginCommand } from './commands'; import { resolveGithubCommitSha, resolveGithubSource } from './github-resolver'; +import { resolveGitlabSource } from './gitlab-resolver'; import { resolveInstallSource } from './source'; import { parseManifest, type ParsedManifestResult } from './manifest'; import { readInstalled, writeInstalled, type InstalledRecord } from './store'; @@ -114,7 +115,9 @@ export class PluginManager { } return resolution.tarballUrl; })() - : resolved.path; + : resolved.kind === 'gitlab' + ? (await resolveGitlabSource(resolved)).tarballUrl + : resolved.path; const buffer = await downloadZip(zipUrl); zipTmpDir = await mkdtemp(path.join(tmpdir(), 'kimi-plugin-zip-')); sourceRoot = await extractZip(buffer, zipTmpDir); diff --git a/packages/agent-core-v2/src/app/plugin/source.ts b/packages/agent-core-v2/src/app/plugin/source.ts index a2092c087e..62b193e454 100644 --- a/packages/agent-core-v2/src/app/plugin/source.ts +++ b/packages/agent-core-v2/src/app/plugin/source.ts @@ -5,10 +5,13 @@ export interface GithubRef { readonly value: string; } +export type GitlabRef = GithubRef; + export type ResolvedSource = | { kind: 'local-path'; path: string } | { kind: 'zip-url'; path: string } - | { kind: 'github'; owner: string; repo: string; ref?: GithubRef }; + | { kind: 'github'; owner: string; repo: string; ref?: GithubRef } + | { kind: 'gitlab'; baseUrl: string; projectPath: string; ref?: GitlabRef }; export type InstallSource = ResolvedSource; @@ -20,6 +23,9 @@ export function resolveInstallSource(source: string): ResolvedSource { const github = parseGithubUrl(trimmed); if (github !== undefined) return github; + const gitlab = parseGitlabUrl(trimmed); + if (gitlab !== undefined) return gitlab; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { return { kind: 'zip-url', path: trimmed }; } @@ -29,6 +35,65 @@ export function resolveInstallSource(source: string): ResolvedSource { return { kind: 'local-path', path: trimmed }; } +function parseGitlabUrl(raw: string): ResolvedSource | undefined { + let url: URL; + try { + url = new URL(raw); + } catch { + return undefined; + } + if (url.protocol !== 'https:') return undefined; + + const segments = url.pathname.split('/').filter((segment) => segment.length > 0); + const separatorIndex = segments.indexOf('-'); + const projectSegments = separatorIndex === -1 ? segments : segments.slice(0, separatorIndex); + const rest = separatorIndex === -1 ? [] : segments.slice(separatorIndex + 1); + const repoRaw = projectSegments.at(-1); + if (projectSegments.length < 2 || repoRaw === undefined) return undefined; + + const hasGitSuffix = repoRaw.endsWith('.git'); + const isGitlabHost = url.hostname.toLowerCase().includes('gitlab'); + if (!isGitlabHost && separatorIndex === -1 && !hasGitSuffix) return undefined; + if (separatorIndex === -1 && (segments[0] === 'api' || repoRaw.endsWith('.zip'))) { + return undefined; + } + + const repo = hasGitSuffix ? repoRaw.slice(0, -4) : repoRaw; + const projectPath = decodeRefSegments([...projectSegments.slice(0, -1), repo]); + const base = { kind: 'gitlab' as const, baseUrl: url.origin, projectPath }; + + if (rest.length === 0) return base; + + const head = rest[0]; + if (head === 'tree' && rest.length >= 2) { + const value = decodeRefSegments(rest.slice(1)); + const kind: GitlabRef['kind'] = SHA_RE.test(value) ? 'sha' : 'branch'; + return { ...base, ref: { kind, value } }; + } + + if (head === 'releases') { + if (rest.length === 3 && rest[1] === 'permalink' && rest[2] === 'latest') { + return base; + } + if (rest.length === 2) { + return { + ...base, + ref: { kind: 'tag', value: decodeRefSegments(rest.slice(1)) }, + }; + } + return undefined; + } + + if (head === 'commit' && rest.length >= 2) { + return { + ...base, + ref: { kind: 'sha', value: decodeRefSegments(rest.slice(1)) }, + }; + } + + return undefined; +} + function parseGithubUrl(raw: string): ResolvedSource | undefined { let url: URL; try { diff --git a/packages/agent-core-v2/test/app/plugin/gitlab-resolver.test.ts b/packages/agent-core-v2/test/app/plugin/gitlab-resolver.test.ts new file mode 100644 index 0000000000..b1259ee042 --- /dev/null +++ b/packages/agent-core-v2/test/app/plugin/gitlab-resolver.test.ts @@ -0,0 +1,68 @@ +/** + * Scenario: GitLab plugin source resolution through instance API endpoints. + * Responsibilities: build archive URLs for explicit refs, select the latest + * release, and fall back to the default branch when no release exists. + * Wiring: real resolver with only the external `fetch` boundary stubbed. + * Run: pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/app/plugin/gitlab-resolver.test.ts + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resolveGitlabSource } from '#/app/plugin/gitlab-resolver'; + +describe('resolveGitlabSource', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('builds an archive URL directly for an explicit ref', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + await expect( + resolveGitlabSource({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/plugins/sample', + ref: { kind: 'tag', value: 'release#1' }, + }), + ).resolves.toEqual({ + tarballUrl: + 'https://gitlab.example.com/api/v4/projects/team%2Fplugins%2Fsample/repository/archive.zip?sha=release%231&ref_type=tags', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('uses the latest release for a bare repository URL', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ tag_name: 'v1.2.3' }), { status: 200 })); + vi.stubGlobal('fetch', fetch); + + await expect( + resolveGitlabSource({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }), + ).resolves.toEqual({ + tarballUrl: + 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip?sha=v1.2.3&ref_type=tags', + }); + }); + + it('falls back to the default branch when the project has no release', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 }))); + + await expect( + resolveGitlabSource({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }), + ).resolves.toEqual({ + tarballUrl: + 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip', + }); + }); +}); diff --git a/packages/agent-core-v2/test/app/plugin/manager.test.ts b/packages/agent-core-v2/test/app/plugin/manager.test.ts index 8bd5150cc9..621635af99 100644 --- a/packages/agent-core-v2/test/app/plugin/manager.test.ts +++ b/packages/agent-core-v2/test/app/plugin/manager.test.ts @@ -126,6 +126,37 @@ describe('PluginManager', () => { } }); + it('installs a plugin from a GitLab release URL', async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-gitlab-source-')); + const zipPath = join(tmpdir(), `plugin-gitlab-${Date.now()}.zip`); + try { + await writeFile( + join(sourceRoot, 'kimi.plugin.json'), + JSON.stringify({ name: 'gitlab-plugin' }), + 'utf8', + ); + execFileSync('zip', ['-qr', zipPath, '.'], { cwd: sourceRoot }); + const zip = await readFile(zipPath); + const fetchMock = vi.fn(async () => new Response(zip)); + vi.stubGlobal('fetch', fetchMock as typeof fetch); + const manager = new PluginManager({ kimiHomeDir: home }); + const source = 'https://gitlab.example.com/team/sample/-/releases/v1.2.3'; + + const record = await manager.install(source); + + expect(record.id).toBe('gitlab-plugin'); + expect(record.source).toBe('zip-url'); + expect(record.originalSource).toBe(source); + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip?sha=v1.2.3&ref_type=tags', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + } finally { + await rm(sourceRoot, { recursive: true, force: true }); + await rm(zipPath, { force: true }); + } + }); + it('installs a github plugin through codeload', async () => { const sourceRoot = await mkdtemp(join(tmpdir(), 'plugin-github-source-')); const zipPath = join(tmpdir(), `plugin-github-${Date.now()}.zip`); diff --git a/packages/agent-core-v2/test/app/plugin/source.test.ts b/packages/agent-core-v2/test/app/plugin/source.test.ts index 2669a58664..869a4a404d 100644 --- a/packages/agent-core-v2/test/app/plugin/source.test.ts +++ b/packages/agent-core-v2/test/app/plugin/source.test.ts @@ -1,3 +1,10 @@ +/** + * Scenario: plugin installation source classification from paths and repository URLs. + * Responsibilities: distinguish local, zip, GitHub, and GitLab sources and preserve refs. + * Wiring: pure source resolver with no external collaborators or stubbed boundaries. + * Run: pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/app/plugin/source.test.ts + */ + import { describe, expect, it } from 'vitest'; import { resolveInstallSource } from '#/app/plugin/source'; @@ -35,6 +42,78 @@ describe('resolveInstallSource', () => { }); }); + it('recognizes a bare GitLab.com URL with nested groups', () => { + expect(resolveInstallSource('https://gitlab.com/team/plugins/sample')).toEqual({ + kind: 'gitlab', + baseUrl: 'https://gitlab.com', + projectPath: 'team/plugins/sample', + }); + }); + + it('recognizes a tree ref on a self-managed GitLab hostname', () => { + expect(resolveInstallSource('https://gitlab.example.com/team/sample/-/tree/main')).toEqual({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + ref: { kind: 'branch', value: 'main' }, + }); + }); + + it('recognizes a GitLab release URL as a tag ref', () => { + expect( + resolveInstallSource('https://gitlab.example.com/team/sample/-/releases/v1.2.3'), + ).toEqual({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + ref: { kind: 'tag', value: 'v1.2.3' }, + }); + }); + + it('treats the latest-release permalink as a bare repository URL', () => { + expect( + resolveInstallSource( + 'https://gitlab.example.com/team/sample/-/releases/permalink/latest', + ), + ).toEqual({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }); + }); + + it.each([ + [ + 'versioned release asset', + 'https://gitlab.example.com/team/sample/-/releases/v1/downloads/plugin.zip', + ], + [ + 'latest release asset', + 'https://gitlab.example.com/team/sample/-/releases/permalink/latest/downloads/plugin.zip', + ], + ])('leaves a %s URL as a zip source', (_description, url) => { + expect(resolveInstallSource(url)).toEqual({ kind: 'zip-url', path: url }); + }); + + it('uses a .git suffix to recognize an arbitrary self-managed hostname', () => { + expect(resolveInstallSource('https://code.example.com/team/sample.git')).toEqual({ + kind: 'gitlab', + baseUrl: 'https://code.example.com', + projectPath: 'team/sample', + }); + }); + + it.each(['mygitlab.example.com', 'gitlab01.example.com'])( + 'recognizes a self-managed hostname containing GitLab: %s', + (hostname) => { + expect(resolveInstallSource(`https://${hostname}/team/sample`)).toEqual({ + kind: 'gitlab', + baseUrl: `https://${hostname}`, + projectPath: 'team/sample', + }); + }, + ); + it('rejects relative paths', () => { expect(() => resolveInstallSource('./plugin')).toThrow('absolute path'); }); diff --git a/packages/agent-core/src/plugin/gitlab-resolver.ts b/packages/agent-core/src/plugin/gitlab-resolver.ts new file mode 100644 index 0000000000..13bcf6e5be --- /dev/null +++ b/packages/agent-core/src/plugin/gitlab-resolver.ts @@ -0,0 +1,63 @@ +import type { GitlabRef } from './source'; + +export interface GitlabSourceInput { + readonly kind: 'gitlab'; + readonly baseUrl: string; + readonly projectPath: string; + readonly ref?: GitlabRef; +} + +export interface GitlabSourceResolution { + readonly tarballUrl: string; +} + +export async function resolveGitlabSource( + input: GitlabSourceInput, +): Promise { + if (input.ref !== undefined) { + return { tarballUrl: archiveUrl(input, input.ref) }; + } + + const latestTag = await tryResolveLatestReleaseTag(input); + return { + tarballUrl: + latestTag === undefined + ? archiveUrl(input) + : archiveUrl(input, { kind: 'tag', value: latestTag }), + }; +} + +async function tryResolveLatestReleaseTag( + input: GitlabSourceInput, +): Promise { + const projectId = encodeURIComponent(input.projectPath); + const url = `${input.baseUrl}/api/v4/projects/${projectId}/releases/permalink/latest`; + const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (resp.status === 404) return undefined; + if (!resp.ok) { + throw new Error( + `Could not look up latest release of \`${input.projectPath}\` on ${input.baseUrl}: ` + + `HTTP ${resp.status} ${resp.statusText}.`, + ); + } + + const release = (await resp.json()) as { tag_name?: unknown }; + if (typeof release.tag_name !== 'string' || release.tag_name.length === 0) { + throw new Error( + `Could not determine the latest release tag of \`${input.projectPath}\` on ${input.baseUrl}.`, + ); + } + return release.tag_name; +} + +function archiveUrl(input: GitlabSourceInput, ref?: GitlabRef): string { + const projectId = encodeURIComponent(input.projectPath); + const url = new URL( + `${input.baseUrl}/api/v4/projects/${projectId}/repository/archive.zip`, + ); + if (ref !== undefined) { + url.searchParams.set('sha', ref.value); + if (ref.kind === 'tag') url.searchParams.set('ref_type', 'tags'); + } + return url.toString(); +} diff --git a/packages/agent-core/src/plugin/manager.ts b/packages/agent-core/src/plugin/manager.ts index 65992acae3..31bf0a7f52 100644 --- a/packages/agent-core/src/plugin/manager.ts +++ b/packages/agent-core/src/plugin/manager.ts @@ -8,6 +8,7 @@ import type { HookDef } from '../session/hooks'; import { loadPluginCommand } from './commands'; import { downloadZip, extractZip } from './archive'; import { resolveGithubSource } from './github-resolver'; +import { resolveGitlabSource } from './gitlab-resolver'; import { parseManifest, type ParsedManifestResult } from './manifest'; import { readInstalled, writeInstalled, type InstalledRecord } from './store'; import { resolveInstallSource } from './source'; @@ -93,6 +94,11 @@ export class PluginManager { repo: resolved.repo, ref: githubResolution.ref, }; + } else if (resolved.kind === 'gitlab') { + const gitlabResolution = await resolveGitlabSource(resolved); + zipUrl = gitlabResolution.tarballUrl; + originalSource = source.trim(); + sourceType = 'zip-url'; } else { zipUrl = resolved.path; originalSource = resolved.path; diff --git a/packages/agent-core/src/plugin/source.ts b/packages/agent-core/src/plugin/source.ts index 38a02ece44..0c175c48cb 100644 --- a/packages/agent-core/src/plugin/source.ts +++ b/packages/agent-core/src/plugin/source.ts @@ -5,10 +5,13 @@ export interface GithubRef { readonly value: string; } +export type GitlabRef = GithubRef; + export type ResolvedSource = | { kind: 'local-path'; path: string } | { kind: 'zip-url'; path: string } - | { kind: 'github'; owner: string; repo: string; ref?: GithubRef }; + | { kind: 'github'; owner: string; repo: string; ref?: GithubRef } + | { kind: 'gitlab'; baseUrl: string; projectPath: string; ref?: GitlabRef }; // Kept as a back-compat alias for downstream code that imported the old name. export type InstallSource = ResolvedSource; @@ -21,6 +24,9 @@ export function resolveInstallSource(source: string): ResolvedSource { const github = parseGithubUrl(trimmed); if (github !== undefined) return github; + const gitlab = parseGitlabUrl(trimmed); + if (gitlab !== undefined) return gitlab; + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { return { kind: 'zip-url', path: trimmed }; } @@ -30,6 +36,65 @@ export function resolveInstallSource(source: string): ResolvedSource { return { kind: 'local-path', path: trimmed }; } +function parseGitlabUrl(raw: string): ResolvedSource | undefined { + let url: URL; + try { + url = new URL(raw); + } catch { + return undefined; + } + if (url.protocol !== 'https:') return undefined; + + const segments = url.pathname.split('/').filter((segment) => segment.length > 0); + const separatorIndex = segments.indexOf('-'); + const projectSegments = separatorIndex === -1 ? segments : segments.slice(0, separatorIndex); + const rest = separatorIndex === -1 ? [] : segments.slice(separatorIndex + 1); + const repoRaw = projectSegments.at(-1); + if (projectSegments.length < 2 || repoRaw === undefined) return undefined; + + const hasGitSuffix = repoRaw.endsWith('.git'); + const isGitlabHost = url.hostname.toLowerCase().includes('gitlab'); + if (!isGitlabHost && separatorIndex === -1 && !hasGitSuffix) return undefined; + if (separatorIndex === -1 && (segments[0] === 'api' || repoRaw.endsWith('.zip'))) { + return undefined; + } + + const repo = hasGitSuffix ? repoRaw.slice(0, -4) : repoRaw; + const projectPath = decodeRefSegments([...projectSegments.slice(0, -1), repo]); + const base = { kind: 'gitlab' as const, baseUrl: url.origin, projectPath }; + + if (rest.length === 0) return base; + + const head = rest[0]; + if (head === 'tree' && rest.length >= 2) { + const value = decodeRefSegments(rest.slice(1)); + const kind: GitlabRef['kind'] = SHA_RE.test(value) ? 'sha' : 'branch'; + return { ...base, ref: { kind, value } }; + } + + if (head === 'releases') { + if (rest.length === 3 && rest[1] === 'permalink' && rest[2] === 'latest') { + return base; + } + if (rest.length === 2) { + return { + ...base, + ref: { kind: 'tag', value: decodeRefSegments(rest.slice(1)) }, + }; + } + return undefined; + } + + if (head === 'commit' && rest.length >= 2) { + return { + ...base, + ref: { kind: 'sha', value: decodeRefSegments(rest.slice(1)) }, + }; + } + + return undefined; +} + function parseGithubUrl(raw: string): ResolvedSource | undefined { let url: URL; try { diff --git a/packages/agent-core/test/plugin/gitlab-resolver.test.ts b/packages/agent-core/test/plugin/gitlab-resolver.test.ts new file mode 100644 index 0000000000..98310e7d9f --- /dev/null +++ b/packages/agent-core/test/plugin/gitlab-resolver.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resolveGitlabSource } from '../../src/plugin/gitlab-resolver'; + +describe('resolveGitlabSource', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('builds an archive URL directly for an explicit ref', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + await expect( + resolveGitlabSource({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/plugins/sample', + ref: { kind: 'tag', value: 'release#1' }, + }), + ).resolves.toEqual({ + tarballUrl: + 'https://gitlab.example.com/api/v4/projects/team%2Fplugins%2Fsample/repository/archive.zip?sha=release%231&ref_type=tags', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('installs the latest release for a bare repository URL', async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ tag_name: 'v1.2.3' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetch); + + await expect( + resolveGitlabSource({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }), + ).resolves.toEqual({ + tarballUrl: + 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip?sha=v1.2.3&ref_type=tags', + }); + expect(fetch).toHaveBeenCalledWith( + 'https://gitlab.example.com/api/v4/projects/team%2Fsample/releases/permalink/latest', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('falls back to the default branch when the project has no release', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 }))); + + await expect( + resolveGitlabSource({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }), + ).resolves.toEqual({ + tarballUrl: + 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip', + }); + }); + + it('reports an unexpected latest-release lookup failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(null, { status: 503, statusText: 'Unavailable' })), + ); + + await expect( + resolveGitlabSource({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }), + ).rejects.toThrow('HTTP 503 Unavailable'); + }); +}); diff --git a/packages/agent-core/test/plugin/manager.test.ts b/packages/agent-core/test/plugin/manager.test.ts index 26decd8e50..db49ff1477 100644 --- a/packages/agent-core/test/plugin/manager.test.ts +++ b/packages/agent-core/test/plugin/manager.test.ts @@ -712,6 +712,36 @@ describe('PluginManager', () => { expect(reloaded.get('gh-demo')?.github?.ref).toEqual({ kind: 'tag', value: 'v1.0.0' }); }); + it('install() from a GitLab release URL downloads and records the original source', async () => { + const home = await makeKimiHome(); + const zipBuffer = await createZipBuffer([ + { + name: 'sample-v1.2.3/kimi.plugin.json', + data: JSON.stringify({ name: 'gitlab-demo', version: '1.2.3' }), + }, + ]); + const source = 'https://gitlab.example.com/team/sample/-/releases/v1.2.3'; + const original = globalThis.fetch; + const fetchMock = vi.fn(async () => new Response(zipBuffer, { status: 200 })); + globalThis.fetch = fetchMock as typeof fetch; + + try { + const manager = new PluginManager({ kimiHomeDir: home }); + await manager.load(); + const record = await manager.install(source); + + expect(record.id).toBe('gitlab-demo'); + expect(record.source).toBe('zip-url'); + expect(record.originalSource).toBe(source); + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.example.com/api/v4/projects/team%2Fsample/repository/archive.zip?sha=v1.2.3&ref_type=tags', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + } finally { + globalThis.fetch = original; + } + }); + 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 diff --git a/packages/agent-core/test/plugin/source.test.ts b/packages/agent-core/test/plugin/source.test.ts index 12b76bc451..32c3d5ed73 100644 --- a/packages/agent-core/test/plugin/source.test.ts +++ b/packages/agent-core/test/plugin/source.test.ts @@ -225,4 +225,99 @@ describe('resolveInstallSource', () => { }); }); }); + + describe('GitLab URL recognition', () => { + it('recognizes a bare GitLab.com URL with nested groups', () => { + expect(resolveInstallSource('https://gitlab.com/example/plugins/sample')).toEqual({ + kind: 'gitlab', + baseUrl: 'https://gitlab.com', + projectPath: 'example/plugins/sample', + }); + }); + + it('recognizes a self-managed GitLab host', () => { + expect(resolveInstallSource('https://gitlab.example.com/team/sample')).toEqual({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }); + }); + + it.each(['mygitlab.example.com', 'gitlab01.example.com'])( + 'recognizes a self-managed hostname containing GitLab: %s', + (hostname) => { + expect(resolveInstallSource(`https://${hostname}/team/sample`)).toEqual({ + kind: 'gitlab', + baseUrl: `https://${hostname}`, + projectPath: 'team/sample', + }); + }, + ); + + it('uses a .git suffix to recognize an arbitrary self-managed host', () => { + expect(resolveInstallSource('https://code.example.com/team/sample.git')).toEqual({ + kind: 'gitlab', + baseUrl: 'https://code.example.com', + projectPath: 'team/sample', + }); + }); + + it('recognizes tree, release, and commit URLs', () => { + expect( + resolveInstallSource('https://code.example.com/team/sample/-/tree/feat%231'), + ).toEqual({ + kind: 'gitlab', + baseUrl: 'https://code.example.com', + projectPath: 'team/sample', + ref: { kind: 'branch', value: 'feat#1' }, + }); + expect( + resolveInstallSource('https://code.example.com/team/sample/-/releases/v1.2.3'), + ).toEqual({ + kind: 'gitlab', + baseUrl: 'https://code.example.com', + projectPath: 'team/sample', + ref: { kind: 'tag', value: 'v1.2.3' }, + }); + expect( + resolveInstallSource('https://code.example.com/team/sample/-/commit/abc1234'), + ).toEqual({ + kind: 'gitlab', + baseUrl: 'https://code.example.com', + projectPath: 'team/sample', + ref: { kind: 'sha', value: 'abc1234' }, + }); + }); + + it('treats the latest-release permalink as a bare repository URL', () => { + expect( + resolveInstallSource( + 'https://gitlab.example.com/team/sample/-/releases/permalink/latest', + ), + ).toEqual({ + kind: 'gitlab', + baseUrl: 'https://gitlab.example.com', + projectPath: 'team/sample', + }); + }); + + it.each([ + [ + 'versioned release asset', + 'https://gitlab.example.com/team/sample/-/releases/v1/downloads/plugin.zip', + ], + [ + 'latest release asset', + 'https://gitlab.example.com/team/sample/-/releases/permalink/latest/downloads/plugin.zip', + ], + ])('leaves a %s URL as a zip source', (_description, url) => { + expect(resolveInstallSource(url)).toEqual({ kind: 'zip-url', path: url }); + }); + + it('leaves existing GitLab archive URLs as zip URLs', () => { + const url = + 'https://gitlab.example.com/team/sample/-/archive/main/sample-main.zip'; + expect(resolveInstallSource(url)).toEqual({ kind: 'zip-url', path: url }); + }); + }); });