Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ config/langgraph-config.yaml
# the wizard has been run. Not config to commit.
config/.setup-complete

# Git-installed plugins (ADR 0027) — code fetched from a URL into the live plugins
# dir. NOT committed; it's reproducible from the committed `plugins.lock` via
# `python -m server plugin sync`. (The lock IS committed — see !plugins.lock.)
config/plugins/
!plugins.lock

# Local-run artifacts — autostart stdout/stderr logs + memory middleware
# fallback directory when /sandbox is not available.
logs/
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
## [Unreleased]

## [0.20.0] - 2026-06-07

### Added
- **Install plugins from a git URL** (ADR 0027, PR1) — `python -m server plugin
install <git-url> [--ref <tag|sha>]` clones a plugin repo into the live plugins
dir (already discovered by the loader), **pinned to a resolved commit SHA** and
recorded in a committed **`plugins.lock`** for reproducible installs
(`plugin sync` re-clones the exact set). Also `plugin list` / `uninstall` /
`sync`. Safety baked in: **install ≠ enable ≠ trust** — it only fetches code +
reads the manifest (data), never imports the plugin and never pip-installs its
deps (`requires_pip` is declared, installed explicitly); it refuses to shadow a
built-in, rejects a repo with no manifest, drops git metadata, skips submodules,
and supports an optional `plugins.sources.allow` allowlist. Manifest gains
`requires_pip` / `repository` / `homepage` / `min_protoagent_version`. A console
**Plugins panel** (Settings → Integrations, PR2) installs from a URL, lists
installed plugins with their manifest + declared capabilities for review, shows
enabled state + the "enable in config + restart" hint, and uninstalls — backed by
`/api/plugins/installed|install` + `DELETE /api/plugins/{id}`. PR3 adds the safety
rails: **`plugin install-deps <id>`** (the explicit, separate pip step) with a
clear "declared deps not installed — run install-deps" diagnostic when an enabled
plugin's deps are missing; **audit logging** of install/uninstall/install-deps;
and a **`plugins.sources.allow`** allowlist (host/org globs) enforced on CLI +
console installs. PR4 makes a plugin repo a **full bundle**: `register()` already
contributes tools / subagents / routes / MCP / views, and conventional
**`skills/`** (SKILL.md) + **`workflows/`** (`*.yaml`) subdirs are now
auto-discovered (data — no boilerplate; `register_workflow_dir()` for non-standard
paths), so installing a repo pulls in skills + workflows too. Publish + install
guide: [`plugin-registry.md`](docs/guides/plugin-registry.md). See
[ADR 0027](docs/adr/0027-install-plugins-from-git-url.md).

## [0.19.0] - 2026-06-06

### Added
Expand Down
27 changes: 27 additions & 0 deletions apps/web/e2e/mock-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ async function readBody(req) {
}

// GET API routes → canned fixtures.
// Git-installed plugins (ADR 0027) — mutable so install/uninstall round-trip in e2e.
let INSTALLED_PLUGINS = [];

