0027 — Install plugins from a git URL (shareable plugin repos)
Status: Accepted (sliced; D3 confirm amended 2026-08-15 — provenance-based consent, ADR 0071 D3)
Context
Plugins (ADR 0018 backend, 0019 settings, 0026 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.
Two facts shape the design:
- The seam already exists.
graph/plugins/loader.py::_plugin_roots()already discovers an external plugins dir (<config_dir>/plugins, outside the repo), and discovery readsprotoagent.plugin.yamlas 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 <git-url> 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 <config_dir>/plugins/<id>/ (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).
Amendment (2026-08-15): the confirm is now provenance-based rather than per-install (ADR 0071 D3, shipped #2733/#2739/#2734): sources matching
plugins.sources.official(default: the protoLabsAI org; fork-overridable) install with no ask, any other source gets a one-time "this plugin runs code" consent recorded inplugins.sources.acked, andplugins.trust_unverifiedis the operator's "don't ask again" switch. Thesources.allowhard allowlist above is unchanged and checked first.
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 <id>, 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 <url> [--ref <tag|sha>] [--enable],list,enable/disable <id>,uninstall <id>,sync(from lock),install-deps <id>.--enableis 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; the host version is the shared infra.paths.package_version() resolver the A2A card also advertises — repo pyproject.toml first, installed metadata on wheel/frozen installs — so a dev checkout's stale editable-install dist-info can't refuse a valid plugin, #1644).
D8 — Integrity rails
- Pin to resolved SHA (D2);
--refaccepts tag/branch/sha → resolved + recorded. - Clone
--depth 1at 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).
Amendment (2026-09-11) — superseded sources. A built-in (bundled) plugin may declare
supersedes: [<git URL>]: the standalone repo(s) it replaces after the plugin moved into core under the same id. For those URLs only, the built-in guard skips instead of refusing: an install (direct, or as a bundle member) fetches nothing, an update or auto-update stands down with a reason, and uninstall removes the ignored copy and its lock entry but keeps the id's enable state, config and secrets, which the bundled copy now uses. The loader lets the bundled copy win over a lock entry recorded from a superseded URL, at any version. That refines the #1574 rule, in which a recorded copy always won. A copy recorded from any other URL (a fork) is still a deliberate override, and every other URL still gets the refusal above. See When a plugin moves into core.Two consequences of that skip are deliberate. It runs before the
sources.allowallowlist (D3): nothing is fetched from the retired URL, and what ends up enabled is code that shipped inside protoAgent — which the operator already trusts by running it — so installing the old URL under a deny-all allowlist enables the bundled copy rather than failing. And the deps half of D4 follows the copy that actually runs: with a bundled copy superseding an old install,install-depsinstalls the bundled manifest'srequires_pip, and the consent/allowlist re-check is skipped because that copy has no fetched origin to re-validate.
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/syncCLI (no enable, no dep auto-run). Tests.
- installer core (clone → resolve SHA → validate manifest → write
- 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.allowenforcement. - 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 conventionalskills/(SKILL.md) andworkflows/(*.yaml) subdirs (data — noregister_*boilerplate; aregister_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.