Files
coder/agent
Scott Miller 0e53c513c1 feat: discover and validate Agent Plugins manifests
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 plugin.json at a scan root, or in plugins/ (a name other
tools also use), whose $schema is absent or not an
https://agent-plugins.org/schemas/ URL may belong to another tool and
is ignored after a debug log; at a scan root an unreadable or malformed
file is ignored too. Every other problem is reported as an invalid
plugin with the reason, and in .agents/plugins/ every plugin.json is
taken as an Agent Plugins manifest. Rejected and duplicate manifests
are logged at Debug through Resolver.Logger; their rows carry the
error. A scan root that validates as a
plugin keeps its instruction files, .mcp.json and other skill
containers, but its skills/ belongs to the plugin and its plugin
containers are not scanned. Symlinked plugin directories and plugin
containers are skipped, as is a plugin or plugin skill directory whose
path is longer than workspacesdk.MaxContextSourceBytes (1024), coderd's
Source cap, or whose name is not valid UTF-8.
The plugin root is canonicalized once and is the containment boundary
for plugin.json, the skills/ container, each skill directory, and
every SKILL.md: following spec section 4.1, each
may be a symlink that resolves inside the plugin root. An escaping
skills/ disables the skills component with a warning on the plugin
resource, and an escaping skill directory or SKILL.md is reported as an
invalid skill. Canonicalization falls back to the cleaned path when
EvalSymlinks fails, which it does on Windows substituted drives, but
only for an existing directory with no link between the scan root and
the path.

Validation follows spec section 5 by hand: name grammar (via
workspacesdk.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 at 64 and 1024 runes before they reach the
resource; the spec sets no limit on either, so the caps are Coder's.

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 another plugin root makes later same-named plugins
StatusInvalid with no components. After the caps, skills and MCP
servers whose plugin is not StatusOK (for example one the count cap
excluded) are dropped, since coderd rejects a push holding them.
Resources are deduplicated on Source as they are found, across kinds,
for the same reason: a skill directory that is also a scan-root plugin
keeps the earlier scan root's row unless that row is invalid and the
later one valid; skill directories are compared after resolving
symlinks. A valid plugin whose row is not kept claims no name, emits no
skills, and is still treated as a plugin root, so its plugin containers
are not scanned. A rejected plugin that loses to an OK skill adds its
error to the skill row as a warning.

Resource gains PluginName, set on resources that belong to a plugin,
and PluginVersion. The DRPC pusher maps KindPlugin to PluginBody and
sets plugin_name on skill and MCP server bodies. When coderd rejects a
push that carries plugin data, the pusher retries it once without
plugin resources or plugin-attributed resources, with the aggregate
hash recomputed and a notice naming the rejection in SnapshotError. If
that push is accepted, the pusher omits plugin data for the rest of the
connection. Older coderd versions reject the PluginBody variant, so
context keeps syncing and the rejection is visible in the chat UI.
Every resource error has invalid UTF-8
replaced and is truncated on a UTF-8 boundary to
workspacesdk.MaxContextErrorBytes (4096). coderd's agentapi now uses
that constant and workspacesdk.MaxContextSourceBytes in place of its
own.

Plugin discovery is always on in the agent; coderd drops plugin data
when the agent-plugins experiment is off. Watcher syncs are serialized
and each reads the current scan roots, so an older root set never
replaces a newer one.

The fsnotify watcher registers the plugin
containers under each scan root (or the existing parent of a missing
.agents/plugins), each immediate plugin directory, its skills/
container, and the skill directories the resolver would read, and
treats plugin.json events as relevant. Skills are watched only for a
directory holding a plugin.json, and in a plugin directory only
plugin.json and skills events re-resolve. A scan root that holds a
plugin.json has its skills/ watched the same way. Sync publishes each
directory's watch role through an atomic pointer, and the event loop
reads it without the watcher lock, so filtering an event never waits
on a Sync that is blocked in fsnotify Add or Remove on Windows.

testutil.FakeSink holds its lock across t.Log, so a goroutine logging
as the test ends can no longer log after the test completed.

The built-in ~/.claude/plugins/cache scan root is removed: Claude's
.claude-plugin manifests are not read.

When the manifest has no directory the agent logs, once at Info, a
notice listing the working-directory entries it does not scan
(instruction files, .mcp.json, skill containers, plugin.json and the
plugin containers) and pointing at ~/.coder/.agents/plugins, since
templates without coder_agent.dir otherwise give no signal.

The agent socket's ContextResource and the HTTP API's SnapshotResource
gain plugin_name and plugin_version, and `coder exp chat context show`
prints a plugin column.
2026-10-15 02:13:14 +00:00
..
…