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:
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user