Skip to content

94. Plugins are runtime-scoped harness resources

Date: 2026-08-29

Status

Accepted

Context

pi grows its tool surface through extensions — JavaScript/TypeScript modules loaded with -e that register tools, providers and event handlers. The pi runtime already loads the vendored Vertex providers and the sandbox hook adapter (ADR 0090) that way, under --no-extensions and defaultProjectTrust: never, so nothing from the target repository is picked up — but a harness had no way to add one of its own. pi's settings.json packages/extensions sources install from the network at startup, which the sandbox cannot do. The fleet wants extension-provided tools (#6520, #6550, #6527), and the user requirement was explicit: a path list should be a complete configuration.

The harness does not choose the runtime. runtime.ResolveForAgent reads it from org and per-repo config, with per-agent overrides (ADR 0091), so the same harness runs under whichever runtime the org picked. A per-runtime resource key would therefore make the harness carry configuration for a decision it does not own, and would multiply with every runtime added after pi — Codex and OpenCode are both in the roadmap.

Across those runtimes, plugin directories fall into two families rather than one per runtime:

  • Manifest bundles a runtime reads at startup: Claude Code's plugin layout, whose manifest is .claude-plugin/plugin.json; Codex discovers .codex-plugin/plugin.json, .claude-plugin/plugin.json and .cursor-plugin/plugin.json. fullsend's own historical marker for a Claude plugin is plugin.json at the directory root (what fetchBasePlugin has always required).
  • Code modules a runtime loads and executes: pi's -e <dir> extensions, and OpenCode's plugin modules.

The families are distinguishable from the directory itself, which is what makes one key possible.

Decision

One harness key, plugins:, lists directories a runtime loads. Each entry is a path string, or {path, env, pi} when it needs environment or runtime-specific options. Five rules govern it.

  1. The directory decides which runtime loads it. internal/pluginformat is a leaf package (no dependency on internal/harness or internal/runtime) that classifies an entry: plugin.json at the root or .claude-plugin/plugin.json marks a Claude plugin (Claude Code treats its manifest as optional; fullsend requires one of the two, since a directory with neither is not something any runtime here would load), otherwise pi's own -e <dir> loader rule decides whether it is a pi extension, and a directory that is neither is a validation error. The marker order is precedence, not exclusivity: a Claude plugin that bundles a Node MCP server ships a package.json whose main resolves, which would satisfy pi's rule as well, and such a directory is a Claude plugin. Each runtime loads the entries of its own kind and names and skips the rest, so switching runtime never silently drops a plugin.
  2. Sourced like skills:. A plugin has the same trust as skills: and scripts:: a path in the harness repository or a forge tree URL pinned with #sha256=, org-allowlisted, content-addressed, injection scanned. npm:/git:/ssh: sources and .. segments are rejected at validation — pi would fetch the first two from the network at startup. Nothing changes on the target-repo side: defaultProjectTrust: never, --no-approve and --no-extensions stay as they are; the runner appends the vetted -e paths.
  3. Local and vendored. A pi-format directory must be loadable by pi's own entry-point rule (validated at harness load), and its dependencies are committed — the sandbox never runs npm install. pi's rule is not the obvious one: a package.json carrying a pi object decides the verdict by itself, so a directory that names no resolvable pi.extensions entry loads nothing at all rather than falling back to index.js, and does so silently. Validation mirrors that, and refuses an entry that resolves outside the directory.
  4. Runtime-specific options are namespaced. env is the code family's knob (it is exported before the runtime starts) and pi: {args} holds the flags pi passes after -e <path>. On a Claude plugin both are a validation error rather than a silent drop, which keeps ClaudeRuntime behaviour unchanged. A future runtime adds its own block instead of its own key.
  5. No per-tool declaration, and no per-tool exemption either.--no-extensions plus explicit -e closes the set of code that can register tools, so an extension needs no manifest of the tools it adds. It gets no privilege from that closure: every sandbox hook, the optional tool allowlist included, decides on an extension tool exactly as on any other, and an org that runs the allowlist lists extension tool names in FULLSEND_TOOL_ALLOWLIST like any other name. The adapter cannot grant an exemption anyway — the manifest it would key on lives in the agent-writable config directory.

One entry serves one runtime. A polyglot directory is not a goal and mostly not possible: pi's package rule means a skills/ subfolder — which a Claude plugin may well have — disables index.* outright.

Directories only, in this decision. Single-file pi entries (-e <file>, which pi accepts) are a follow-up: they need their own hash and upload shape and would have no Claude counterpart.

Run-time mechanics follow from those rules: upload to a runner-owned directory, a tree-hash preflight before each iteration that fails closed, pi.args restricted to flags the extension itself registers, and an env deny-list — not the export order — keeping the runtime's and the providers' variables out of a plugin's reach, since pi passes its environment to every hook script it spawns. The harness author's walkthrough is pi runtime: plugins; the mechanics and their reasoning are in Runtime Implementation: Pi extensions.

Options

  • A separate extensions key for pi. Rejected: the runtime is chosen by org and per-repo config, not by the harness, so a per-runtime key makes the harness carry a decision it does not own — and it multiplies with Codex and OpenCode. Detecting the format per directory costs one leaf package and keeps one list working across a runtime switch.
  • pi settings.json packages/extensions sources. Rejected: pi installs them from the network at startup, and the set of code that may register tools would no longer be closed by --no-extensions + -e.
  • A mandatory per-plugin tool manifest (declared tool names, Claude mappings). Rejected for UX: the closure above makes it redundant, and it is exactly the bookkeeping the requirement excludes.
  • Exempting extension tools from the tool-allowlist hook. Rejected: the decision would rest on agent-writable manifest fields, and naming the tools in FULLSEND_TOOL_ALLOWLIST costs the org one line.

Consequences

  • A harness adds a plugin with one list entry, for local and URL-sourced harnesses alike, and it fails loudly: at validation when no runtime would load the directory, at exit 96 when the sandbox copy moved.
  • The plugins: list is now format-checked. A directory that is neither a Claude plugin nor a pi extension, and two entries that would upload under the same sandbox name, are refused at load — both used to pass and either fail or drop an entry at run time.
  • Plugins must not write into their own directory between iterations and must contain no symlinks; the preflight treats either as tampering.
  • A hash over the plugin source only binds what the loader reads, so the loader environment is pinned as well. The on-disk transpile cache is disabled (JITI_FS_CACHE=false in PiRuntime.EnvExports): it lives in an agent-writable directory and validates an entry against a marker derived from the source alone, so a rewritten cache body would execute while the source, this preflight and the hook adapter's checksum all stayed clean. The rest of the family is cleared outright right after the agent-writable .env is sourced, on every provider path — above all the loader's module alias map, which points a loaded specifier at a different file and is read from the environment because pi's bundled entry point does not pin that option. A time-of-check/time-of-use window remains, shared with the hook-adapter guard: a process left running by an earlier iteration can rewrite the tree between the check and pi's import.
  • Plugin env cannot set the interpreter environment, any credential- or proxy-shaped name, or the runner's and providers' families.
  • Base-composed plugin directories key their lock entry on the directory URL rather than on <dir>/plugin.json, since a plugin entry no longer has one marker file. The legacy key is still honoured on lookup, so an existing cache keeps serving offline runs until the next online fullsend lock rewrites it.
  • Follow-ups out of scope here: single-file pi entries; the Codex and OpenCode loaders; Claude-side honouring of env; an image-baked (image:) prefix form; replaces_builtin guards; per-tool Claude-name mapping; the Track E sub-agent tool (#6527); --tools union with extension tools. Per-agent runtime selection remains ADR 0091.