From 91bdee3d3a01099367e55d97d8fe54227326fcf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 20 Jul 2026 14:03:52 +0100 Subject: [PATCH] feat(plugins): show source repo link on installed plugin list and settings (#1821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): show source repo link on installed plugin list and settings Installed plugins carried no repository URL — a "Repo" link existed only on marketplace catalog cards, whose repo_url is registry-derived from plugins.yaml and never reaches an installed record. Let the manifest declare an optional repo_url so sideloaded and directly-installed plugins surface the same link. - manifest: add optional RepoURL (repo_url); validate http(s)-only at registration so a javascript: scheme can't reach the operator's session. - Record embeds the manifest, so repo_url flows through JSON to the frontend PluginRecord with no extra plumbing. - frontend: extract the shared, guarded PluginRepoLink (+ isHttpUrl) from the marketplace row and render it on the installed plugin row and the plugin detail header. - fixture + docs: exercise repo_url in the e2e fixture manifest and document the field in the public manifest reference. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(plugins): harden repo_url handling per review - PluginRepoLink accepts string|undefined|null and isHttpUrl null-checks before trim, so it can't throw on an absent repo_url (CodeRabbit); drop the now-redundant truthiness guards at the call sites. - validateRepoURL trims m.RepoURL in place so the stored/serialised value matches what was validated (Greptile). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../fixture-package/manifest.yaml | 1 + .../plugin-fixture/fixture_package_test.go | 1 + .../internal/plugins/manifest/manifest.go | 10 +++++ .../plugins/manifest/manifest_test.go | 40 ++++++++++++++++++ .../internal/plugins/manifest/validate.go | 20 +++++++++ .../plugins/marketplace-entry-row.tsx | 25 ++--------- .../settings/plugins/plugin-detail.tsx | 8 +++- .../plugins/plugin-repo-link.test.tsx | 38 +++++++++++++++++ .../settings/plugins/plugin-repo-link.tsx | 39 +++++++++++++++++ .../settings/plugins/plugin-row.test.tsx | 42 +++++++++++++++++++ .../settings/plugins/plugin-row.tsx | 8 +++- apps/web/lib/types/plugins.ts | 7 ++++ docs/public/plugins-manifest.md | 2 + 13 files changed, 215 insertions(+), 26 deletions(-) create mode 100644 apps/web/components/settings/plugins/plugin-repo-link.test.tsx create mode 100644 apps/web/components/settings/plugins/plugin-repo-link.tsx diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml index 72fca76b91..847687dc8d 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml +++ b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml @@ -11,6 +11,7 @@ display_name: "Kandev E2E Fixture Plugin" description: "SDK-based plugin backend used by backend integration tests and Playwright e2e tests" author: "kandev" categories: ["tools"] +repo_url: "https://github.com/kdlbs/kandev-plugin-template" runtime: type: binary diff --git a/apps/backend/cmd/plugin-fixture/fixture_package_test.go b/apps/backend/cmd/plugin-fixture/fixture_package_test.go index 3681c2a495..5e7c501745 100644 --- a/apps/backend/cmd/plugin-fixture/fixture_package_test.go +++ b/apps/backend/cmd/plugin-fixture/fixture_package_test.go @@ -24,6 +24,7 @@ func TestFixtureManifest_ParsesAndValidates(t *testing.T) { require.Equal(t, 1, m.APIVersion) require.Equal(t, "1.0.0", m.Version) require.True(t, m.IsManaged()) + require.Equal(t, "https://github.com/kdlbs/kandev-plugin-template", m.RepoURL) require.Equal(t, "/ui/bundle.js", m.UI.Bundle) require.True(t, m.HasEvent("task.created")) require.True(t, m.Capabilities.State) diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index a53da07d09..391223ebdd 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -22,6 +22,16 @@ type Manifest struct { // for an installed plugin it is served from the extracted package. Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` + // RepoURL is an optional URL to the plugin's source repository (e.g. + // "https://github.com/owner/plugin"). kandev renders it as a "Repo" link + // in the installed-plugin list and detail. Unlike the marketplace + // catalog's repo_url — which the registry index-build derives from + // plugins.yaml — this is author-declared in the manifest, so sideloaded + // and directly-installed plugins carry the link too. When set it must be + // an http(s) URL (see Validate); the frontend href guard is enforced here + // as well so a bad scheme fails registration rather than reaching the UI. + RepoURL string `yaml:"repo_url,omitempty" json:"repo_url,omitempty"` + BaseURL string `yaml:"base_url" json:"base_url"` Endpoints Endpoints `yaml:"endpoints" json:"endpoints"` diff --git a/apps/backend/internal/plugins/manifest/manifest_test.go b/apps/backend/internal/plugins/manifest/manifest_test.go index b49930ac15..2c860d9dbb 100644 --- a/apps/backend/internal/plugins/manifest/manifest_test.go +++ b/apps/backend/internal/plugins/manifest/manifest_test.go @@ -78,6 +78,36 @@ func validManifest(t *testing.T) *Manifest { return m } +func TestParse_DecodesRepoURL(t *testing.T) { + const withRepo = validManifestYAML + "\nrepo_url: \"https://github.com/kdlbs/kandev-plugin-slack\"\n" + m, err := Parse([]byte(withRepo)) + if err != nil { + t.Fatalf("Parse() unexpected error: %v", err) + } + if m.RepoURL != "https://github.com/kdlbs/kandev-plugin-slack" { + t.Fatalf("m.RepoURL = %q, want the manifest's repo_url", m.RepoURL) + } +} + +func TestValidate_AcceptsHTTPRepoURL(t *testing.T) { + m := validManifest(t) + m.RepoURL = "https://github.com/kdlbs/kandev-plugin-slack" + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error for a valid http(s) repo_url: %v", err) + } +} + +func TestValidate_TrimsRepoURLInPlace(t *testing.T) { + m := validManifest(t) + m.RepoURL = " https://github.com/kdlbs/kandev-plugin-slack " + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + if m.RepoURL != "https://github.com/kdlbs/kandev-plugin-slack" { + t.Fatalf("m.RepoURL = %q, want the surrounding whitespace trimmed", m.RepoURL) + } +} + func TestValidate_RejectsBadIDPattern(t *testing.T) { m := validManifest(t) m.ID = "Kandev_Plugin!" @@ -180,6 +210,16 @@ func TestValidate_RejectsInvalidManifests(t *testing.T) { mutate: func(m *Manifest) { m.Version = "1.0.0 " }, wantErr: "version", }, + { + name: "repo_url with a javascript: scheme", + mutate: func(m *Manifest) { m.RepoURL = "javascript:alert(document.cookie)" }, + wantErr: "repo_url", + }, + { + name: "repo_url without an http(s) scheme", + mutate: func(m *Manifest) { m.RepoURL = "ftp://example.com/repo" }, + wantErr: "repo_url", + }, } for _, tt := range tests { diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go index 754a0d1df1..5f0f2c1c7f 100644 --- a/apps/backend/internal/plugins/manifest/validate.go +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -44,6 +44,7 @@ func (m *Manifest) Validate() error { errs = append(errs, m.validateEndpoints()...) } errs = append(errs, m.validateCategories()...) + errs = append(errs, m.validateRepoURL()...) errs = append(errs, m.validateUIPages()...) errs = append(errs, m.validateUIBundle()...) errs = append(errs, m.validateWebhooks()...) @@ -174,6 +175,25 @@ func (m *Manifest) validateCategories() []error { return errs } +// validateRepoURL checks that repo_url, when set, is an http(s) URL. It is +// surfaced as a clickable "Repo" link in the plugin UI, so a non-http(s) +// scheme (e.g. "javascript:") is rejected at registration rather than relying +// solely on the frontend href guard. An empty repo_url is valid (optional). +func (m *Manifest) validateRepoURL() []error { + // Normalise in place so the stored/serialised value matches what was + // validated (a manifest with surrounding whitespace would otherwise pass + // the check but keep the spaces in the href). + m.RepoURL = strings.TrimSpace(m.RepoURL) + if m.RepoURL == "" { + return nil + } + u := strings.ToLower(m.RepoURL) + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return []error{fmt.Errorf("repo_url %q must be an http(s) URL", m.RepoURL)} + } + return nil +} + // validateUIPages checks each declared UI page's surface against the known // enum (settings | task-panel | main-nav). func (m *Manifest) validateUIPages() []error { diff --git a/apps/web/components/settings/plugins/marketplace-entry-row.tsx b/apps/web/components/settings/plugins/marketplace-entry-row.tsx index 86bc2863d8..681a25c903 100644 --- a/apps/web/components/settings/plugins/marketplace-entry-row.tsx +++ b/apps/web/components/settings/plugins/marketplace-entry-row.tsx @@ -1,25 +1,16 @@ "use client"; import { useState } from "react"; -import { IconArrowUpCircle, IconCheck, IconExternalLink, IconStar } from "@tabler/icons-react"; +import { IconArrowUpCircle, IconCheck, IconStar } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import type { MarketplaceEntry } from "@/lib/types/plugins"; +import { PluginRepoLink } from "./plugin-repo-link"; // Id of the built-in official source (marketplace.officialSourceID). Entries // from any other source get a source badge; the official one does not. const OFFICIAL_SOURCE_ID = "official"; -/** - * Guard for rendering a catalog-supplied URL as an . repo_url comes - * from an untrusted index.json (an operator-added or compromised source), so - * only http(s) is allowed — a `javascript:` (or other) scheme would execute in - * the operator's session on click. rel/target do NOT block `javascript:`. - */ -function isHttpUrl(url: string): boolean { - return /^https?:\/\//i.test(url.trim()); -} - type MarketplaceEntryRowProps = { entry: MarketplaceEntry; busy: boolean; @@ -65,17 +56,7 @@ export function MarketplaceEntryRow({ entry, busy, onInstall }: MarketplaceEntry {cat} ))} - {isHttpUrl(entry.repo_url) && ( - - - Repo - - )} + ); diff --git a/apps/web/components/settings/plugins/plugin-detail.tsx b/apps/web/components/settings/plugins/plugin-detail.tsx index 569aee85f9..dcfd8069b2 100644 --- a/apps/web/components/settings/plugins/plugin-detail.tsx +++ b/apps/web/components/settings/plugins/plugin-detail.tsx @@ -12,6 +12,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; import { PluginConfigForm } from "./plugin-config-form"; import { PluginManifestCard } from "./plugin-manifest-card"; +import { PluginRepoLink } from "./plugin-repo-link"; import { PluginStatusBadge } from "./plugin-status-badge"; import { UninstallPluginDialog } from "./uninstall-plugin-dialog"; import { usePluginActions } from "./use-plugin-actions"; @@ -97,8 +98,11 @@ function PluginDetailHeader({ plugin }: PluginDetailHeaderProps) { )} -
- {plugin.id} · v{plugin.version} +
+ + {plugin.id} · v{plugin.version} + +
{plugin.description && (

{plugin.description}

diff --git a/apps/web/components/settings/plugins/plugin-repo-link.test.tsx b/apps/web/components/settings/plugins/plugin-repo-link.test.tsx new file mode 100644 index 0000000000..912fa4e40b --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-repo-link.test.tsx @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { PluginRepoLink, isHttpUrl } from "./plugin-repo-link"; + +afterEach(() => cleanup()); + +describe("isHttpUrl", () => { + it("accepts http and https, rejects other schemes and blanks", () => { + expect(isHttpUrl("https://github.com/kdlbs/kandev-plugin-acme")).toBe(true); + expect(isHttpUrl("http://example.com")).toBe(true); + expect(isHttpUrl(" https://example.com ")).toBe(true); + expect(isHttpUrl("javascript:alert(1)")).toBe(false); + expect(isHttpUrl("ftp://example.com")).toBe(false); + expect(isHttpUrl("")).toBe(false); + expect(isHttpUrl(undefined)).toBe(false); + expect(isHttpUrl(null)).toBe(false); + }); +}); + +describe("PluginRepoLink", () => { + it("renders an external link for an http(s) URL", () => { + render(); + const link = screen.getByTestId("plugin-repo-link"); + expect(link.getAttribute("href")).toBe("https://github.com/kdlbs/kandev-plugin-acme"); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noreferrer"); + }); + + it("renders nothing for a javascript: (or other non-http) scheme", () => { + render(); + expect(screen.queryByTestId("plugin-repo-link")).toBeNull(); + }); + + it("renders nothing for an empty URL", () => { + render(); + expect(screen.queryByTestId("plugin-repo-link")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/plugins/plugin-repo-link.tsx b/apps/web/components/settings/plugins/plugin-repo-link.tsx new file mode 100644 index 0000000000..c8adfd38de --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-repo-link.tsx @@ -0,0 +1,39 @@ +import { IconExternalLink } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +/** + * Guard for rendering a plugin-supplied repo URL as an ``. A plugin's + * repo_url comes from its manifest (installed plugins) or an untrusted + * index.json (marketplace catalog), so only http(s) is allowed — a + * `javascript:` (or other) scheme would execute in the operator's session on + * click, and `rel`/`target` do NOT block it. + */ +export function isHttpUrl(url: string | undefined | null): boolean { + if (!url) return false; + return /^https?:\/\//i.test(url.trim()); +} + +/** + * A guarded external "Repo" link to a plugin's source repository. Renders + * nothing when the URL is empty or not http(s). Shared by the marketplace + * catalog card and the installed-plugin list/detail so the scheme guard and + * link markup live in one place. + */ +export function PluginRepoLink({ url, className }: { url?: string | null; className?: string }) { + if (!isHttpUrl(url)) return null; + return ( + + + Repo + + ); +} diff --git a/apps/web/components/settings/plugins/plugin-row.test.tsx b/apps/web/components/settings/plugins/plugin-row.test.tsx index bcbe879a4f..574d187535 100644 --- a/apps/web/components/settings/plugins/plugin-row.test.tsx +++ b/apps/web/components/settings/plugins/plugin-row.test.tsx @@ -80,3 +80,45 @@ describe("PluginRow update button", () => { expect(screen.queryByTestId("plugin-update-acme")).toBeNull(); }); }); + +describe("PluginRow repo link", () => { + it("renders a Repo link when the plugin declares an http(s) repo_url", () => { + render( + , + ); + const link = screen.getByTestId("plugin-repo-link"); + expect(link.getAttribute("href")).toBe("https://github.com/kdlbs/kandev-plugin-acme"); + }); + + it("renders no Repo link when the plugin declares no repo_url", () => { + render( + , + ); + expect(screen.queryByTestId("plugin-repo-link")).toBeNull(); + }); + + it("renders no Repo link for a non-http(s) repo_url scheme", () => { + render( + , + ); + expect(screen.queryByTestId("plugin-repo-link")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/plugins/plugin-row.tsx b/apps/web/components/settings/plugins/plugin-row.tsx index 12d91733b1..cb0e446cc2 100644 --- a/apps/web/components/settings/plugins/plugin-row.tsx +++ b/apps/web/components/settings/plugins/plugin-row.tsx @@ -4,6 +4,7 @@ import { IconArrowUpCircle } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import Link from "@/components/routing/app-link"; +import { PluginRepoLink } from "./plugin-repo-link"; import { PluginStatusBadge } from "./plugin-status-badge"; import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins"; @@ -61,8 +62,11 @@ export function PluginRow({ )}
-
- {plugin.id} · v{plugin.version} +
+ + {plugin.id} · v{plugin.version} + +
{plugin.description && (
{plugin.description}
diff --git a/apps/web/lib/types/plugins.ts b/apps/web/lib/types/plugins.ts index 66ac6901a4..bc84589515 100644 --- a/apps/web/lib/types/plugins.ts +++ b/apps/web/lib/types/plugins.ts @@ -52,6 +52,13 @@ export interface PluginRecord { description: string; author: string; categories: string[]; + /** + * Optional URL to the plugin's source repository, declared by the author in + * the manifest (`repo_url`). Rendered as a guarded "Repo" link in the plugin + * list and detail; omitted when the plugin declares none. The backend + * enforces an http(s) scheme at registration, but the UI guards again. + */ + repo_url?: string; capabilities: PluginCapabilities; webhooks?: PluginWebhook[]; config_schema?: Record; diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index 736ae96bd0..657fadfbcf 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -22,6 +22,7 @@ display_name: "Slack Notifications" description: "Post to Slack on task events, relay messages to agents" author: "kandev" categories: ["connector"] # connector | automation | tools | analytics +repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins runtime: type: binary # only supported value today @@ -72,6 +73,7 @@ ui: # optional native frontend plugin | `author` | no | string | Free-form. | | `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. | | `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. | +| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. | | `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. | | `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. | | `min_kandev_version` | no | string | Optional advisory; not currently enforced by the installer. |