diff --git a/.gitignore b/.gitignore index b05eccd..e9e79ea 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ca2a0..c99c870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [--ref ]` 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 `** (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 diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index cdef1f6..379b8c5 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -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": @@ -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": @@ -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 diff --git a/apps/web/e2e/plugin-install.spec.ts b/apps/web/e2e/plugin-install.spec.ts new file mode 100644 index 0000000..243f0e7 --- /dev/null +++ b/apps/web/e2e/plugin-install.spec.ts @@ -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(); +}); diff --git a/apps/web/src/app/theme.css b/apps/web/src/app/theme.css index 0a454bd..4680976 100644 --- a/apps/web/src/app/theme.css +++ b/apps/web/src/app/theme.css @@ -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; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 7d6d43e..6e87fed 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -10,6 +10,8 @@ import type { GoalState, HitlPayload, InboxItem, + InstalledPlugin, + PluginInstallSummary, KnowledgeChunk, NotesWorkspace, RuntimeStatus, @@ -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) { return request<{ ok: boolean; message: string; delegates: DelegateView[] }>("/api/delegates", { method: "POST", diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 9c16394..432552f 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -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 @@ -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, diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 14dbc86..067e1c9 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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; + 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; + contributes: { views: string[]; secrets: string[] }; +}; + export type SlashCommand = { name: string; description: string; diff --git a/apps/web/src/settings/PluginsSection.tsx b/apps/web/src/settings/PluginsSection.tsx new file mode 100644 index 0000000..22b8d64 --- /dev/null +++ b/apps/web/src/settings/PluginsSection.tsx @@ -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 ( +
+
+

Plugins

+

+ Install a plugin from a git URL. Fetching code never runs it — review, then{" "} + enable it (add to plugins.enabled and restart). Untrusted code? + Use an MCP server instead.{" "} + Guide. +

+
+ + {/* Install form */} +
+ setUrl(e.target.value)} + aria-label="plugin git URL" + /> + setRef(e.target.value)} + aria-label="git ref" + style={{ maxWidth: 200 }} + /> + +
+ {status ?

{status}

: null} + + {/* Installed list */} + {list.isError ? ( +

Plugin install API unavailable.

+ ) : plugins.length === 0 ? ( +

No git-installed plugins yet.

+ ) : ( +
    + {plugins.map((p) => remove.mutate(p.id)} removing={remove.isPending} />)} +
+ )} +
+ ); +} + +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 ( +
  • +
    +
    + {m?.name || p.id} + {m?.version ? v{m.version} : null} + {p.enabled ? "enabled" : "not enabled"} + {!p.present ? missing — run sync : null} +
    + {m?.description ?

    {m.description}

    : null} +

    + {p.source_url} · {p.resolved_sha.slice(0, 10)} + {p.requested_ref ? ` · ${p.requested_ref}` : ""} +

    + {/* 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) ? ( +

    + {m?.views?.length ? views: {m.views.join(", ")} : null} + {m?.requires_pip?.length ? deps (install manually): {m.requires_pip.join(", ")} : null} + {m?.requires_env?.length ? env: {m.requires_env.join(", ")} : null} + {m?.secrets?.length ? secrets: {m.secrets.join(", ")} : null} + {caps ? capabilities: {JSON.stringify(caps)} : null} +

    + ) : null} + {!p.enabled ? ( +

    + To enable: add {p.id} to plugins.enabled in config, then restart. +

    + ) : null} +
    + +
  • + ); +} diff --git a/apps/web/src/settings/SettingsSurface.tsx b/apps/web/src/settings/SettingsSurface.tsx index b015186..ae07311 100644 --- a/apps/web/src/settings/SettingsSurface.tsx +++ b/apps/web/src/settings/SettingsSurface.tsx @@ -12,6 +12,7 @@ import { api } from "../lib/api"; import { queryKeys, settingsSchemaQuery } from "../lib/queries"; import type { SettingsField } from "../lib/types"; import { DelegatesSection } from "./DelegatesSection"; +import { PluginsSection } from "./PluginsSection"; // Generic settings surface — renders whatever GET /api/settings/schema returns, // so it stays in sync as config grows. Saving POSTs the changed fields and the @@ -43,12 +44,6 @@ function SettingsBody() { const [dirty, setDirty] = useState>({}); const [status, setStatus] = useState(""); - // The delegates plugin (ADR 0025) contributes no settings-schema group — it's a - // CRUD panel — so probe it to know whether to surface the Integrations tab even - // when no schema-driven integration (Discord/Google) is enabled. Shares the - // delegates query key, so it's deduped with DelegatesSection's list. - const delegatesProbe = useQuery({ queryKey: queryKeys.delegates, queryFn: () => api.delegates(), retry: false }); - // Category sub-nav (ADR 0020): the server tags each group with a category and // orders them, so we derive the ordered category list by first appearance. const categories = useMemo(() => { @@ -57,9 +52,11 @@ function SettingsBody() { const c = g.category || "Integrations"; if (!seen.includes(c)) seen.push(c); } - if (delegatesProbe.isSuccess && !seen.includes("Integrations")) seen.push("Integrations"); + // Integrations always shows — the Plugins panel (ADR 0027) is core, and the + // delegates panel appears there when its plugin is enabled. + if (!seen.includes("Integrations")) seen.push("Integrations"); return seen; - }, [groups, delegatesProbe.isSuccess]); + }, [groups]); const [activeCategory, setActiveCategory] = useState(categories[0] || ""); // The active category must stay valid if the schema reshapes under us. const category = categories.includes(activeCategory) ? activeCategory : categories[0] || ""; @@ -252,6 +249,8 @@ function SettingsBody() { {/* The delegate registry (ADR 0025) isn't part of the settings schema — it's a CRUD surface, rendered as a custom panel under Integrations. */} {category === "Integrations" ? : null} + {/* Git-installed plugins (ADR 0027) — install/manage from a URL, under Integrations. */} + {category === "Integrations" ? : null} ); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7e2a64a..08efc0e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -56,6 +56,7 @@ export default defineConfig({ { text: "MCP servers", link: "/guides/mcp" }, { text: "Plugins", link: "/guides/plugins" }, { text: "Plugin console views", link: "/guides/plugin-views" }, + { text: "Install & publish plugins (git URLs)", link: "/guides/plugin-registry" }, { text: "Goal mode", link: "/guides/goal-mode" }, { text: "Scheduler", link: "/guides/scheduler" }, { text: "Discord surface", link: "/guides/discord" }, diff --git a/docs/adr/0027-install-plugins-from-git-url.md b/docs/adr/0027-install-plugins-from-git-url.md new file mode 100644 index 0000000..5945483 --- /dev/null +++ b/docs/adr/0027-install-plugins-from-git-url.md @@ -0,0 +1,140 @@ +# 0027 — Install plugins from a git URL (shareable plugin repos) + +Status: **Accepted** (sliced) + +## Context + +Plugins ([ADR 0018](./0018-plugin-surfaces-routes-subagents.md) backend, +[0019](./0019-plugin-config-settings-secrets.md) settings, +[0026](./0026-plugin-contributed-console-surfaces.md) console surfaces) are a +full extension surface — but today a plugin must live **in-repo** (`plugins/`). +There's no way to make one in its own GitHub repo and share it. ComfyUI +popularized git-URL "custom nodes"; we want the same for protoAgent — author a +plugin repo, install it by URL — without ComfyUI's "clone = arbitrary code runs" +safety posture. This is the long-deferred Slice 5 (registry/marketplace) of +[ADR 0001](./0001-extensibility-and-plugin-architecture.md). + +Two facts shape the design: +- **The seam already exists.** `graph/plugins/loader.py::_plugin_roots()` already + discovers an **external** plugins dir (`/plugins`, outside the repo), + and discovery reads `protoagent.plugin.yaml` as **data without importing** the + plugin. So *fetching* a plugin never executes its code; only *enabling* it does. +- **In-process plugins share the interpreter.** Installing one means trusting its + code — exactly like adding a pip dependency. You **cannot** sandbox it with a + per-plugin venv (the code runs in the main interpreter). True isolation of + *untrusted* code is what **MCP** already provides (out-of-process, declared + tools). So git-URL plugins are best framed as **trusted, reviewed code**. + +## Decision + +A `plugin install ` flow (CLI **and** console) that clones into the +external plugins dir, pins to a resolved commit, records a lockfile, surfaces the +manifest + capabilities for review, and **never auto-enables or auto-runs code**. + +### D1 — Trust model: install ≠ enable ≠ trust + +Git-URL plugins are **trusted, in-process code** (you reviewed it, like a pip dep). +The three steps are distinct: +- **Install** = `git clone` + checkout pinned ref → code on disk. No import, no + execution (deps are **not** pip-installed — D4). +- **Discover** = read the manifest (data). No import. +- **Enable** = `plugins.enabled` → `register()` runs. **This** is the trust decision. + +For **untrusted** third-party code, use **MCP** (out-of-process, sandboxable), not +a git plugin. Stated prominently in the docs + the install review. + +### D2 — Install location + reproducibility (lockfile, pinned SHA) + +Clone into `/plugins//` (already on `_plugin_roots`; gitignored +from the fork). Record every install in a committed **`plugins.lock`**: +`{id, source_url, requested_ref, resolved_sha, installed_at, by}`. Always pin to a +**resolved commit SHA** — never silently track a moving branch. `plugin sync` +re-clones the exact set from the lock (reproducible forks / CI / containers). + +### D3 — Source posture: any URL + mandatory review gate (+ optional allowlist) + +Any git URL is allowed, but **every install requires an explicit confirm** showing: +source URL, resolved SHA, manifest (id/name/version/description/repository), declared +capabilities (network/fs/secrets), and what it contributes (tools/views/routes/ +subagents). A fork can lock down with `plugins.sources.allow: [github.com/org/*]` +(refuse anything off-allowlist). **Default: open + gated** (builder-friendly, never +silent). + +### D4 — Dependencies: declare-only, explicit install (no surprise code-exec) + +Manifest gains `requires_pip: ["pkg>=x"]`. **`plugin install` fetches code only — it +does NOT pip-install** (pip runs arbitrary `setup.py`/build code, which would defeat +"install ≠ execute"). The operator installs deps as a **separate explicit step** +(`plugin install-deps `, or the shown `pip install` line) after reviewing them. +Missing deps → the plugin fails to import on **enable** with a clear "declared deps +not installed: …" message, not a cryptic ImportError. + +### D5 — Capabilities surfaced + audited (enforcement iterates) + +Before enable, surface declared capabilities (network hosts, filesystem scope, +secrets requested, tools/views/routes added) for operator review. Audit-log +install / enable / disable / uninstall (url, sha, operator, time) to the existing +audit log. **No hard in-process enforcement in v1** — honest: you can't sandbox +in-process Python. A per-plugin egress allowlist + fs fencing is a documented +fast-follow; untrusted code → MCP (D1). + +### D6 — Lifecycle: CLI **and** console + +- **CLI** (`python -m server plugin …`): `install [--ref ] [--enable]`, + `list`, `enable/disable `, `uninstall `, `sync` (from lock), + `install-deps `. `--enable` is opt-in; bare install does **not** enable (D1). +- **Console** (Settings → **Plugins**): paste URL → review card (manifest + caps + + resolved SHA) → install → enable toggle → uninstall. Mirrors the delegates panel. + +### D7 — Manifest additions (all data, all optional) + +`requires_pip: [..]` (D4); `repository:` / `homepage:` (provenance, shown in review); +`min_protoagent_version:` (compat — warn/refuse if the host is older). + +### D8 — Integrity rails + +- Pin to resolved SHA (D2); `--ref` accepts tag/branch/sha → resolved + recorded. +- Clone `--depth 1` at the ref; **no submodules** by default (a vector). +- Validate the manifest (id matches dir, required fields) before accepting; reject + a repo with no `protoagent.plugin.yaml` (not a plugin). +- Refuse to overwrite a built-in or existing id without `--force` (no silent + shadowing). +- Uninstall removes the dir + the lock entry. Cloned-but-disabled plugins are inert + (discovery is data-only). + +### D9 — Slices + +- **PR1:** manifest additions (`requires_pip`/`repository`/`min_protoagent_version`) + + installer core (clone → resolve SHA → validate manifest → write `plugins.lock`) + + `plugin list/install/uninstall/sync` CLI (no enable, no dep auto-run). Tests. +- **PR2:** console **Plugins** panel + the install/review/uninstall API (paste URL → + review card → install → enable/disable → uninstall). e2e. +- **PR3:** `requires_pip` + `install-deps` + missing-dep diagnostics; capability + review surfacing + audit logging; `plugins.sources.allow` enforcement. +- **PR4 — full bundle:** a plugin repo contributes the *whole* extension set, not + just tools. `register()` already covers tools / subagents / routes / MCP / views; + PR4 auto-discovers conventional **`skills/`** (SKILL.md) and **`workflows/`** + (`*.yaml`) subdirs (data — no `register_*` boilerplate; a `register_workflow_dir()` + exists for non-standard locations) so installing a repo pulls in skills + + workflows too. Docs: `guides/plugin-registry.md` (install + publish the full + bundle + the untrusted→MCP note). + +## Consequences + +- People author plugins as **standalone GitHub repos** and forks install them by + URL with a **reproducible lock** + an **informed review gate**. Completes the + extensibility arc: author (0018) → settings (0019) → surfaces (0026) → + **distribute (0027)**. +- Safety is **informed trust + verifiable supply chain + audit**, not a sandbox — + stated honestly; untrusted code routes to MCP. +- A future curated **index/registry** is a thin layer on top (it still installs via + this path). + +## Alternatives considered + +- **Auto-enable on install** — rejected: install ≠ trust (D1). +- **Auto pip-install on install** — rejected: surprise code-exec (D4). +- **Per-plugin venv isolation** — impossible for in-process plugins (shared + interpreter); real isolation = out-of-process = MCP. +- **Central registry/index first** — deferred: URL install is the primitive; an + index is curation on top. diff --git a/docs/adr/index.md b/docs/adr/index.md index 867e25c..86eb4d7 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -35,3 +35,4 @@ decision, numbered, never deleted (supersede instead). | [0024](./0024-spawn-cli-coding-agents-acp.md) | Spawn CLI coding agents over ACP (`code_with`) | Accepted (PR1 + PR3) | | [0025](./0025-unified-delegate-registry-and-panel.md) | Unified delegate registry + hot-swappable panel (`delegate_to`) | Accepted (complete; PR1–PR4) | | [0026](./0026-plugin-contributed-console-surfaces.md) | Plugin-contributed console surfaces (rail views + tabs) | Accepted (complete; PR1–PR3) | +| [0027](./0027-install-plugins-from-git-url.md) | Install plugins from a git URL (shareable plugin repos) | Accepted (sliced) | diff --git a/docs/guides/plugin-registry.md b/docs/guides/plugin-registry.md new file mode 100644 index 0000000..6f91378 --- /dev/null +++ b/docs/guides/plugin-registry.md @@ -0,0 +1,98 @@ +# Install & publish plugins (git URLs) + +Plugins can live in their own GitHub repo and be installed by URL — so you can +make one and share it, and pull others in. A plugin repo is a **full bundle**: it +can contribute tools, subagents, SKILL.md skills, workflows, console views, routes, +MCP servers, and config — all from the one repo. See +[ADR 0027](/adr/0027-install-plugins-from-git-url) for the design + safety model. + +## Install one + +**CLI:** +```sh +python -m server plugin install https://github.com/owner/protoagent-plugin-x --ref v1.0 +python -m server plugin list +python -m server plugin uninstall protoagent-plugin-x +python -m server plugin sync # re-clone the locked set (CI / fresh checkout) +python -m server plugin install-deps protoagent-plugin-x # explicit, separate +``` + +**Console:** Settings → Integrations → **Plugins** — paste the URL, review the +manifest + capabilities, install, uninstall. + +Either way, **install fetches code only — it does not enable or run it.** To +enable: add the plugin's id to `plugins.enabled` in your config and restart. + +```yaml +plugins: + enabled: [protoagent-plugin-x] +``` + +Install pins the **resolved commit SHA** and records it in a committed +`plugins.lock`, so `plugin sync` reproduces the exact set. The code itself is +gitignored (re-cloned from the lock). + +## Publish one + +A plugin is a directory (its own repo) with a manifest + a `register()`. The +**conventional layout** — everything here is picked up when the plugin is enabled: + +``` +my-plugin/ + protoagent.plugin.yaml # manifest (id, name, version, requires_pip, views, …) + __init__.py # def register(registry): … — tools, subagents, etc. + skills/ # SKILL.md skills — auto-discovered (data, no code) + my-skill/SKILL.md + workflows/ # *.yaml workflow recipes — auto-discovered (data) + my-recipe.yaml +``` + +`register(registry)` contributes the **code** extensions: + +```python +def register(registry): + registry.register_tool(my_tool) # a LangChain tool + registry.register_subagent(my_subagent) # a SubagentConfig + registry.register_router(my_router) # FastAPI routes at /plugins/ + registry.register_mcp_server(my_factory) # a managed MCP server + # skills/ and workflows/ are auto-discovered — no call needed. For a + # non-standard location: registry.register_workflow_dir("recipes") +``` + +`skills/` and `workflows/` are **data**, so they're auto-discovered from those +conventional subdirs — no boilerplate. **Console views** (a rail icon + page) are +declared in the manifest — see [Plugin console views](/guides/plugin-views). + +Declare pip dependencies (they are **not** auto-installed — see Safety): + +```yaml +# protoagent.plugin.yaml +id: my-plugin +name: My Plugin +version: 1.0.0 +repository: https://github.com/owner/my-plugin +requires_pip: ["httpx>=0.27"] +min_protoagent_version: "0.20.0" +``` + +## Safety + +The model is **informed trust + a verifiable supply chain**, not a sandbox — an +enabled plugin runs in-process *as the agent* (like a pip dependency). So: + +- **Install ≠ enable ≠ trust.** Installing only fetches code + reads the manifest + (data); it never imports the plugin. Enabling (`plugins.enabled`) is the trust + decision — review the manifest + capabilities first. +- **Deps are explicit.** `requires_pip` is declared, never auto-installed (pip runs + arbitrary build code). Run `plugin install-deps ` after reviewing them; a + missing dep gives a clear "run install-deps" message on enable. +- **Pinned + reproducible.** Installs pin a commit SHA in `plugins.lock`. +- **Optional source allowlist.** Lock installs down to trusted orgs: + ```yaml + plugins: + sources: + allow: ["github.com/yourorg/*"] + ``` +- **Audited.** install / uninstall / install-deps are written to the audit log. +- **Untrusted code? Use [MCP](/guides/mcp) instead** — it runs out-of-process and + is sandboxable. Git plugins are for code you've reviewed and trust. diff --git a/graph/config.py b/graph/config.py index 7c74512..aa61be3 100644 --- a/graph/config.py +++ b/graph/config.py @@ -334,6 +334,9 @@ class LangGraphConfig: # without deleting its directory or editing core. plugins_disabled: list[str] = field(default_factory=list) plugins_dir: str = "" + # Optional source allowlist for git-URL installs (ADR 0027 D3) — host/org globs + # (e.g. ``github.com/protoLabsAI/*``); empty = any URL allowed (gated install). + plugins_sources_allow: list[str] = field(default_factory=list) # Plugin-declared config sections (ADR 0019), keyed by the claimed top-level # section. Each value is the section's resolved config (manifest defaults ⊕ # YAML ⊕ secrets overlay). A plugin reads its own via plugin_config["
    "]. @@ -554,6 +557,7 @@ def from_yaml(cls, path: str | Path) -> "LangGraphConfig": plugins_enabled=list(plugins.get("enabled", []) or []), plugins_disabled=list(plugins.get("disabled", []) or []), plugins_dir=plugins.get("dir", cls.plugins_dir), + plugins_sources_allow=list((plugins.get("sources", {}) or {}).get("allow", []) or []), identity_name=identity.get("name", cls.identity_name), identity_operator=identity.get("operator", cls.identity_operator), a2a_skills=list(a2a.get("skills", []) or []), diff --git a/graph/plugins/cli.py b/graph/plugins/cli.py new file mode 100644 index 0000000..50d0510 --- /dev/null +++ b/graph/plugins/cli.py @@ -0,0 +1,82 @@ +"""`python -m server plugin …` — manage git-installed plugins (ADR 0027). + +A thin CLI over ``graph.plugins.installer``. Install fetches code only (it never +enables the plugin or installs its deps — both are explicit, by design). +""" + +from __future__ import annotations + +import argparse +import sys + +from graph.plugins import installer + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="python -m server plugin", + description="Install/manage plugins from git URLs (ADR 0027).", + ) + sub = p.add_subparsers(dest="cmd", required=True) + + pi = sub.add_parser("install", help="install a plugin from a git URL (does NOT enable it)") + pi.add_argument("url", help="git URL (https://, ssh://, git@, or a local path)") + pi.add_argument("--ref", default=None, help="tag, branch, or commit SHA to pin (default: default branch HEAD)") + pi.add_argument("--force", action="store_true", help="replace an already-installed plugin of the same id") + + sub.add_parser("list", help="list git-installed plugins (from plugins.lock)") + pu = sub.add_parser("uninstall", help="remove a git-installed plugin") + pu.add_argument("id") + sub.add_parser("sync", help="re-clone locked plugins at their pinned SHA (reproducible set)") + pd = sub.add_parser("install-deps", help="pip-install a plugin's declared requires_pip (explicit code-exec)") + pd.add_argument("id") + return p + + +def run_plugin_cli(argv: list[str]) -> int: + args = _build_parser().parse_args(argv) + try: + if args.cmd == "install": + s = installer.install(args.url, args.ref, force=args.force, + allow=installer.configured_allowlist()) + print(f"✓ installed {s['id']} v{s['version']} @ {s['resolved_sha'][:10]}") + if s["description"]: + print(f" {s['description']}") + if s["repository"]: + print(f" repo: {s['repository']}") + if s["requires_pip"]: + print(f" ⚠ declared deps (NOT installed — review, then install): {', '.join(s['requires_pip'])}") + print(f" pip install {' '.join(s['requires_pip'])}") + if s["contributes"]["views"]: + print(f" contributes views: {', '.join(s['contributes']['views'])}") + if s["capabilities"]: + print(f" declared capabilities: {s['capabilities']}") + print(f" NOT enabled. To enable, add '{s['id']}' to plugins.enabled in your config, then restart.") + return 0 + if args.cmd == "list": + rows = installer.list_installed() + if not rows: + print("(no git-installed plugins)") + return 0 + for e in rows: + mark = "" if e.get("present") else " [MISSING — run `plugin sync`]" + print(f" {e['id']:20} {e['resolved_sha'][:10]} {e['source_url']}{mark}") + return 0 + if args.cmd == "uninstall": + installer.uninstall(args.id) + print(f"✓ uninstalled {args.id}") + return 0 + if args.cmd == "sync": + for r in installer.sync(allow=installer.configured_allowlist()): + extra = f" ({r['error']})" if r.get("error") else "" + print(f" {r['id']}: {r['status']}{extra}") + return 0 + if args.cmd == "install-deps": + deps = installer.install_deps(args.id) + print(f"✓ installed {len(deps)} dep(s) for {args.id}: {', '.join(deps)}" if deps + else f"{args.id} declares no deps") + return 0 + except installer.InstallError as exc: + print(f"✗ {exc}", file=sys.stderr) + return 1 + return 0 diff --git a/graph/plugins/installer.py b/graph/plugins/installer.py new file mode 100644 index 0000000..def7400 --- /dev/null +++ b/graph/plugins/installer.py @@ -0,0 +1,273 @@ +"""Install plugins from a git URL (ADR 0027). + +Fetches a plugin repo into the **live** plugins dir (``/plugins/``, +the one ``loader._plugin_roots`` already discovers), pinned to a resolved commit +SHA and recorded in a committed ``plugins.lock`` for reproducibility. + +Safety model (ADR 0027): **install ≠ enable ≠ trust**. This module only puts code +on disk + reads the manifest (data) — it never imports the plugin and never +pip-installs its deps (``requires_pip`` is declared, installed explicitly later). +Enabling (``plugins.enabled`` → ``register()``) is the separate trust decision. +For *untrusted* code use MCP (out-of-process), not a git plugin. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +from graph.config_io import _BUNDLE_CONFIG_DIR, _live_config_dir +from graph.plugins.manifest import PluginManifest, load_manifest + +log = logging.getLogger(__name__) + +REPO_ROOT = _BUNDLE_CONFIG_DIR.parent +LOCK_PATH = Path(os.environ.get("PROTOAGENT_PLUGINS_LOCK", str(REPO_ROOT / "plugins.lock"))) + +_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$", re.IGNORECASE) +_ALLOWED_SCHEMES = ("https://", "http://", "git://", "ssh://", "git@", "file://", "/") + + +class InstallError(RuntimeError): + """A plugin install/uninstall/sync failed (bad URL, manifest, git, collision).""" + + +def live_plugins_dir() -> Path: + """Where git-installed plugins land — the live dir the loader discovers.""" + override = os.environ.get("PROTOAGENT_PLUGINS_DIR", "") + return Path(override).expanduser() if override else (_live_config_dir() / "plugins") + + +def _git(*args: str, cwd: Path | None = None) -> str: + proc = subprocess.run( + ["git", *args], cwd=str(cwd) if cwd else None, + capture_output=True, text=True, + ) + if proc.returncode != 0: + raise InstallError(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}") + return proc.stdout.strip() + + +def _validate_url(url: str) -> None: + if not any(url.startswith(s) for s in _ALLOWED_SCHEMES): + raise InstallError( + f"unsupported source {url!r} — use https://, ssh://, git@, or a local path." + ) + + +def _source_allowed(url: str, allow: list[str] | None) -> bool: + """Optional fork lock-down (ADR 0027 D3): if an allowlist is configured, the + URL must match one of its host/org globs (e.g. ``github.com/protoLabsAI/*``).""" + if not allow: + return True + import fnmatch + norm = re.sub(r"^(https?://|git://|ssh://|git@)", "", url).replace(":", "/") + return any(fnmatch.fnmatch(norm, pat) or fnmatch.fnmatch(norm, pat + "*") for pat in allow) + + +def _read_lock() -> dict: + if LOCK_PATH.exists(): + try: + return json.loads(LOCK_PATH.read_text()) + except (json.JSONDecodeError, OSError): + log.warning("[plugins] %s is unreadable — starting a fresh lock", LOCK_PATH) + return {"plugins": []} + + +def _write_lock(data: dict) -> None: + data["plugins"].sort(key=lambda e: e.get("id", "")) + LOCK_PATH.write_text(json.dumps(data, indent=2) + "\n") + + +def _audit(action: str, args: dict, summary: str, *, success: bool = True) -> None: + """Record install/uninstall/install-deps to the audit log (ADR 0027 D5).""" + try: + from audit import audit_logger + audit_logger.log( + session_id="plugins", tool=f"plugin.{action}", args=args, + result_summary=summary, duration_ms=0, success=success, + ) + except Exception: # noqa: BLE001 — auditing must never block the operation + log.debug("[plugins] audit log failed for %s", action, exc_info=True) + + +def configured_allowlist() -> list[str] | None: + """`plugins.sources.allow` read from the live config file (for the CLI, which + runs without a loaded LangGraphConfig). None = open.""" + try: + import yaml + cfg_path = _live_config_dir() / "langgraph-config.yaml" + if not cfg_path.exists(): + return None + data = yaml.safe_load(cfg_path.read_text()) or {} + allow = (((data.get("plugins") or {}).get("sources") or {}).get("allow")) or None + return [str(x) for x in allow] if allow else None + except Exception: # noqa: BLE001 + return None + + +def _summary(m: PluginManifest, *, source: str, ref: str, sha: str) -> dict: + return { + "id": m.id, "name": m.name, "version": m.version, "description": m.description, + "source_url": source, "requested_ref": ref, "resolved_sha": sha, + "repository": m.repository, "homepage": m.homepage, + "capabilities": m.capabilities, "requires_env": m.requires_env, + "requires_pip": m.requires_pip, "min_protoagent_version": m.min_protoagent_version, + # what it contributes — surfaced in the install review (ADR 0027 D3) + "contributes": { + "tools": bool(m.config_section), # heuristic; real tool list needs import + "views": [v.get("label") for v in m.views], + "secrets": m.secrets, + "settings": [s.get("key") for s in m.settings], + }, + } + + +def _clone(url: str, ref: str | None, dest: Path) -> str: + """Clone ``url`` at ``ref`` into ``dest``; return the resolved commit SHA.""" + if ref and _SHA_RE.match(ref): + # A specific commit: full clone (shallow can't reliably check out an + # arbitrary SHA), then check it out. + _git("clone", "--no-recurse-submodules", url, str(dest)) + _git("checkout", ref, cwd=dest) + elif ref: + # A tag or branch: shallow clone of just that ref. + _git("clone", "--depth", "1", "--no-recurse-submodules", "--branch", ref, url, str(dest)) + else: + _git("clone", "--depth", "1", "--no-recurse-submodules", url, str(dest)) + return _git("rev-parse", "HEAD", cwd=dest) + + +def install(url: str, ref: str | None = None, *, force: bool = False, + by: str = "cli", allow: list[str] | None = None) -> dict: + """Clone a plugin from ``url`` (at ``ref``) into the live plugins dir, pinned + to its resolved SHA, and record it in ``plugins.lock``. Does NOT enable it or + install its deps. Returns the install summary.""" + _validate_url(url) + if not _source_allowed(url, allow): + raise InstallError( + f"source {url!r} is not on plugins.sources.allow — add it or install from an allowed origin." + ) + + target_root = live_plugins_dir() + target_root.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="pa-plugin-") as tmp: + staging = Path(tmp) / "repo" + sha = _clone(url, ref, staging) + + manifest = load_manifest(staging) + if manifest is None: + raise InstallError( + f"{url!r} has no valid protoagent.plugin.yaml — not a protoAgent plugin." + ) + pid = manifest.id + + # No silent shadowing of a built-in (repo) plugin. + if (REPO_ROOT / "plugins" / pid).exists(): + raise InstallError(f"plugin id {pid!r} is a built-in — cannot install over it.") + + target = target_root / pid + if target.exists(): + if not force: + raise InstallError(f"plugin {pid!r} already installed — use --force to replace.") + shutil.rmtree(target) + + shutil.rmtree(staging / ".git", ignore_errors=True) # drop git metadata; lock holds provenance + shutil.move(str(staging), str(target)) + manifest = load_manifest(target) or manifest # re-read from final path + + summary = _summary(manifest, source=url, ref=ref or "", sha=sha) + lock = _read_lock() + lock["plugins"] = [e for e in lock["plugins"] if e.get("id") != pid] + lock["plugins"].append({ + "id": pid, "source_url": url, "requested_ref": ref or "", + "resolved_sha": sha, "installed_at": datetime.now(timezone.utc).isoformat(), "by": by, + }) + _write_lock(lock) + _audit("install", {"url": url, "ref": ref or "", "sha": sha, "id": pid}, + f"installed {pid}@{sha[:10]}") + log.info("[plugins] installed %s@%s from %s", pid, sha[:10], url) + return summary + + +def uninstall(plugin_id: str) -> bool: + """Remove a git-installed plugin (live dir + lock entry). Built-ins are refused.""" + if (REPO_ROOT / "plugins" / plugin_id).exists(): + raise InstallError(f"{plugin_id!r} is a built-in plugin — not removable via uninstall.") + target = live_plugins_dir() / plugin_id + removed = False + if target.exists(): + shutil.rmtree(target) + removed = True + lock = _read_lock() + before = len(lock["plugins"]) + lock["plugins"] = [e for e in lock["plugins"] if e.get("id") != plugin_id] + if len(lock["plugins"]) != before: + _write_lock(lock) + removed = True + if not removed: + raise InstallError(f"plugin {plugin_id!r} is not installed.") + _audit("uninstall", {"id": plugin_id}, f"uninstalled {plugin_id}") + log.info("[plugins] uninstalled %s", plugin_id) + return True + + +def install_deps(plugin_id: str) -> list[str]: + """Pip-install a plugin's declared ``requires_pip`` — the explicit code-exec + step that ``install`` deliberately skips (ADR 0027 D4). Returns the deps.""" + manifest = None + for base in (live_plugins_dir(), REPO_ROOT / "plugins"): + if (base / plugin_id / "protoagent.plugin.yaml").exists(): + manifest = load_manifest(base / plugin_id) + break + if manifest is None: + raise InstallError(f"plugin {plugin_id!r} is not installed.") + deps = list(manifest.requires_pip) + if not deps: + return [] + proc = subprocess.run( + [sys.executable, "-m", "pip", "install", *deps], capture_output=True, text=True, + ) + if proc.returncode != 0: + _audit("install_deps", {"id": plugin_id, "deps": deps}, "pip install failed", success=False) + raise InstallError(f"pip install failed: {(proc.stderr or proc.stdout).strip()[-400:]}") + _audit("install_deps", {"id": plugin_id, "deps": deps}, f"installed {len(deps)} dep(s)") + log.info("[plugins] installed %d dep(s) for %s", len(deps), plugin_id) + return deps + + +def list_installed() -> list[dict]: + """Lock entries, annotated with whether the code is present on disk.""" + out = [] + root = live_plugins_dir() + for e in _read_lock()["plugins"]: + out.append({**e, "present": (root / e["id"]).exists()}) + return out + + +def sync(*, allow: list[str] | None = None) -> list[dict]: + """Re-clone every locked plugin at its pinned SHA (reproducible install set). + Missing ones are fetched; present ones are left as-is.""" + results = [] + root = live_plugins_dir() + for e in _read_lock()["plugins"]: + pid = e["id"] + if (root / pid).exists(): + results.append({"id": pid, "status": "present"}) + continue + try: + install(e["source_url"], e.get("resolved_sha") or e.get("requested_ref") or None, + force=True, by="sync", allow=allow) + results.append({"id": pid, "status": "installed"}) + except InstallError as exc: + results.append({"id": pid, "status": "failed", "error": str(exc)}) + return results diff --git a/graph/plugins/loader.py b/graph/plugins/loader.py index 323b632..4e68477 100644 --- a/graph/plugins/loader.py +++ b/graph/plugins/loader.py @@ -28,6 +28,7 @@ class PluginLoadResult: tools: list = field(default_factory=list) skill_dirs: list = field(default_factory=list) + workflow_dirs: list = field(default_factory=list) # *.yaml recipe dirs (ADR 0027) a2a_skills: list = field(default_factory=list) # A2A card skill specs (#570) routers: list = field(default_factory=list) # {plugin_id, router, prefix} (ADR 0018) surfaces: list = field(default_factory=list) # {plugin_id, name, start, stop} @@ -179,8 +180,16 @@ def load_plugins(config, *, core_tool_names: set[str] | None = None) -> PluginLo registry = PluginRegistry(manifest.id, manifest.path, config=pconf) register(registry) except Exception as exc: # noqa: BLE001 — a bad plugin must not break boot - entry["error"] = str(exc) - log.warning("[plugins] %s failed to load: %s — skipping", manifest.id, exc) + # Clear diagnostic when an enabled plugin's declared deps aren't + # installed (ADR 0027 D4: install fetches code; deps are explicit). + if isinstance(exc, ModuleNotFoundError) and manifest.requires_pip: + entry["error"] = ( + f"declared deps not installed ({', '.join(manifest.requires_pip)}) — " + f"run: python -m server plugin install-deps {manifest.id}" + ) + else: + entry["error"] = str(exc) + log.warning("[plugins] %s failed to load: %s — skipping", manifest.id, entry["error"]) result.meta.append(entry) continue @@ -195,6 +204,17 @@ def load_plugins(config, *, core_tool_names: set[str] | None = None) -> PluginLo result.tools.extend(kept) result.skill_dirs.extend(registry.skill_dirs) + result.workflow_dirs.extend(registry.workflow_dirs) + # Full-bundle auto-discovery (ADR 0027): a plugin repo can ship SKILL.md + # skills + *.yaml workflows in conventional subdirs and they're picked up + # without any register_* boilerplate — so installing a repo pulls in the + # whole bundle (tools+subagents via register(), skills+workflows as data). + conv_skills = manifest.path / "skills" + if conv_skills.is_dir() and conv_skills not in result.skill_dirs: + result.skill_dirs.append(conv_skills) + conv_workflows = manifest.path / "workflows" + if conv_workflows.is_dir() and conv_workflows not in result.workflow_dirs: + result.workflow_dirs.append(conv_workflows) result.a2a_skills.extend(registry.a2a_skills) if registry.thread_id_resolver is not None: # last plugin wins (#571) if result.thread_id_resolver is not None: diff --git a/graph/plugins/manifest.py b/graph/plugins/manifest.py index 2b8dafe..2cb7998 100644 --- a/graph/plugins/manifest.py +++ b/graph/plugins/manifest.py @@ -45,6 +45,15 @@ class PluginManifest: # data so it's known without importing the plugin, and surfaced to the # frontend via /api/runtime/status. Each: {id, label, icon, path, tabs?}. views: list[dict] = field(default_factory=list) + # Distribution (ADR 0027) — for plugins installed from a git URL. + # requires_pip: declared pip deps. NOT auto-installed (install ≠ code exec); + # the operator installs them explicitly. Missing → clear error on enable. + # repository/homepage: provenance, shown in the install review. + # min_protoagent_version: compat guard (warn/refuse on an older host). + requires_pip: list[str] = field(default_factory=list) + repository: str = "" + homepage: str = "" + min_protoagent_version: str = "" def load_manifest(plugin_dir: Path) -> PluginManifest | None: @@ -80,6 +89,7 @@ def load_manifest(plugin_dir: Path) -> PluginManifest | None: secrets = data.get("secrets") settings = data.get("settings") views = data.get("views") + requires_pip = data.get("requires_pip") return PluginManifest( id=pid, name=name, @@ -96,4 +106,8 @@ def load_manifest(plugin_dir: Path) -> PluginManifest | None: settings=[s for s in settings if isinstance(s, dict)] if isinstance(settings, (list, tuple)) else [], views=[v for v in views if isinstance(v, dict) and v.get("id") and v.get("path")] if isinstance(views, (list, tuple)) else [], + requires_pip=[str(x) for x in requires_pip] if isinstance(requires_pip, (list, tuple)) else [], + repository=str(data.get("repository", "")).strip(), + homepage=str(data.get("homepage", "")).strip(), + min_protoagent_version=str(data.get("min_protoagent_version", "")).strip(), ) diff --git a/graph/plugins/registry.py b/graph/plugins/registry.py index dc83153..6a37551 100644 --- a/graph/plugins/registry.py +++ b/graph/plugins/registry.py @@ -47,6 +47,7 @@ def __init__(self, plugin_id: str, plugin_dir: Path, config: dict | None = None) self.host = HOST self.tools: list = [] self.skill_dirs: list[Path] = [] + self.workflow_dirs: list[Path] = [] # dirs of *.yaml workflow recipes (ADR 0027) self.a2a_skills: list[dict] = [] # A2A card skill specs (#570) self.routers: list[dict] = [] # {"router", "prefix"} self.surfaces: list[dict] = [] # {"name", "start", "stop"} @@ -76,6 +77,17 @@ def register_skill_dir(self, path: str | Path) -> None: p = self.plugin_dir / p self.skill_dirs.append(p) + def register_workflow_dir(self, path: str | Path) -> None: + """Add a directory of ``*.yaml`` workflow recipes bundled with the plugin + (ADR 0027). Relative paths resolve against the plugin's own directory. A + conventional ``/workflows/`` dir is auto-discovered without this + call; use it for a non-standard location. + """ + p = Path(path) + if not p.is_absolute(): + p = self.plugin_dir / p + self.workflow_dirs.append(p) + def register_a2a_skill(self, spec: dict) -> None: """Contribute an A2A *card* skill — advertised on the agent card and, when it declares ``output_schema`` + ``result_mime``, enforced by the diff --git a/operator_api/plugin_routes.py b/operator_api/plugin_routes.py new file mode 100644 index 0000000..fc39008 --- /dev/null +++ b/operator_api/plugin_routes.py @@ -0,0 +1,77 @@ +"""Operator API for git-installed plugins (ADR 0027, PR2). + +Backs the console Plugins panel: list installed plugins (with their manifest + +declared capabilities for review), install from a git URL, and uninstall. Install +fetches code only — enabling stays a config + restart decision (install ≠ enable). +""" + +from __future__ import annotations + +import logging + +from fastapi import HTTPException + +from graph.plugins import installer +from graph.plugins.manifest import load_manifest +from runtime.state import STATE + +log = logging.getLogger(__name__) + + +def _sources_allowlist() -> list[str] | None: + """`plugins.sources.allow` from config, if a fork locked installs down (PR3 + wires the config field; None = open).""" + cfg = STATE.graph_config + allow = getattr(cfg, "plugins_sources_allow", None) if cfg else None + return list(allow) if allow else None + + +def register_plugin_routes(app) -> None: + """Register `/api/plugins/installed`, `/api/plugins/install`, `/api/plugins/{id}`.""" + + @app.get("/api/plugins/installed") + async def _installed(): + # enabled state comes from the loader's per-plugin meta (id → enabled) + enabled = {p["id"]: bool(p.get("enabled")) for p in (STATE.plugin_meta or [])} + root = installer.live_plugins_dir() + out = [] + for e in installer.list_installed(): + item = {**e, "enabled": enabled.get(e["id"], False)} + m = load_manifest(root / e["id"]) if e.get("present") else None + if m is not None: + item["manifest"] = { + "name": m.name, "version": m.version, "description": m.description, + "repository": m.repository, "homepage": m.homepage, + "capabilities": m.capabilities, "requires_env": m.requires_env, + "requires_pip": m.requires_pip, + "views": [v.get("label") for v in m.views], + "secrets": m.secrets, + } + out.append(item) + return {"plugins": out} + + @app.post("/api/plugins/install") + async def _install(body: dict | None = None): + body = body or {} + url = str(body.get("url", "")).strip() + if not url: + raise HTTPException(status_code=400, detail="url is required") + ref = (str(body.get("ref", "")).strip() or None) + force = bool(body.get("force")) + try: + summary = installer.install( + url, ref, force=force, by="console", allow=_sources_allowlist(), + ) + except installer.InstallError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + # install ≠ enable: the new plugin's routes/surfaces mount at init, so it + # needs a restart + plugins.enabled to take effect. + return {"installed": summary, "restart_required": True} + + @app.delete("/api/plugins/{plugin_id}") + async def _uninstall(plugin_id: str): + try: + installer.uninstall(plugin_id) + except installer.InstallError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True} diff --git a/pyproject.toml b/pyproject.toml index 75b44de..e0804d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "protoagent" -version = "0.19.0" +version = "0.20.0" description = "protoAgent — LangGraph + A2A template for spawning protoLabs agents" requires-python = ">=3.11" diff --git a/runtime/state.py b/runtime/state.py index 3d7dfaa..bffd051 100644 --- a/runtime/state.py +++ b/runtime/state.py @@ -37,6 +37,7 @@ class AppState: mcp_meta: list = field(default_factory=list) plugin_tools: list = field(default_factory=list) plugin_skill_dirs: list = field(default_factory=list) + plugin_workflow_dirs: list = field(default_factory=list) # *.yaml recipe dirs (ADR 0027) plugin_a2a_skills: list = field(default_factory=list) # A2A card skills from plugins (#570) thread_id_resolver: object = None # (request_metadata, session_id) -> str (#571) plugin_routers: list = field(default_factory=list) diff --git a/server/__init__.py b/server/__init__.py index ff8fe2e..3940329 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -273,6 +273,14 @@ def agent_name() -> str: def _main(): + # Plugin management subcommand (ADR 0027): `python -m server plugin install + # ` (+ list/uninstall/sync). Handled before the server argparse — + # it fetches code to disk and exits, never starting the server. + if len(sys.argv) > 1 and sys.argv[1] == "plugin": + from graph.plugins.cli import run_plugin_cli + + raise SystemExit(run_plugin_cli(sys.argv[2:])) + # Frozen-binary entrypoint for a plugin's managed MCP server (ADR 0019): the # bundled desktop app has no `python` on PATH, so a plugin's managed-server # factory re-invokes this binary with `--mcp-plugin ` instead of `-m @@ -409,6 +417,7 @@ def _main(): from operator_api.chat_routes import register_chat_routes from operator_api.config_routes import register_config_routes from operator_api.knowledge_routes import register_knowledge_routes + from operator_api.plugin_routes import register_plugin_routes from operator_api.routes import register_operator_routes from operator_api.telemetry_routes import register_telemetry_routes @@ -562,6 +571,7 @@ async def _scheduler_shutdown() -> None: # Knowledge store + Playbooks (ADR 0020). Extracted to # operator_api/knowledge_routes.py (ADR 0023 phase 3). register_knowledge_routes(fastapi_app) + register_plugin_routes(fastapi_app) # --- Telemetry (ADR 0006 Slice 2) -------------------------------------- # Per-turn cost/latency + advise-only insights (ADR 0006). Extracted to diff --git a/server/agent_init.py b/server/agent_init.py index 3dc4212..b971cea 100644 --- a/server/agent_init.py +++ b/server/agent_init.py @@ -143,6 +143,7 @@ def _init_langgraph_agent(headless_setup: bool = False): STATE.plugin_tools, STATE.plugin_skill_dirs, STATE.plugin_meta = ( _plugins.tools, _plugins.skill_dirs, _plugins.meta, ) + STATE.plugin_workflow_dirs = _plugins.workflow_dirs STATE.plugin_a2a_skills = _plugins.a2a_skills # A2A card skills (#570) STATE.thread_id_resolver = _plugins.thread_id_resolver # thread_id seam (#571) # Surfaces / routes / subagents (ADR 0018). Routers + surfaces are captured @@ -569,6 +570,12 @@ def _build_workflow_registry(config): bundled = _bundle_root() / "workflows" if bundled.is_dir(): dirs.append(str(bundled)) + # Plugin-bundled workflow recipes (ADR 0027) — enabled plugins' workflows/ + # dirs, so installing a plugin repo pulls in its workflows too. Before the + # writable dir below, so a user-saved recipe still wins on a name clash. + for d in (getattr(STATE, "plugin_workflow_dirs", None) or []): + if Path(d).is_dir(): + dirs.append(str(d)) # Writable dir for user / agent-emitted recipes (same fallback shape). writable = scope_leaf(Path(config.workflow_dir).expanduser()) try: diff --git a/tests/test_plugin_installer.py b/tests/test_plugin_installer.py new file mode 100644 index 0000000..44244ad --- /dev/null +++ b/tests/test_plugin_installer.py @@ -0,0 +1,161 @@ +"""Git-URL plugin installer (ADR 0027) — fetch ≠ enable ≠ trust.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from graph.plugins import installer + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True) + + +def _make_plugin_repo(root: Path, pid: str = "demo_ext", manifest_extra: str = "", tag: str | None = None) -> Path: + repo = root / f"src-{pid}" + repo.mkdir(parents=True) + (repo / "protoagent.plugin.yaml").write_text( + f"id: {pid}\nname: Demo Ext\nversion: 0.1.0\ndescription: a test plugin\n{manifest_extra}" + ) + (repo / "__init__.py").write_text("def register(registry):\n pass\n") + _git(repo, "init", "-q") + _git(repo, "add", "-A") + _git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init") + if tag: + _git(repo, "tag", tag) + return repo + + +@pytest.fixture +def env(tmp_path, monkeypatch): + """Point the installer's lock + install dir at a temp area (never the real repo).""" + monkeypatch.setattr(installer, "LOCK_PATH", tmp_path / "plugins.lock") + monkeypatch.setenv("PROTOAGENT_PLUGINS_DIR", str(tmp_path / "installed")) + return tmp_path + + +def test_install_fetches_code_writes_lock_does_not_enable(env): + repo = _make_plugin_repo(env) + summary = installer.install(str(repo)) + + assert summary["id"] == "demo_ext" + assert len(summary["resolved_sha"]) == 40 + # code landed in the live plugins dir, git metadata stripped + target = installer.live_plugins_dir() / "demo_ext" + assert (target / "protoagent.plugin.yaml").exists() + assert not (target / ".git").exists() + # lock recorded with provenance + locked = installer.list_installed() + assert locked[0]["id"] == "demo_ext" and locked[0]["present"] is True + assert locked[0]["resolved_sha"] == summary["resolved_sha"] + # install ≠ enable: nothing enabled it (no config touched, no register run) + + +def test_install_pins_a_tag(env): + repo = _make_plugin_repo(env, tag="v1") + summary = installer.install(str(repo), "v1") + assert summary["requested_ref"] == "v1" and len(summary["resolved_sha"]) == 40 + + +def test_duplicate_requires_force(env): + repo = _make_plugin_repo(env) + installer.install(str(repo)) + with pytest.raises(installer.InstallError, match="already installed"): + installer.install(str(repo)) + installer.install(str(repo), force=True) # ok with force + + +def test_refuses_to_shadow_a_builtin(env): + # `hello` is a real built-in plugin in the repo — must not be installable over. + repo = _make_plugin_repo(env, pid="hello") + with pytest.raises(installer.InstallError, match="built-in"): + installer.install(str(repo)) + + +def test_repo_without_manifest_is_rejected(env, tmp_path): + bare = tmp_path / "src-bare" + bare.mkdir() + (bare / "README.md").write_text("not a plugin") + _git(bare, "init", "-q") + _git(bare, "add", "-A") + _git(bare, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "x") + with pytest.raises(installer.InstallError, match="not a protoAgent plugin"): + installer.install(str(bare)) + + +def test_bad_url_scheme_rejected(env): + with pytest.raises(installer.InstallError, match="unsupported source"): + installer.install("ftp://evil.example/x.git") + + +def test_uninstall_removes_code_and_lock(env): + repo = _make_plugin_repo(env) + installer.install(str(repo)) + installer.uninstall("demo_ext") + assert not (installer.live_plugins_dir() / "demo_ext").exists() + assert installer.list_installed() == [] + with pytest.raises(installer.InstallError, match="not installed"): + installer.uninstall("demo_ext") + + +def test_sync_recolones_missing_from_lock(env): + repo = _make_plugin_repo(env) + installer.install(str(repo)) + # simulate a fresh checkout: code gone, lock present + import shutil + shutil.rmtree(installer.live_plugins_dir() / "demo_ext") + assert installer.list_installed()[0]["present"] is False + results = installer.sync() + assert results == [{"id": "demo_ext", "status": "installed"}] + assert (installer.live_plugins_dir() / "demo_ext").exists() + + +def test_source_allowlist_blocks_offlist(env): + repo = _make_plugin_repo(env) + with pytest.raises(installer.InstallError, match="not on plugins.sources.allow"): + installer.install(str(repo), allow=["github.com/protoLabsAI/*"]) + + +def test_install_deps_noop_without_deps(env): + repo = _make_plugin_repo(env) + installer.install(str(repo)) + assert installer.install_deps("demo_ext") == [] + + +def test_install_deps_missing_plugin(env): + with pytest.raises(installer.InstallError, match="not installed"): + installer.install_deps("nope") + + +def test_install_deps_runs_pip_with_declared_deps(env, monkeypatch): + repo = _make_plugin_repo(env, manifest_extra="requires_pip: [requests>=2, rich]\n") + installer.install(str(repo)) + calls = [] + + class _OK: + returncode = 0 + stderr = "" + stdout = "" + + def _fake_run(cmd, **kw): + calls.append(cmd) + return _OK() + + monkeypatch.setattr(installer.subprocess, "run", _fake_run) # don't hit the network + deps = installer.install_deps("demo_ext") + assert deps == ["requests>=2", "rich"] + assert calls and calls[0][1:4] == ["-m", "pip", "install"] + assert calls[0][4:] == ["requests>=2", "rich"] + + +def test_configured_allowlist_reads_config(tmp_path, monkeypatch): + cfg_dir = tmp_path / "cfg" + cfg_dir.mkdir() + (cfg_dir / "langgraph-config.yaml").write_text( + "plugins:\n sources:\n allow: [github.com/protoLabsAI/*]\n" + ) + monkeypatch.setenv("PROTOAGENT_CONFIG_DIR", str(cfg_dir)) + assert installer.configured_allowlist() == ["github.com/protoLabsAI/*"] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 47d5b90..ac064be 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -306,3 +306,27 @@ def test_loader_meta_exposes_views_for_enabled_plugin(monkeypatch, tmp_path) -> meta = res.meta[0] assert meta["id"] == "viewy" and meta["enabled"] is True assert [v["id"] for v in meta["views"]] == ["board"] + + +# ── full-bundle auto-discovery (ADR 0027) ───────────────────────────────────── + + +def test_plugin_autodiscovers_workflows_and_skills_dirs(monkeypatch, tmp_path) -> None: + root = tmp_path / "plugins" + d = _make_plugin(root, "bundle", enabled=True, tool="bt") + (d / "workflows").mkdir() + (d / "workflows" / "wf.yaml").write_text("name: wf\n", encoding="utf-8") + monkeypatch.setattr(plugin_loader, "_plugin_roots", lambda config: [root]) + res = load_plugins(_cfg(plugins_enabled=["bundle"])) + assert any(p.name == "workflows" and "bundle" in str(p) for p in res.workflow_dirs) + assert any(p.name == "skills" and "bundle" in str(p) for p in res.skill_dirs) + + +def test_register_workflow_dir(monkeypatch, tmp_path) -> None: + root = tmp_path / "plugins" + body = "def register(reg):\n reg.register_workflow_dir('recipes')\n" + d = _make_plugin(root, "wfp", enabled=True, body=body) + (d / "recipes").mkdir() + monkeypatch.setattr(plugin_loader, "_plugin_roots", lambda config: [root]) + res = load_plugins(_cfg(plugins_enabled=["wfp"])) + assert any(p.name == "recipes" for p in res.workflow_dirs)