GeezerVison is showing github.com — feat: add read-only support for Agent Plugins behind the agent-plugins experiment by jscottmiller · Pull Request #29308 · coder/coder
Conversation
Coder Agents workspaces can already load SKILL.md skills and .mcp.json MCP servers that live in the workspace, but they cannot read the portable bundle format those things increasingly ship in. Agent Plugins v1.0.0 standardizes exactly that bundle: a closed plugin.json manifest, skills under skills/ in the Agent Skills format, and an mcp.json declaring stdio and HTTP MCP servers. It is adopted by VS Code, Cursor, Codex, Kiro and others, so a repo or template that ships a plugin directory today serves every client except Coder. Background and the gap analysis are in the internal research document research/agent-plugins-standard.md (coder/scott-misc, internal).
This PR adds read-only support for plugins that are already present in the workspace, behind a new agent-plugins experiment.
Scope
In scope:
- The workspace agent discovers plugin directories by their
plugin.json (detected by $schema), validates the manifest per spec sections 5.3 and 5.5, and pushes a plugin context resource plus the plugin's skills with plugin attribution over the existing context push.
- chatd lists plugin skills in
<available-skills> as - <alias> (plugin: <name>): <description> and serves them through read_skill and read_skill_file. Skill identity is (plugin, name); collisions are never resolved silently and use the qualified alias plugin/<plugin>/<skill> alongside the existing personal/<skill> and workspace/<skill>.
- The agent runs the plugin's
mcp.json servers through the existing workspace MCP path: stdio servers as subprocesses with PLUGIN_ROOT and PLUGIN_DATA, streamable-http and sse servers through the existing HTTP transports. Their tools appear like other workspace MCP tools, attributed to the plugin.
- Everything is gated: the agent only discovers and emits plugins when coderd advertises the experiment, and chatd ignores plugin-attributed rows when it is off.
Out of scope (unchanged): hooks, commands, agents, settings (not part of the standard), the Claude Code .claude-plugin cache layout, install or update flows, org-managed plugins, signing, and UI beyond what the existing skills UI shows. The only UI touches are a plugin label in the context panel and a /plugin/<plugin>/<skill> trigger in the composer's slash menu, both required for the existing UI to stay correct with the new resource kind.
Commits
feat: add agent-plugins experiment: codersdk.ExperimentAgentPlugins.
feat: add plugin body and manifest capability flag to the Agent API (v2.13): proto PluginBody variant, plugin_name on skill and MCP server bodies, Manifest.agent_plugins_enabled, workspacesdk.ValidatePluginName. coderd stores the variant under the existing plugin body kind; no migration.
feat(agent): discover and validate Agent Plugins manifests: manifest loader, discovery under plugins/ and .agents/plugins/ of every scan root (so ~/.coder/plugins/<name> and <repo>/.agents/plugins/<name>) plus a scan root that is itself a plugin, plugin-root containment, (plugin, name) dedupe, resolver and pusher gating, watcher coverage.
feat(coderd/x): add plugin skill source and alias grammar: skills.SourcePlugin, three-way MergeSkills, QualifiedAlias as a method.
feat: render plugin skills in chat and gate on the agent-plugins experiment: prompt index, read_skill/read_skill_file, read-side gating across prompt, tools, and API listing, description sanitization, composer trigger, integration test with a real agent.
feat(agent): load plugin mcp.json with placeholders, per-entry isolation, and env allow-list: agentmcp plugin sources and the hardening below.
feat(agent): run plugin MCP servers discovered by agentcontext: wiring from discovery to the MCP engine, plugin-attributed MCP server resources.
fix(agent): make plugin loading work on Windows and fix(agent): tolerate not-found errors when canonicalizing plugin paths: canonicalization falls back to the cleaned path for non-symlink paths when the filesystem cannot report symlink targets (Windows substituted drives on CI), and the plugin env allow-list includes the variables a Windows process needs, compared case-insensitively.
Agent API 2.13 and version skew
The Agent API minor is bumped to 2.13 for message-only changes (see tailnet/proto/version.go). Agents keep dialing 2.10 as they did for 2.11 and 2.12, because an older coderd rejects any dial above its own version. The new plugin oneof variant is the one change an older coderd cannot ignore (an unknown variant fails the whole push), so coderd sets Manifest.agent_plugins_enabled only when the experiment is on, and the agent treats a missing or false flag as "do not discover plugins and do not emit the variant or plugin_name". The push routine reads the flag per connection after the manifest arrives, so a reconnect to an older or downgraded coderd never sends the variant, and the aggregate hash is recomputed over what is actually sent.
Security hardening
- Only
${PLUGIN_ROOT} and ${PLUGIN_DATA} are expanded in plugin mcp.json (args, env, cwd), single pass; no ambient ${VAR} expansion for plugin files (spec 9.2).
- Plugin stdio servers get an allow-listed environment (
PATH, HOME, USER, LOGNAME, SHELL, TERM, TMPDIR, TZ, LANG, LANGUAGE, LC_*, XDG_*, proxy and CA variables) plus PLUGIN_ROOT and PLUGIN_DATA. CODER_AGENT_TOKEN, other CODER_* variables, and workspace secrets are not inherited.
- Containment: the canonical plugin root bounds
plugin.json, the skills/ container, every SKILL.md, mcp.json, ./-relative commands (executed as their resolved absolute path), and cwd. Symlinked plugin directories are skipped. PLUGIN_DATA lives at ~/.coder/plugin-data/<name>-<sha256(root)[:12]>, created 0700.
- HTTP servers: configured headers are attached only to requests whose scheme and host match the configured URL, client headers win, and cross-origin or https-to-http redirects are refused (spec 7.2).
https is required except for loopback hosts; userinfo and fragments are rejected.
- Skill descriptions from every source are sanitized before entering the prompt:
SanitizePromptText, control runes removed, single line, < and > escaped, rune-capped (1024 for workspace and plugin skills, 4096 for personal). Agent-supplied plugin and skill names are re-validated server-side.
Behavior changes for existing workspace .mcp.json files
Two hardening changes apply to legacy .mcp.json servers as well, because they share the code path:
- A single invalid entry no longer drops the whole file. Valid entries connect and the invalid one appears in the context panel as an MCP server with an error.
- Configured headers are no longer forwarded across origins on redirects or SSE endpoint events, and cross-origin or https-to-http redirects are refused. Servers that relied on that forwarding will fail to connect and show the error.
Trying it
Start coderd with CODER_EXPERIMENTS=agent-plugins and put a plugin in a workspace, for example under the agent's working directory:
.agents/plugins/acme/
plugin.json
skills/deploy/SKILL.md
mcp.json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "acme",
"version": "1.0.0",
"description": "Acme tooling"
}
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"acme": { "type": "stdio", "command": "npx", "args": ["-y", "@acme/mcp-server"], "cwd": "${PLUGIN_DATA}" }
}
}
A chat bound to the workspace then lists - deploy (plugin: acme): ... in <available-skills>, shows the plugin and its components in the context panel, and exposes the server's tools as acme__<tool>. ~/.coder/plugins/<name> works the same way. With the experiment off, nothing plugin-related is discovered or shown.
Testing
Unit tests cover the manifest loader, discovery and containment, dedupe, gating on resolve and on encode, the alias grammar, prompt rendering and sanitization, the plugin mcp.json parser, placeholder expansion, the env allow-list, header pinning, per-entry isolation, and concurrent reloads. TestChatContextPluginSkillsFromAgentPush runs a real agent against a temp scan root and asserts the captured system prompt with the experiment on and off; TestAgent_PluginMCPServersLoaded asserts a plugin's MCP server is loaded and pushed with attribution. make gen, make lint, go test -race on the affected packages, and the frontend pnpm check, pnpm lint, and Vitest suites pass.
Follow-ups (noted, not changed here)
- A working-directory
.mcp.json server still wins a name collision against a same-named plugin server from ~/.coder/plugins; the loser is visible with an error rather than silently dropped.
- An invalid plugin
mcp.json is reported only in agent logs; the plugin row stays OK.
- HTTP
mcp.json entries ignore stdio-only fields instead of rejecting them.
read_skill_file follows symlinks under a skill directory (existing posture).
- The fsnotify watcher registers plugin directories even when the experiment is off.
- Docs for the experiment beyond the generated reference.
Plan summary and review dispositions
Plan (checkpoint 1)
Design decisions: manifest validation implemented by hand per spec 5.3/5.5 with $schema-based detection (foreign or unreadable files ignored, agent-plugins schema other than 1.0.0 rejected); discovery in plugins/ and .agents/plugins/ under every scan root plus root-is-plugin; attribution via a PluginBody oneof variant and plugin_name fields with no migration; a third skills.Source with (plugin, name) identity and plugin/<plugin>/<skill> qualified alias; skew gating via Manifest.agent_plugins_enabled with the agent still dialing 2.10 and per-connection encode gating; chatd read-side gating; agentmcp plugin sources with placeholder-only expansion, env allow-list, containment, PLUGIN_DATA, per-entry isolation, header origin pinning; description sanitization for all sources.
Plan review (3 personas, 24 findings, all accepted): reconnect race fixed by per-connection encode gating; proto tag 17 instead of a reserved tag; resolver flag under the manager mutex; ManifestAPI.Experiments; commit scopes; header origin pinning and redirect policy; ./ command executed as its absolute path; ServerConfig.Plugin so DeepEqual forces reconnects; containment for the skills/ container, mcp.json, and symlinked plugin dirs; root-is-plugin double emission; sanitization escaping and server-side name validation; per-source description caps; capped PluginBody fields; proxy and CA variables in the allow-list; identity-based findSkill; single mcpConfigSources() for both reload callers; QualifiedAlias as a method; composer trigger; snapshot compares plugin scope; mcp_server rows gated in the listing; SetPluginsEnabled before SetReady; stable holder order.
Implementation review (checkpoint 2)
3 personas (security, concurrency and compatibility, chatd and frontend). No blockers. Fixed: composer dropped pluginName so the plugin label never rendered (HIGH); reload retry could reapply a stale source list (ReloadFrom recomputes per attempt); plugin sync goroutine untracked and could log after Close; plugin MCP precedence followed path sort rather than scan-root order; integration test asserted only the API listing (now asserts the rendered prompt with a real agent); commit 1 scope; mcp_server plugin names unvalidated in the listing; non-object plugin.json produced an invalid row instead of being ignored; plugin mcp.json read without re-checking containment; ${PLUGIN_DATA} cwd rejected under a symlinked parent; aggregate hash sent ungated; malformed env keys accepted. Noted without change: the follow-ups listed above and a transient reconnect window where plugin skills are pushed as plain skills until the next resolve.
This PR was generated by Coder Agents on behalf of @jscottmiller.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Adds ExperimentAgentPlugins, which gates read-only support for Agent
Plugins (agent-plugins.org) found in workspaces. Later commits make the
workspace agent discover plugin manifests and chatd surface their
skills and MCP servers when the experiment is enabled.
…v2.13)
Bumps the Agent API to v2.13 with message-only changes:
- ContextResource gains a PluginBody variant carrying validated Agent
Plugins (agent-plugins.org) manifest fields, and SkillMetaBody and
MCPServerBody gain plugin_name for attribution.
- Manifest gains agent_plugins_enabled, set by coderd when the
agent-plugins experiment is on. Older coderd versions never set it, so
an agent treats false as both "experiment off" and "peer would reject
the plugin variant", and keeps dialing v2.10 as v2.11 and v2.12 did.
coderd stores the plugin variant under the existing plugin body kind;
no schema migration is needed.
workspacesdk.ValidatePluginName implements the spec's plugin name
grammar (1 to 64 characters of a-z, 0-9, '.', '-', alphanumeric first
and last, no repeated separators) so the agent and coderd validate
names the same way.
The agentcontext resolver recognizes Agent Plugins (agent-plugins.org,
spec 1.0.0) directories and emits them as KindPlugin resources with
their skills attributed to the plugin.
Discovery: a plugin is an immediate child of plugins/ or
.agents/plugins/ under a scan root, or a scan root that itself holds a
plugin.json. A directory is a plugin only when plugin.json carries an
https://agent-plugins.org/schemas/ $schema; other plugin.json files
are ignored. A scan root that validates as a plugin is emitted as that
plugin only, so its skills/ children are not also emitted as plain
skills. Symlinked plugin directories are skipped. The plugin root is
canonicalized once and is the containment boundary for plugin.json,
mcp.json, the skills/ container, and every SKILL.md; a symlinked or
non-directory skills/ disables the skills component with a warning on
the plugin resource.
Validation follows spec section 5 by hand: name grammar (exported as
ValidatePluginName), optional string fields, keywords as strings,
closed author object, extensions as object. Unknown top-level fields
and a non-object extensions value are recorded in Resource.Error while
the status stays OK; every other violation, an unsupported schema
version, invalid JSON, or an oversize manifest rejects the plugin and
loads no components. Version and description are sanitized and capped
before they reach the resource.
Skills are deduplicated by (PluginName, Name) so a plugin skill and a
plain skill of the same name both survive. A plugin name already
claimed by an earlier scan root makes later same-named plugins
StatusInvalid with no components.
Resource gains PluginName, PluginVersion, and HasMCPConfig;
MCPServerStatus gains PluginName; Snapshot.Plugins() lists the valid
plugins with their roots. The DRPC pusher maps KindPlugin to
PluginBody and sets plugin_name on skill and MCP server bodies, but
only when constructed with WithPluginsEnabled(true); otherwise plugin
resources are dropped and plugin_name stays empty.
Manager.SetPluginsEnabled stores the flag and re-resolves on change;
both resolve paths pass it to Resolver.ResolveWithOptions, and
Resolve/ResolveContext keep plugin discovery off. The agent sets the
flag from Manifest.AgentPluginsEnabled on every manifest and the push
routine waits for the manifest before constructing the pusher with the
same flag.
The fsnotify watcher registers the plugin containers under each scan
root, each immediate plugin directory, its skills/ container, and that
container's immediate subdirectories, and treats plugin.json and
mcp.json events as relevant. Plugin directories are watched regardless
of the flag; the resolver decides what to read.
Adds skills.SourcePlugin and Skill.Plugin for skills shipped inside an
Agent Plugin. MergeSkills takes a third group of plugin skills whose
identity is the (plugin, name) pair, so distinct plugins may each ship
a skill of the same name. A name held by one skill keeps a bare alias;
when several skills share a name every holder gets its qualified alias
(personal/<name>, workspace/<name>, plugin/<plugin>/<name>) in a fixed
order, and Lookup keeps reporting a bare match as ambiguous. The fixed
plugin/ source token keeps plugin names such as "personal" or
"workspace" from colliding with the other sources.
QualifiedAlias becomes a method on Skill so both Lookup and the prompt
index derive the plugin form the same way. Existing callers pass no
plugin skills yet.
…riment
Skills pushed by the workspace agent with a plugin_name now reach the chat
as plugin-source skills. The prompt index lists them as
"- <alias> (plugin: <name>): <description>", collisions qualify them as
plugin/<plugin>/<name>, and read_skill and read_skill_file serve them from
the pinned SKILL.md and the skill directory like workspace skills.
findSkill matches on (source, plugin, name), so two plugins shipping the
same skill name resolve to their own bodies.
The agent-plugins experiment gates every read of plugin-attributed pinned
context. When it is off, plugin manifest rows and skill or mcp_server rows
carrying a plugin_name are dropped from the system prompt, the workspace
MCP tool set, and the chat context resource listing. Hydration and refresh
still pin every row, so flipping the experiment needs no re-pin.
Pinned skill rows are validated before use: skill names must match the
kebab-case skill grammar and plugin names the Agent Plugins name grammar
(workspacesdk.ValidatePluginName). Rows that fail are dropped and logged.
Skill descriptions from every source (personal, workspace, plugin) are now
sanitized when the index is rendered: prompt-sanitized, control runes and
whitespace collapsed to a single line, angle brackets escaped so a
description cannot close the available-skills block, and truncated by rune
(1024 for workspace and plugin skills, 4096 for personal skills).
codersdk.ChatContextResource gains PluginName and the plugin kind, so the
composer inserts /plugin/<plugin>/<name> for plugin skills and the context
indicator can label plugin manifest rows.
…ion, and env allow-list
Teach agentmcp to load Agent Plugins mcp.json files alongside the
legacy .mcp.json files it already handles.
New input types: ConfigSource{Path, Plugin *PluginScope} and
PluginScope{Name, Root, DataDir}. Manager.ReloadSources accepts
sources; Manager.Reload keeps its []string signature and wraps
ReloadSources with nil scopes. The file snapshot records the scope a
path was read under, so a plugin rename or data dir change with an
untouched mcp.json still reloads. ServerConfig gains Plugin and Cwd,
both part of the identity classifyServers compares, so a server moving
between a legacy file and a plugin is reconnected instead of kept.
ServerStatus gains PluginName so the catalog can carry a rejected
plugin entry next to a same-named legacy server.
Plugin files (plugin.go): the top level must be exactly $schema and
mcpServers with the 1.0.0 mcp schema URL, otherwise the file is
rejected and the plugin has no MCP servers. Entries require type in
{stdio, streamable-http, sse}; "http" is not accepted. stdio commands
are a single token, either a bare name or a ./-relative path that is
resolved against the plugin root, symlink-resolved, containment-checked,
and stored absolute. cwd accepts ./, ${PLUGIN_ROOT}, and ${PLUGIN_DATA}
prefixes and must resolve inside the root, or inside DataDir for the
last form. An entry env that sets PLUGIN_ROOT or PLUGIN_DATA is
rejected. HTTP URLs must be absolute, https unless the host is
localhost or a loopback IP, and carry no userinfo or fragment; headers
are passed as configured. Only ${PLUGIN_ROOT} and ${PLUGIN_DATA} are
expanded in args, env values, and cwd, in a single pass; plugin files
never go through os.Expand.
Plugin stdio launch: cmd.Dir is the resolved cwd, DataDir is created
0700 before launch, and the environment is the allow-listed subset of
the agent environment (PATH, HOME, USER, LOGNAME, SHELL, TERM, TMPDIR,
TZ, LANG, LANGUAGE, proxy and CA variables, LC_* and XDG_*) with HOME
falling back to the user's home dir, plus PLUGIN_ROOT, PLUGIN_DATA, and
the entry env. The updateEnv callback is not consulted for plugin
servers. PluginDataDir(home, name, root) is exported so the caller can
compute <home>/.coder/plugin-data/<name>-<sha256(root)[:12]>.
createTransport accepts "streamable-http" alongside "http".
Behavior changes for legacy .mcp.json servers:
1. Per-entry failure isolation. An invalid mcpServers entry (bad name,
missing command and url, non-object) no longer drops the whole file.
Valid siblings load, and the invalid entry is logged and surfaced in
the catalog as a disconnected ServerStatus with Err, so it becomes an
mcp_server resource with an error instead of vanishing. Unreadable
or malformed files are still skipped whole. A plugin server whose
name is already claimed by a legacy file or an earlier plugin is
skipped with a visible Err entry; legacy duplicates keep the old
silent first-wins behavior.
2. HTTP header origin pinning and redirect policy. Configured headers
are attached only to requests whose scheme and host match the
configured server URL, and only when the request does not already
carry that header. The http.Client now refuses redirects to another
origin, which includes any https to http downgrade, instead of
following them.
The agent now feeds each valid plugin's mcp.json into the MCP engine.
mcpConfigSources composes the env-configured .mcp.json files followed
by one plugin-scoped source per plugin that ships an mcp.json, with
PLUGIN_DATA under ~/.coder/plugin-data. Both the startup reload and a
new snapshot subscriber use the same composition, and the subscriber
reloads only when the set of plugins changes, so catalog updates that
re-trigger the resolver do not loop.
ReloadSources re-checks the snapshot after joining a concurrent reload
and starts another one when its own sources were not the ones applied,
so the startup reload and the subscriber cannot drop each other's
servers.
MCP server resources declared by a plugin carry the plugin name and use
<plugin>/<server> as their source, so a plugin entry that lost a name
collision with a workspace server still appears with its error.
Docs preview
Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Plugin roots, ./-relative commands, cwd, and the plugin mcp.json are
canonicalized with EvalSymlinks. On Windows substituted drives that
call fails even for plain directories, which made every plugin
invisible and every plugin entry invalid on the Windows CI runner. A
path that is not itself a symlink now falls back to its cleaned form
when the filesystem cannot report symlink targets; a path that is a
symlink still has to resolve or is rejected.
The plugin stdio environment allow-list adds the variables a Windows
process needs to start (SystemRoot, ComSpec, PATHEXT, TEMP, profile and
application data directories) and compares names case-insensitively
there, since Windows environment names are case-insensitive.
Tests that create symlinks skip on Windows, matching the existing
agentcontext tests, and the fake plugin server binary carries the .exe
suffix Windows requires to resolve an extension-less command.
EvalSymlinks on a Windows substituted drive reports ERROR_PATH_NOT_FOUND
for paths that exist, which the previous fallback treated as a genuinely
missing path. The fallback now decides from Lstat alone: a missing path
or a symlink keeps the error, anything else uses the cleaned path.
The agent-level plugin MCP test waits longer for the discovery, reload,
and re-resolve chain and reports the pushed resources when it fails, so
a stall is attributable to a stage.
…eadonly
# Conflicts:
# docs/reference/api/schemas.md
Match missing-path entry errors with fs.ErrNotExist instead of the
Unix message text, skip the Unix permission-bit assertion on Windows,
and copy rather than hard-link the fake server binary on Windows so
TempDir cleanup can remove it.
The sync goroutine subscribed to snapshot changes from inside itself,
so a snapshot broadcast before the goroutine was scheduled was never
observed and plugin MCP servers were not loaded until the next change.
Subscribe synchronously during init and examine the current snapshot
before the first wait.
|
Superseded by the stacked PRs #29320 through #29326 (stack #29327), which split this change into seven reviewable layers with an identical final tree.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Reviewers
spikecurtis
Awaiting requested review from spikecurtis
spikecurtis will be requested when the pull request is marked ready for review
spikecurtis is a code owner
mafredri
Awaiting requested review from mafredri
mafredri will be requested when the pull request is marked ready for review
mafredri is a code owner
Uh oh!
There was an error while loading. Please reload this page.
Labels
experimental
Changes that might not necessarily be merged, until its approved to proceed with.
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|