function handleApiGet(pathname) {
switch (pathname) {
case "/api/runtime/status":
Expand Down Expand Up @@ -106,6 +109,8 @@ function handleApiGet(pathname) {
return DELEGATE_TYPES;
case "/api/delegates":
return DELEGATES;
case "/api/plugins/installed":
return { plugins: INSTALLED_PLUGINS };
case "/api/workflows":
return { workflows: WORKFLOWS };
case "/api/activity":
Expand Down Expand Up @@ -247,6 +252,28 @@ const server = createServer(async (req, res) => {
if (req.method === "DELETE" && /^\/api\/playbooks\/\d+$/.test(pathname)) {
return sendJson(res, { enabled: true, deleted: true });
}
if (pathname === "/api/plugins/install") {
const id = (String(body.url || "").replace(/\.git$/, "").split("/").pop()) || "ext_plugin";
const sha = "abc1234567def8900000000000000000000abcd";
INSTALLED_PLUGINS = INSTALLED_PLUGINS.filter((p) => p.id !== id).concat({
id, source_url: body.url, requested_ref: body.ref || "", resolved_sha: sha,
present: true, enabled: false,
manifest: { name: id, version: "0.1.0", description: "installed via console", requires_pip: [], views: [] },
});
return sendJson(res, {
installed: {
id, name: id, version: "0.1.0", description: "installed via console",
resolved_sha: sha, source_url: body.url, requires_pip: [], capabilities: {},
contributes: { views: [], secrets: [] },
},
restart_required: true,
});
}
if (req.method === "DELETE" && /^\/api\/plugins\/[^/]+$/.test(pathname)) {
const id = decodeURIComponent(pathname.split("/").pop());
INSTALLED_PLUGINS = INSTALLED_PLUGINS.filter((p) => p.id !== id);
return sendJson(res, { ok: true });
}
return sendJson(res, { ok: true });
}
// Plugin-served pages (ADR 0026) — a tiny listener page so the e2e can assert
Expand Down
41 changes: 41 additions & 0 deletions apps/web/e2e/plugin-install.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";

// Console Plugins panel (ADR 0027, PR2) — install a plugin from a git URL under
// Settings → Integrations; the installed list round-trips install → uninstall.

async function openPluginsPanel(page) {
await page.goto("/app/", { waitUntil: "load" });
await page.locator(".rail").getByRole("button", { name: "Settings", exact: true }).click();
await page.locator(".stage-subnav").getByRole("button", { name: "Integrations", exact: true }).click();
await expect(page.getByRole("heading", { name: "Plugins" })).toBeVisible();
}

test("install a plugin from a git URL, then uninstall it", async ({ page }) => {
await openPluginsPanel(page);

await expect(page.getByText("No git-installed plugins yet.")).toBeVisible();

// Install
await page.getByLabel("plugin git URL").fill("https://github.com/acme/protoagent-plugin-widgets");
await page.getByRole("button", { name: "Install", exact: true }).click();

// Row appears, marked NOT enabled (install ≠ enable).
const row = page.locator(".plugin-row");
await expect(row).toHaveCount(1);
await expect(row.locator(".plugin-row-title")).toContainText("protoagent-plugin-widgets");
await expect(row.getByText("not enabled", { exact: true })).toBeVisible();
await expect(row.getByText(/add .*to.*plugins\.enabled/i)).toBeVisible();

// Uninstall
await row.getByRole("button", { name: /uninstall/i }).click();
await expect(page.locator(".plugin-row")).toHaveCount(0);
await expect(page.getByText("No git-installed plugins yet.")).toBeVisible();
});

test("install surfaces a bad-URL error from the server", async ({ page }) => {
await openPluginsPanel(page);
// Empty URL keeps the button disabled; a URL the mock accepts installs fine —
// so just assert the form is present + actionable.
await expect(page.getByLabel("plugin git URL")).toBeVisible();
await expect(page.getByRole("button", { name: "Install", exact: true })).toBeDisabled();
});
20 changes: 20 additions & 0 deletions apps/web/src/app/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -3111,3 +3111,23 @@ textarea {
.plugin-view-body { position: relative; flex: 1; min-height: 0; }
.plugin-view-frame { width: 100%; height: 100%; border: 0; background: var(--bg); display: block; }
.plugin-view-state { display: flex; align-items: center; gap: 8px; padding: 24px; color: var(--fg-muted); font-size: 13px; }

/* Plugins panel (ADR 0027) — install from a git URL, under Settings → Integrations. */
.plugin-install-form { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
.plugin-install-form input { flex: 1; min-width: 220px; padding: 7px 10px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: 7px; color: var(--fg); font-size: 13px; }
.plugin-install-status { font-size: 12px; color: var(--fg-secondary); margin: 4px 0 10px; }
.plugin-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.plugin-row { display: flex; align-items: flex-start; gap: 10px; padding: 12px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: 9px; }
.plugin-row-main { flex: 1; min-width: 0; }
.plugin-row-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.plugin-ver { font-size: 11px; color: var(--fg-muted); }
.plugin-state { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; padding: 1px 6px; border-radius: 999px; }
.plugin-state.on { background: rgba(70,196,106,.15); color: #46c46a; }
.plugin-state.off { background: var(--bg-hover); color: var(--fg-muted); }
.plugin-state.warn { background: rgba(245,180,80,.15); color: #f5b450; display: inline-flex; align-items: center; gap: 3px; }
.plugin-desc { font-size: 12px; color: var(--fg-secondary); margin: 4px 0; }
.plugin-meta { font-size: 11px; color: var(--fg-muted); margin: 2px 0; overflow: hidden; text-overflow: ellipsis; }
.plugin-review { font-size: 11px; color: var(--fg-muted); margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 12px; }
.plugin-review .warn { color: #f5b450; display: inline-flex; align-items: center; gap: 3px; }
.plugin-enable-hint { font-size: 11px; color: var(--fg-secondary); margin: 6px 0 0; }
.btn-icon.danger:hover { color: #ef6b6b; }
15 changes: 15 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
GoalState,
HitlPayload,
InboxItem,
InstalledPlugin,
PluginInstallSummary,
KnowledgeChunk,
NotesWorkspace,
RuntimeStatus,
Expand Down Expand Up @@ -811,6 +813,19 @@ export const api = {
delegates() {
return request<{ delegates: DelegateView[] }>("/api/delegates");
},
// Git-installed plugins (ADR 0027). install fetches code only (does NOT enable).
installedPlugins() {
return request<{ plugins: InstalledPlugin[] }>("/api/plugins/installed");
},
installPlugin(url: string, ref?: string, force?: boolean) {
return request<{ installed: PluginInstallSummary; restart_required: boolean }>(
"/api/plugins/install",
{ method: "POST", body: { url, ref: ref || undefined, force: force || undefined } },
);
},
uninstallPlugin(id: string) {
return request<{ ok: boolean }>(`/api/plugins/${encodeURIComponent(id)}`, { method: "DELETE" });
},
createDelegate(entry: Record<string, unknown>) {
return request<{ ok: boolean; message: string; delegates: DelegateView[] }>("/api/delegates", {
method: "POST",
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const queryKeys = {
runtime: ["runtime"] as const,
delegates: ["delegates"] as const,
delegateTypes: ["delegates", "types"] as const,
installedPlugins: ["plugins", "installed"] as const,
};

// Goals the agent works toward (goal mode). Lives in the right sidebar and
Expand Down Expand Up @@ -115,6 +116,13 @@ export const delegatesQuery = () =>
retry: false,
});

export const installedPluginsQuery = () =>
queryOptions({
queryKey: queryKeys.installedPlugins,
queryFn: () => api.installedPlugins(),
retry: false,
});

export const delegateTypesQuery = () =>
queryOptions({
queryKey: queryKeys.delegateTypes,
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,44 @@ export type PluginView = {
tabs?: { id: string; label: string; path: string }[];
};

// A git-installed plugin (ADR 0027) — a plugins.lock entry enriched with its
// manifest + enabled state for the console Plugins panel.
export type InstalledPlugin = {
id: string;
source_url: string;
requested_ref: string;
resolved_sha: string;
installed_at?: string;
by?: string;
present: boolean;
enabled: boolean;
manifest?: {
name: string;
version: string;
description: string;
repository?: string;
homepage?: string;
capabilities?: Record<string, unknown>;
requires_env?: string[];
requires_pip?: string[];
views?: string[];
secrets?: string[];
};
};

// The summary returned right after installing (the review card).
export type PluginInstallSummary = {
id: string;
name: string;
version: string;
description: string;
resolved_sha: string;
source_url: string;
requires_pip: string[];
capabilities: Record<string, unknown>;
contributes: { views: string[]; secrets: string[] };
};

export type SlashCommand = {
name: string;
description: string;
Expand Down
136 changes: 136 additions & 0 deletions apps/web/src/settings/PluginsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Package, Plus, ShieldAlert, Trash2 } from "lucide-react";
import { useState } from "react";

import { api } from "../lib/api";
import { installedPluginsQuery, queryKeys } from "../lib/queries";
import type { InstalledPlugin } from "../lib/types";

// Plugins panel (ADR 0027) — install plugins from a git URL, under Settings →
// Integrations. Mirrors the delegates panel. Read non-suspense so a 404 shows a
// hint rather than blanking Settings. Install fetches code only (install ≠
// enable): enabling stays a config + restart decision, surfaced here.
const REGISTRY_GUIDE_URL = "https://protolabsai.github.io/protoAgent/guides/plugin-registry";

export function PluginsSection() {
const qc = useQueryClient();
const list = useQuery(installedPluginsQuery());
const [url, setUrl] = useState("");
const [ref, setRef] = useState("");
const [status, setStatus] = useState("");

const invalidate = () => qc.invalidateQueries({ queryKey: queryKeys.installedPlugins });

const install = useMutation({
mutationFn: () => api.installPlugin(url.trim(), ref.trim() || undefined),
onSuccess: (res) => {
const s = res.installed;
const deps = s.requires_pip.length ? ` — declares deps (install manually): ${s.requires_pip.join(", ")}` : "";
setStatus(`✓ installed ${s.id} v${s.version} @ ${s.resolved_sha.slice(0, 10)} — NOT enabled yet${deps}`);
setUrl("");
setRef("");
invalidate();
},
onError: (e: unknown) => setStatus(`✗ ${e instanceof Error ? e.message : "install failed"}`),
});

const remove = useMutation({
mutationFn: (id: string) => api.uninstallPlugin(id),
onSuccess: () => { setStatus("✓ uninstalled"); invalidate(); },
onError: (e: unknown) => setStatus(`✗ ${e instanceof Error ? e.message : "uninstall failed"}`),
});

const plugins = list.data?.plugins ?? [];

return (
<section className="settings-section">
<header className="settings-section-head">
<h3><Package size={16} /> Plugins</h3>
<p className="settings-section-sub">
Install a plugin from a git URL. Fetching code never runs it — review, then{" "}
<strong>enable</strong> it (add to <code>plugins.enabled</code> and restart). Untrusted code?
Use an <a href="https://protolabsai.github.io/protoAgent/guides/mcp" target="_blank" rel="noreferrer">MCP server</a> instead.{" "}
<a href={REGISTRY_GUIDE_URL} target="_blank" rel="noreferrer">Guide</a>.
</p>
</header>

{/* Install form */}
<div className="plugin-install-form">
<input
type="text"
placeholder="https://github.com/owner/protoagent-plugin-x"
value={url}
onChange={(e) => setUrl(e.target.value)}
aria-label="plugin git URL"
/>
<input
type="text"
placeholder="ref (tag / sha — optional)"
value={ref}
onChange={(e) => setRef(e.target.value)}
aria-label="git ref"
style={{ maxWidth: 200 }}
/>
<button
className="btn"
disabled={!url.trim() || install.isPending}
onClick={() => { setStatus(""); install.mutate(); }}
>
{install.isPending ? <Loader2 className="spin" size={15} /> : <Plus size={15} />} Install
</button>
</div>
{status ? <p className="plugin-install-status" role="status">{status}</p> : null}

{/* Installed list */}
{list.isError ? (
<p className="settings-section-sub">Plugin install API unavailable.</p>
) : plugins.length === 0 ? (
<p className="settings-section-sub">No git-installed plugins yet.</p>
) : (
<ul className="plugin-list">
{plugins.map((p) => <PluginRow key={p.id} p={p} onRemove={() => remove.mutate(p.id)} removing={remove.isPending} />)}
</ul>
)}
</section>
);
}

function PluginRow({ p, onRemove, removing }: { p: InstalledPlugin; onRemove: () => void; removing: boolean }) {
const m = p.manifest;
const caps = m?.capabilities && Object.keys(m.capabilities).length ? m.capabilities : null;
return (
<li className="plugin-row">
<div className="plugin-row-main">
<div className="plugin-row-title">
<strong>{m?.name || p.id}</strong>
{m?.version ? <span className="plugin-ver">v{m.version}</span> : null}
<span className={`plugin-state ${p.enabled ? "on" : "off"}`}>{p.enabled ? "enabled" : "not enabled"}</span>
{!p.present ? <span className="plugin-state warn"><AlertTriangle size={12} /> missing — run sync</span> : null}
</div>
{m?.description ? <p className="plugin-desc">{m.description}</p> : null}
<p className="plugin-meta">
<span title={p.source_url}>{p.source_url}</span> · <code>{p.resolved_sha.slice(0, 10)}</code>
{p.requested_ref ? ` · ${p.requested_ref}` : ""}
</p>
{/* review surface: what this plugin can do (ADR 0027 D5) */}
{(m?.views?.length || m?.requires_pip?.length || m?.requires_env?.length || m?.secrets?.length || caps) ? (
<p className="plugin-review">
{m?.views?.length ? <span>views: {m.views.join(", ")}</span> : null}
{m?.requires_pip?.length ? <span className="warn"><ShieldAlert size={12} /> deps (install manually): {m.requires_pip.join(", ")}</span> : null}
{m?.requires_env?.length ? <span>env: {m.requires_env.join(", ")}</span> : null}
{m?.secrets?.length ? <span>secrets: {m.secrets.join(", ")}</span> : null}
{caps ? <span>capabilities: {JSON.stringify(caps)}</span> : null}
</p>
) : null}
{!p.enabled ? (
<p className="plugin-enable-hint">
To enable: add <code>{p.id}</code> to <code>plugins.enabled</code> in config, then restart.
</p>
) : null}
</div>
<button className="btn-icon danger" title="Uninstall" disabled={removing} onClick={onRemove} aria-label={`uninstall ${p.id}`}>
<Trash2 size={15} />
</button>
</li>
);
}
Loading
Loading