feat(plugins): show source repo link on installed plugin list and settings (#1821)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-20 14:03:52 +01:00
committed by GitHub
parent 77529e1eab
commit 91bdee3d3a
13 changed files with 215 additions and 26 deletions
@@ -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
@@ -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)
@@ -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"`
@@ -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 {
@@ -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 {
@@ -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 <a href>. 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}
</Badge>
))}
{isHttpUrl(entry.repo_url) && (
<a
href={entry.repo_url}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 hover:text-foreground cursor-pointer"
>
<IconExternalLink className="h-3.5 w-3.5" />
Repo
</a>
)}
<PluginRepoLink url={entry.repo_url} className="ml-auto" />
</div>
</div>
);
@@ -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) {
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground font-mono">
{plugin.id} · v{plugin.version}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono">
{plugin.id} · v{plugin.version}
</span>
<PluginRepoLink url={plugin.repo_url} />
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
@@ -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(<PluginRepoLink url="https://github.com/kdlbs/kandev-plugin-acme" />);
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(<PluginRepoLink url="javascript:alert(document.cookie)" />);
expect(screen.queryByTestId("plugin-repo-link")).toBeNull();
});
it("renders nothing for an empty URL", () => {
render(<PluginRepoLink url="" />);
expect(screen.queryByTestId("plugin-repo-link")).toBeNull();
});
});
@@ -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 href>`. 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 (
<a
href={url ?? undefined}
target="_blank"
rel="noreferrer"
data-testid="plugin-repo-link"
className={cn(
"inline-flex items-center gap-1 hover:text-foreground cursor-pointer",
className,
)}
>
<IconExternalLink className="h-3.5 w-3.5" />
Repo
</a>
);
}
@@ -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(
<PluginRow
plugin={plugin({ repo_url: "https://github.com/kdlbs/kandev-plugin-acme" })}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
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(
<PluginRow
plugin={plugin()}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
expect(screen.queryByTestId("plugin-repo-link")).toBeNull();
});
it("renders no Repo link for a non-http(s) repo_url scheme", () => {
render(
<PluginRow
plugin={plugin({ repo_url: "javascript:alert(document.cookie)" })}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
expect(screen.queryByTestId("plugin-repo-link")).toBeNull();
});
});
@@ -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({
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground font-mono truncate">
{plugin.id} · v{plugin.version}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono truncate">
{plugin.id} · v{plugin.version}
</span>
<PluginRepoLink url={plugin.repo_url} />
</div>
{plugin.description && (
<div className="text-xs text-muted-foreground">{plugin.description}</div>
+7
View File
@@ -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<string, unknown>;
+2
View File
@@ -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\<string,string\> | Key is `<goos>-<goarch>` (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. |