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.
This commit is contained in:
Scott Miller
2026-10-15 02:13:14 +00:00
parent c4bda0b710
commit 0e53c513c1
28 changed files with 3344 additions and 331 deletions
+12 -1
View File
@@ -351,6 +351,8 @@ type agent struct {
contextManager *agentcontext.Manager
contextAPI *agentcontext.API
missingDirNoticeLogged atomic.Bool
socketServerEnabled bool
socketPath string
socketServer *agentsocket.Server
@@ -1114,9 +1116,11 @@ func (a *agent) run() (retErr error) {
// so the goroutine exits cleanly on older coderd deployments.
connMan.startAgentAPI210("push context state", gracefulShutdownBehaviorStop,
func(ctx context.Context, aAPI proto.DRPCAgentClient210) error {
logger := a.logger.Named("agentcontext-push")
pusher := agentcontext.NewDRPCPusher(aAPI)
pusher.Logger = logger
return a.contextManager.RunPush(ctx, pusher, agentcontext.PushOptions{
Logger: a.logger.Named("agentcontext-push"),
Logger: logger,
})
})
@@ -1267,6 +1271,13 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
manifestOK.complete(nil)
sentResult = true
if strings.TrimSpace(manifest.Directory) == "" && !a.missingDirNoticeLogged.Swap(true) {
a.logger.Info(ctx, "agent directory is not set; skipping working-directory context",
slog.F("not_scanned", agentcontext.ScanRootEntries()),
slog.F("hint", "set coder_agent.dir in the template, or place plugins under ~/.coder/.agents/plugins"),
)
}
// Manifest just landed; the agentcontext manager now has
// a working directory to scan and a known set of scan
// roots. Re-seed sources from CODER_AGENT_EXP_*_DIRS so
+179
View File
@@ -3,12 +3,17 @@ package agent_test
import (
"os"
"path/filepath"
"slices"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentcontext"
"github.com/coder/coder/v2/agent/agentcontextconfig"
"github.com/coder/coder/v2/agent/agenttest"
agentproto "github.com/coder/coder/v2/agent/proto"
@@ -68,3 +73,177 @@ func TestAgent_ContextStatePushed(t *testing.T) {
assert.False(t, p.GetInitial(), "only the first push must be Initial")
}
}
// hasPluginData reports whether req carries a plugin resource or a
// resource attributed to a plugin.
func hasPluginData(req *agentproto.PushContextStateRequest) bool {
return slices.ContainsFunc(req.GetResources(), func(r *agentproto.ContextResource) bool {
return r.GetPlugin() != nil || r.GetSkill().GetPluginName() != "" || r.GetMcpServer().GetPluginName() != ""
})
}
func writeTestPlugin(t *testing.T, dir string) {
t.Helper()
pluginDir := filepath.Join(dir, "plugins", "p")
require.NoError(t, os.MkdirAll(pluginDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
[]byte(`{"$schema":"`+agentcontext.PluginSchemaV1+`","name":"p"}`), 0o600))
}
func TestAgent_ContextStatePushedPlugins(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeTestPlugin(t, dir)
//nolint:dogsled // setupAgent returns a wide tuple; we only care about the client.
_, client, _, _, _ := setupAgent(t,
agentsdk.Manifest{Directory: dir},
0,
func(_ *agenttest.Client, opts *agent.Options) {
opts.ContextConfig = agentcontextconfig.Config{}
},
)
require.Eventually(t, func() bool {
return slices.ContainsFunc(client.ContextStatePushes(), hasPluginData)
}, testutil.WaitMedium, testutil.IntervalFast, "expected a push with plugin data")
}
// When coderd rejects pushes that carry plugin data, the agent still
// syncs the rest of its context and reports why plugins are missing.
func TestAgent_ContextStatePushedWithoutRejectedPlugins(t *testing.T) {
t.Parallel()
dir := t.TempDir()
writeTestPlugin(t, dir)
require.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("rules"), 0o600))
var rejected atomic.Int32
//nolint:dogsled // setupAgent returns a wide tuple; we only care about the client.
_, client, _, _, _ := setupAgent(t,
agentsdk.Manifest{Directory: dir},
0,
func(c *agenttest.Client, opts *agent.Options) {
opts.ContextConfig = agentcontextconfig.Config{}
c.SetPushContextStateFunc(func(req *agentproto.PushContextStateRequest) error {
if hasPluginData(req) {
rejected.Add(1)
return xerrors.New("unknown resource body")
}
return nil
})
},
)
hasAgentsMD := func(p *agentproto.PushContextStateRequest) bool {
return slices.ContainsFunc(p.GetResources(), func(r *agentproto.ContextResource) bool {
return r.GetInstructionFile() != nil && filepath.Base(r.GetSource()) == "AGENTS.md"
})
}
var pushes []*agentproto.PushContextStateRequest
require.Eventually(t, func() bool {
pushes = client.ContextStatePushes()
return slices.ContainsFunc(pushes, hasAgentsMD)
}, testutil.WaitMedium, testutil.IntervalFast, "expected an accepted push from the working directory")
require.Positive(t, rejected.Load())
first := pushes[0]
require.True(t, first.GetInitial())
require.Contains(t, first.GetSnapshotError(), "Agent Plugins were not sent because coderd rejected them")
require.Contains(t, first.GetSnapshotError(), "unknown resource body")
}
// The notice is logged only when the manifest has no directory.
func TestAgent_MissingDirectoryNotice(t *testing.T) {
t.Parallel()
const notice = "agent directory is not set; skipping working-directory context"
hasNotice := func(e slog.SinkEntry) bool { return e.Message == notice }
t.Run("Unset", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
//nolint:dogsled // Only the side effect on the logger matters here.
_, _, _, _, _ = setupAgent(t,
agentsdk.Manifest{Directory: ""},
0,
func(_ *agenttest.Client, opts *agent.Options) {
opts.Logger = opts.Logger.AppendSinks(sink)
},
)
var entries []slog.SinkEntry
require.Eventually(t, func() bool {
entries = sink.Entries(hasNotice)
return len(entries) > 0
}, testutil.WaitLong, testutil.IntervalFast)
require.Equal(t, slog.LevelInfo, entries[0].Level)
fields := map[string]any{}
for _, f := range entries[0].Fields {
fields[f.Name] = f.Value
}
notScanned, ok := fields["not_scanned"].([]string)
require.True(t, ok, "not_scanned must be a []string, got %T", fields["not_scanned"])
hint, ok := fields["hint"].(string)
require.True(t, ok, "hint must be a string, got %T", fields["hint"])
require.Contains(t, notScanned, "AGENTS.md")
require.Contains(t, notScanned, "plugin.json")
require.Contains(t, notScanned, filepath.Join(".agents", "plugins"))
require.Contains(t, hint, "coder_agent.dir")
require.Contains(t, hint, "~/.coder/.agents/plugins")
})
t.Run("OncePerAgent", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
//nolint:dogsled // setupAgent returns a wide tuple; we only care about the client.
_, client, _, _, _ := setupAgent(t,
agentsdk.Manifest{Directory: ""},
0,
func(_ *agenttest.Client, opts *agent.Options) {
opts.Logger = opts.Logger.AppendSinks(sink)
},
)
// handleManifest returns after the notice point, so each
// "routine exited" entry for it marks a handled manifest.
manifestHandled := func(e slog.SinkEntry) bool {
if e.Message != "routine exited" {
return false
}
return slices.ContainsFunc(e.Fields, func(f slog.Field) bool {
return f.Name == "name" && f.Value == "handle manifest"
})
}
require.Eventually(t, func() bool {
return len(sink.Entries(manifestHandled)) == 1
}, testutil.WaitLong, testutil.IntervalFast)
require.Len(t, sink.Entries(hasNotice), 1)
// A reconnect delivers a new manifest without logging again.
client.LastWorkspaceAgent()
require.Eventually(t, func() bool {
return len(sink.Entries(manifestHandled)) == 2
}, testutil.WaitLong, testutil.IntervalFast, "expected the manifest to be handled on the new connection")
require.Len(t, sink.Entries(hasNotice), 1)
})
t.Run("Set", func(t *testing.T) {
t.Parallel()
sink := testutil.NewFakeSink(t)
//nolint:dogsled // setupAgent returns a wide tuple; we only care about the client.
_, client, _, _, _ := setupAgent(t,
agentsdk.Manifest{Directory: t.TempDir()},
0,
func(_ *agenttest.Client, opts *agent.Options) {
opts.Logger = opts.Logger.AppendSinks(sink)
},
)
// The manifest is handled before the first context push.
require.Eventually(t, func() bool {
return len(client.ContextStatePushes()) > 0
}, testutil.WaitLong, testutil.IntervalFast)
require.Empty(t, sink.Entries(hasNotice))
})
}
+24 -20
View File
@@ -30,16 +30,18 @@ type SourceRequest struct {
// Payloads are omitted; clients that need the bytes go through
// the drpc PushContextState path.
type SnapshotResource struct {
ID string `json:"id"`
Kind string `json:"kind"`
Source string `json:"source"`
SourcePath string `json:"source_path,omitempty"`
ContentHash string `json:"content_hash"`
SizeBytes uint64 `json:"size_bytes"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
ID string `json:"id"`
Kind string `json:"kind"`
Source string `json:"source"`
SourcePath string `json:"source_path,omitempty"`
ContentHash string `json:"content_hash"`
SizeBytes uint64 `json:"size_bytes"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
PluginName string `json:"plugin_name,omitempty"`
PluginVersion string `json:"plugin_version,omitempty"`
}
// SnapshotResponse is the on-wire representation of a Snapshot
@@ -190,16 +192,18 @@ func snapshotResponse(s Snapshot) SnapshotResponse {
}
for _, r := range s.Resources {
out.Resources = append(out.Resources, SnapshotResource{
ID: r.ID,
Kind: r.Kind.String(),
Source: r.Source,
SourcePath: r.SourcePath,
ContentHash: hex.EncodeToString(r.ContentHash[:]),
SizeBytes: r.SizeBytes,
Status: r.Status.String(),
Error: r.Error,
Name: r.Name,
Description: r.Description,
ID: r.ID,
Kind: r.Kind.String(),
Source: r.Source,
SourcePath: r.SourcePath,
ContentHash: hex.EncodeToString(r.ContentHash[:]),
SizeBytes: r.SizeBytes,
Status: r.Status.String(),
Error: r.Error,
Name: r.Name,
Description: r.Description,
PluginName: r.PluginName,
PluginVersion: r.PluginVersion,
})
}
return out
+25
View File
@@ -165,6 +165,31 @@ func TestAPI_Resync(t *testing.T) {
require.Equal(t, "ok", snap.Resources[0].Status)
}
func TestAPI_ResyncReportsPluginFields(t *testing.T) {
t.Parallel()
wd := t.TempDir()
pluginDir := filepath.Join(wd, ".agents", "plugins", "acme")
mustWriteFile(t, filepath.Join(pluginDir, "plugin.json"),
`{"$schema":"`+agentcontext.PluginSchemaV1+`","name":"acme","version":"1.2.0"}`)
mustWriteSkill(t, filepath.Join(pluginDir, "skills"), "deploy", "Deploys")
srv, _ := newAPITestServer(t, agentcontext.ManagerOptions{
WorkingDir: func() string { return wd },
})
status, body := doRequest(t, http.MethodPost, srv.URL+"/resync", nil)
require.Equal(t, http.StatusOK, status)
var snap agentcontext.SnapshotResponse
require.NoError(t, json.Unmarshal(body, &snap))
byKind := map[string]agentcontext.SnapshotResource{}
for _, r := range snap.Resources {
byKind[r.Kind] = r
}
require.Equal(t, "1.2.0", byKind["plugin"].PluginVersion)
require.Equal(t, "acme", byKind["skill"].PluginName)
}
func TestAPI_AddSourceMalformedBody(t *testing.T) {
t.Parallel()
srv, _ := newAPITestServer(t, agentcontext.ManagerOptions{
-6
View File
@@ -11,12 +11,6 @@ func defaultBuiltinRoots() []string {
// User-level Coder config.
"~/.coder",
"~/.coder/skills",
// Claude Code plugin cache, picked up by the plugin
// RFC follow-up. v1 ignores plugin manifests, but
// watching the directory now prevents a surprise
// dirty bit when the resolver eventually classifies
// them.
"~/.claude/plugins/cache",
}
}
+14 -6
View File
@@ -1,6 +1,6 @@
// Package agentcontext consolidates the agent-side plumbing that
// resolves, watches, and pushes workspace context (instruction
// files, skills, and MCP configuration) to coderd.
// files, skills, MCP configuration, and Agent Plugins) to coderd.
//
// This is the agent half of the design described in
// "RFC: Workspace Context Sources for Coder Agents". It owns:
@@ -9,12 +9,20 @@
// built-in defaults and the working directory.
// - A resolver that classifies files at fixed locations under
// each scan root into typed Resources (instruction files,
// skills, MCP configs, MCP servers). Discovery is shallow:
// instruction files (AGENTS.md, CLAUDE.md, .cursorrules) and
// .mcp.json are read only at a scan root's top level, skills
// skills, MCP configs, MCP servers, plugins). Discovery is
// shallow: instruction files (AGENTS.md, CLAUDE.md, .cursorrules)
// and .mcp.json are read only at a scan root's top level, skills
// only from fixed container directories (skills, .agents/skills,
// .claude/skills, .codex/skills), and the resolver never walks
// the tree downward or up to a parent directory.
// .claude/skills, .codex/skills), plugins only from the plugins
// and .agents/plugins containers or a scan root that is itself a
// plugin, and the resolver never walks the tree downward or up
// to a parent directory.
// - Agent Plugins (agent-plugins.org, spec 1.0.0). A plugin is a
// directory whose plugin.json carries the agent-plugins $schema;
// the manifest is validated against the 1.0.0 schema and emitted
// as a KindPlugin resource. Its skills/ children are emitted as
// KindSkill resources with Resource.PluginName set. Every path
// read inside a plugin must resolve within the plugin root.
// - A fixed-location fsnotify watcher that signals a re-resolve
// when any recognized file changes.
// - A readiness gate (Manager.SetReady). The Manager starts gated,
+101 -5
View File
@@ -2,12 +2,19 @@ package agentcontext
import (
"context"
"errors"
"slices"
"strings"
"sync"
"unicode/utf8"
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/structpb"
"storj.io/drpc/drpcerr"
"cdr.dev/slog/v3"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// DRPCPusher adapts a generated DRPCAgentClient to the
@@ -15,11 +22,22 @@ import (
// that knows about the wire protobuf types; the rest of the
// package operates on the Go Snapshot/Resource value types.
//
// Use NewDRPCPusher to construct an instance. The pusher's
// behavior is identical to invoking PushContextState directly:
// per-request retries are handled by Manager.RunPush.
// Use NewDRPCPusher to construct an instance. Per-request retries
// are handled by Manager.RunPush. When coderd rejects a push that
// carries Agent Plugins data and accepts the same push without it,
// the pusher omits plugin data from every later push it sends and
// reports the rejection in SnapshotError.
type DRPCPusher struct {
// Logger receives a warning when coderd rejects plugin data. The
// zero value discards it.
Logger slog.Logger
client agentproto.DRPCAgentClient210
mu sync.Mutex
// pluginsRejected is the error text of the push whose plugin data
// coderd rejected. Non-empty means plugin data is omitted.
pluginsRejected string
}
// NewDRPCPusher wraps the supplied drpc client. The client must
@@ -34,10 +52,42 @@ func NewDRPCPusher(client agentproto.DRPCAgentClient210) *DRPCPusher {
// definition does not include the RPC. The adapter translates
// that into ErrPushUnimplemented so RunPush stops gracefully
// when an old coderd is on the other end.
//
// Any other error on a push that carries plugin data is retried
// once without it. If that retry succeeds, the pusher omits plugin
// data from then on; otherwise the first error is returned.
func (p *DRPCPusher) PushContextState(ctx context.Context, req *PushRequest) (*PushResponse, error) {
if p == nil || p.client == nil {
return nil, xerrors.New("agentcontext: DRPCPusher has no client")
}
p.mu.Lock()
rejected := p.pluginsRejected
p.mu.Unlock()
if rejected != "" {
return p.send(ctx, withoutPluginData(req, rejected))
}
resp, err := p.send(ctx, req)
if err == nil || errors.Is(err, ErrPushUnimplemented) ||
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
ctx.Err() != nil || !slices.ContainsFunc(req.Resources, isPluginData) {
return resp, err
}
resp, retryErr := p.send(ctx, withoutPluginData(req, err.Error()))
if retryErr != nil {
return nil, err
}
p.mu.Lock()
if p.pluginsRejected == "" {
p.pluginsRejected = err.Error()
}
p.mu.Unlock()
p.Logger.Warn(ctx, "coderd rejected Agent Plugins data; pushing context without plugins on this connection",
slog.Error(err))
return resp, nil
}
func (p *DRPCPusher) send(ctx context.Context, req *PushRequest) (*PushResponse, error) {
resp, err := p.client.PushContextState(ctx, pushRequestToProto(req))
if err != nil {
if drpcerr.Code(err) == drpcerr.Unimplemented {
@@ -48,11 +98,34 @@ func (p *DRPCPusher) PushContextState(ctx context.Context, req *PushRequest) (*P
return &PushResponse{Accepted: resp.GetAccepted()}, nil
}
func isPluginData(r Resource) bool {
return r.Kind == KindPlugin || r.PluginName != ""
}
// withoutPluginData returns a copy of req without KindPlugin
// resources or resources with a PluginName, with the aggregate hash
// recomputed and a notice carrying rejection appended to
// SnapshotError.
func withoutPluginData(req *PushRequest, rejection string) *PushRequest {
out := *req
out.Resources = slices.DeleteFunc(slices.Clone(req.Resources), isPluginData)
out.AggregateHash = ComputeAggregateHash(driftResources(out.Resources))
notice := "Agent Plugins were not sent because coderd rejected them: " + rejection
if out.SnapshotError != "" {
notice = out.SnapshotError + "; " + notice
}
out.SnapshotError = truncateUTF8Bytes(strings.ToValidUTF8(notice, "\uFFFD"), workspacesdk.MaxContextErrorBytes)
return &out
}
// pushRequestToProto converts the Go push payload to its
// generated protobuf equivalent. The Kind on each Resource
// selects which body variant of the proto oneof is set; a body
// is always set (zero-valued if necessary) so coderd can tell
// the kind even when Status != OK.
// the kind even when Status != OK. Each Error has invalid UTF-8
// replaced, since proto3 strings must be valid UTF-8 or the whole
// request fails to marshal, and is truncated to
// workspacesdk.MaxContextErrorBytes.
func pushRequestToProto(req *PushRequest) *agentproto.PushContextStateRequest {
pb := &agentproto.PushContextStateRequest{
Version: req.Version,
@@ -68,7 +141,7 @@ func pushRequestToProto(req *PushRequest) *agentproto.PushContextStateRequest {
ContentHash: append([]byte(nil), r.ContentHash[:]...),
Status: resourceStatusToProto(r.Status),
SizeBytes: r.SizeBytes,
Error: r.Error,
Error: truncateUTF8Bytes(strings.ToValidUTF8(r.Error, "\uFFFD"), workspacesdk.MaxContextErrorBytes),
}
setResourceBody(entry, r)
if r.SourcePath != "" {
@@ -80,6 +153,19 @@ func pushRequestToProto(req *PushRequest) *agentproto.PushContextStateRequest {
return pb
}
// truncateUTF8Bytes cuts s to at most limit bytes without splitting a
// UTF-8 sequence.
func truncateUTF8Bytes(s string, limit int) string {
if len(s) <= limit {
return s
}
cut := limit
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
// setResourceBody picks the proto oneof variant for r's Kind and
// populates the kind-specific fields from r. A body is set even
// when status is not OK so coderd can attribute the failure to a
@@ -99,6 +185,7 @@ func setResourceBody(entry *agentproto.ContextResource, r Resource) {
Meta: append([]byte(nil), r.Payload...),
Name: r.Name,
Description: r.Description,
PluginName: r.PluginName,
},
}
case KindMCPConfig:
@@ -113,6 +200,15 @@ func setResourceBody(entry *agentproto.ContextResource, r Resource) {
ServerName: serverNameOrSource(r),
Description: r.Description,
Tools: mcpToolsToProto(r.Tools),
PluginName: r.PluginName,
},
}
case KindPlugin:
entry.Body = &agentproto.ContextResource_Plugin{
Plugin: &agentproto.PluginBody{
Name: r.Name,
Version: r.PluginVersion,
Description: r.Description,
},
}
}
+316
View File
@@ -2,13 +2,22 @@ package agentcontext_test
import (
"context"
"path/filepath"
"slices"
"strings"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"google.golang.org/protobuf/proto"
"storj.io/drpc/drpcerr"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentcontext"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/testutil"
)
// fakeDRPCClient stubs out the DRPCAgentClient210 surface for
@@ -18,15 +27,25 @@ import (
type fakeDRPCClient struct {
agentproto.DRPCAgentClient210
lastReq *agentproto.PushContextStateRequest
reqs []*agentproto.PushContextStateRequest
resp *agentproto.PushContextStateResponse
err error
// reject, when set and err is nil, fails the requests it returns
// an error for.
reject func(*agentproto.PushContextStateRequest) error
}
func (f *fakeDRPCClient) PushContextState(_ context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) {
f.lastReq = req
f.reqs = append(f.reqs, req)
if f.err != nil {
return nil, f.err
}
if f.reject != nil {
if err := f.reject(req); err != nil {
return nil, err
}
}
if f.resp == nil {
return &agentproto.PushContextStateResponse{Accepted: true}, nil
}
@@ -193,3 +212,300 @@ func TestDRPCPusher_NilClientErrors(t *testing.T) {
_, err := pusher.PushContextState(context.Background(), &agentcontext.PushRequest{})
require.Error(t, err)
}
// pluginPushRequest returns a request carrying every plugin-related
// field the wire can express.
func pluginPushRequest() *agentcontext.PushRequest {
return &agentcontext.PushRequest{
Version: 3,
AggregateHash: [32]byte{0xaa},
Resources: []agentcontext.Resource{
{
ID: "plugin:/tmp/plugins/p",
Kind: agentcontext.KindPlugin,
Source: "/tmp/plugins/p",
Name: "p",
PluginVersion: "1.0.0",
Description: "A plugin",
Status: agentcontext.StatusOK,
ContentHash: [32]byte{0x07},
SizeBytes: 42,
},
{
ID: "skill:/tmp/plugins/p/skills/s",
Kind: agentcontext.KindSkill,
Source: "/tmp/plugins/p/skills/s",
Name: "s",
PluginName: "p",
Description: "Plugin skill",
Payload: []byte("---\nname: s\n---\n"),
Status: agentcontext.StatusOK,
},
{
ID: "mcp_server:p/srv",
Kind: agentcontext.KindMCPServer,
Source: "p/srv",
Name: "srv",
PluginName: "p",
Status: agentcontext.StatusOK,
Tools: []agentcontext.MCPTool{{Name: "echo"}},
},
{
ID: "skill:/tmp/skills/plain",
Kind: agentcontext.KindSkill,
Source: "/tmp/skills/plain",
Name: "plain",
Status: agentcontext.StatusOK,
},
},
}
}
func TestDRPCPusher_EncodesPluginData(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{}
pusher := agentcontext.NewDRPCPusher(client)
_, err := pusher.PushContextState(context.Background(), pluginPushRequest())
require.NoError(t, err)
pb := client.lastReq
require.Len(t, pb.Resources, 4)
plugin := pb.Resources[0]
require.Equal(t, "/tmp/plugins/p", plugin.Source)
require.Equal(t, agentproto.ContextResource_OK, plugin.Status)
require.Equal(t, uint64(42), plugin.SizeBytes)
body := plugin.GetPlugin()
require.NotNil(t, body, "plugin body must be set")
require.Equal(t, "p", body.GetName())
require.Equal(t, "1.0.0", body.GetVersion())
require.Equal(t, "A plugin", body.GetDescription())
skill := pb.Resources[1].GetSkill()
require.NotNil(t, skill)
require.Equal(t, "s", skill.GetName())
require.Equal(t, "p", skill.GetPluginName())
srv := pb.Resources[2].GetMcpServer()
require.NotNil(t, srv)
require.Equal(t, "srv", srv.GetServerName())
require.Equal(t, "p", srv.GetPluginName())
plain := pb.Resources[3].GetSkill()
require.NotNil(t, plain)
require.Empty(t, plain.GetPluginName())
}
const pluginNotice = "Agent Plugins were not sent because coderd rejected them: "
func hasPluginData(req *agentproto.PushContextStateRequest) bool {
return slices.ContainsFunc(req.GetResources(), func(r *agentproto.ContextResource) bool {
return r.GetPlugin() != nil || r.GetSkill().GetPluginName() != "" || r.GetMcpServer().GetPluginName() != ""
})
}
// rejectPluginData fails every request that carries plugin data.
func rejectPluginData(req *agentproto.PushContextStateRequest) error {
if hasPluginData(req) {
return xerrors.New("unknown resource body")
}
return nil
}
// requireStripped asserts that pb is pluginPushRequest without its plugin
// data, with the aggregate hash recomputed over what was sent.
func requireStripped(t *testing.T, pb *agentproto.PushContextStateRequest) {
t.Helper()
require.Len(t, pb.Resources, 1, "plugin and plugin-attributed resources must be dropped")
require.Equal(t, "/tmp/skills/plain", pb.Resources[0].Source)
req := pluginPushRequest()
sent := slices.DeleteFunc(req.Resources, func(r agentcontext.Resource) bool {
return r.Kind == agentcontext.KindPlugin || r.PluginName != ""
})
want := agentcontext.ComputeAggregateHash(agentcontext.HashedResources(sent))
require.Equal(t, want[:], pb.AggregateHash)
}
func TestDRPCPusher_RejectedPluginDataRetriedWithout(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{reject: rejectPluginData}
sink := testutil.NewFakeSink(t)
pusher := agentcontext.NewDRPCPusher(client)
pusher.Logger = slog.Make(sink)
resp, err := pusher.PushContextState(context.Background(), pluginPushRequest())
require.NoError(t, err)
require.True(t, resp.Accepted)
require.Len(t, client.reqs, 2)
require.True(t, hasPluginData(client.reqs[0]))
requireStripped(t, client.reqs[1])
require.Equal(t, pluginNotice+"unknown resource body", client.reqs[1].SnapshotError)
// Later pushes skip the full request.
_, err = pusher.PushContextState(context.Background(), pluginPushRequest())
require.NoError(t, err)
require.Len(t, client.reqs, 3)
requireStripped(t, client.reqs[2])
require.Equal(t, pluginNotice+"unknown resource body", client.reqs[2].SnapshotError)
warnings := sink.Entries(func(e slog.SinkEntry) bool { return e.Level == slog.LevelWarn })
require.Len(t, warnings, 1)
}
func TestDRPCPusher_RejectedPushNotRetried(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
req *agentcontext.PushRequest
err error
}{
{
name: "NoPluginData",
req: &agentcontext.PushRequest{Resources: []agentcontext.Resource{
{ID: "skill:/tmp/skills/plain", Kind: agentcontext.KindSkill, Source: "/tmp/skills/plain", Name: "plain", Status: agentcontext.StatusOK},
}},
err: xerrors.New("rejected"),
},
{
name: "Canceled",
req: pluginPushRequest(),
err: xerrors.Errorf("push: %w", context.Canceled),
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{err: tc.err}
pusher := agentcontext.NewDRPCPusher(client)
_, err := pusher.PushContextState(context.Background(), tc.req)
require.ErrorIs(t, err, tc.err)
require.Len(t, client.reqs, 1)
})
}
}
func TestDRPCPusher_FailedRetryReturnsOriginalError(t *testing.T) {
t.Parallel()
original := xerrors.New("original")
client := &fakeDRPCClient{}
client.reject = func(req *agentproto.PushContextStateRequest) error {
if hasPluginData(req) {
return original
}
return xerrors.New("retry")
}
pusher := agentcontext.NewDRPCPusher(client)
_, err := pusher.PushContextState(context.Background(), pluginPushRequest())
require.ErrorIs(t, err, original)
require.Len(t, client.reqs, 2)
require.False(t, hasPluginData(client.reqs[1]))
// The failed retry does not stop the next push from sending
// plugin data.
client.reject = nil
_, err = pusher.PushContextState(context.Background(), pluginPushRequest())
require.NoError(t, err)
require.Len(t, client.reqs, 3)
require.True(t, hasPluginData(client.reqs[2]))
require.Empty(t, client.reqs[2].SnapshotError)
}
func TestDRPCPusher_UnimplementedWithPluginDataNotRetried(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{err: drpcerr.WithCode(xerrors.New("unknown rpc"), drpcerr.Unimplemented)}
pusher := agentcontext.NewDRPCPusher(client)
_, err := pusher.PushContextState(context.Background(), pluginPushRequest())
require.ErrorIs(t, err, agentcontext.ErrPushUnimplemented)
require.Len(t, client.reqs, 1)
}
func TestDRPCPusher_PluginNoticeJoinsSnapshotError(t *testing.T) {
t.Parallel()
t.Run("Joined", func(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{reject: rejectPluginData}
pusher := agentcontext.NewDRPCPusher(client)
req := pluginPushRequest()
req.SnapshotError = "watcher degraded"
_, err := pusher.PushContextState(context.Background(), req)
require.NoError(t, err)
require.Equal(t, "watcher degraded; "+pluginNotice+"unknown resource body", client.lastReq.SnapshotError)
})
t.Run("Truncated", func(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{reject: rejectPluginData}
pusher := agentcontext.NewDRPCPusher(client)
req := pluginPushRequest()
req.SnapshotError = strings.Repeat("é", workspacesdk.MaxContextErrorBytes)
_, err := pusher.PushContextState(context.Background(), req)
require.NoError(t, err)
got := client.lastReq.SnapshotError
require.LessOrEqual(t, len(got), workspacesdk.MaxContextErrorBytes)
require.True(t, utf8.ValidString(got))
})
}
func TestDRPCPusher_NewPusherSendsPluginData(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{reject: rejectPluginData}
_, err := agentcontext.NewDRPCPusher(client).PushContextState(context.Background(), pluginPushRequest())
require.NoError(t, err)
require.False(t, hasPluginData(client.lastReq))
client.reject = nil
_, err = agentcontext.NewDRPCPusher(client).PushContextState(context.Background(), pluginPushRequest())
require.NoError(t, err)
require.True(t, hasPluginData(client.lastReq))
}
// coderd rejects a push whose resource error exceeds 4096 bytes, so the
// pusher truncates every Error on a UTF-8 boundary.
func TestDRPCPusher_TruncatesResourceError(t *testing.T) {
t.Parallel()
client := &fakeDRPCClient{}
pusher := agentcontext.NewDRPCPusher(client)
long := "a" + strings.Repeat("é", 3000)
_, err := pusher.PushContextState(context.Background(), &agentcontext.PushRequest{
Resources: []agentcontext.Resource{
{ID: "instruction_file:/tmp/AGENTS.md", Kind: agentcontext.KindInstructionFile, Source: "/tmp/AGENTS.md", Status: agentcontext.StatusInvalid, Error: long},
{ID: "instruction_file:/tmp/CLAUDE.md", Kind: agentcontext.KindInstructionFile, Source: "/tmp/CLAUDE.md", Status: agentcontext.StatusInvalid, Error: "short"},
},
})
require.NoError(t, err)
got := client.lastReq.Resources[0].Error
require.Len(t, got, 4095, "a two-byte rune straddling the cap is dropped")
require.True(t, utf8.ValidString(got))
require.True(t, strings.HasPrefix(long, got))
require.Equal(t, "short", client.lastReq.Resources[1].Error)
}
// proto3 string fields must be valid UTF-8, so a resource error built
// from file content with invalid UTF-8 must not fail the push.
func TestDRPCPusher_ResourceErrorInvalidUTF8(t *testing.T) {
t.Parallel()
root := testutil.TempDirResolved(t)
mustWriteFile(t, filepath.Join(root, ".agents", "plugins", "x", "plugin.json"), "{\"$schema\": \"\xff\", \"name\": \"x\"}")
snap := resolvePlugins(agentcontext.ScanRoot{Path: root})
require.Len(t, snap.Resources, 1)
require.False(t, utf8.ValidString(snap.Resources[0].Error), "the fixture must produce an invalid UTF-8 error")
client := &fakeDRPCClient{}
pusher := agentcontext.NewDRPCPusher(client)
_, err := pusher.PushContextState(context.Background(), &agentcontext.PushRequest{Resources: snap.Resources})
require.NoError(t, err)
got := client.lastReq.Resources[0].Error
require.True(t, utf8.ValidString(got))
require.Contains(t, got, "is not an Agent Plugins schema")
_, err = proto.Marshal(client.lastReq)
require.NoError(t, err)
}
+20
View File
@@ -1,7 +1,27 @@
package agentcontext
import "github.com/fsnotify/fsnotify"
// ManagerStarted exposes the unexported started() channel for
// use by external _test packages. Production code does not need
// this signal; the agent calls Run synchronously after wiring
// the Manager. Tests use it to coordinate without polling.
func ManagerStarted(m *Manager) <-chan struct{} { return m.started() }
// HashedResources exposes driftResources to tests.
func HashedResources(resources []Resource) []Resource { return driftResources(resources) }
// WatchDirs exposes the directory set Watcher.Sync would watch.
func WatchDirs(roots []ScanRoot) map[string]struct{} {
out := make(map[string]struct{})
for dir := range (*Watcher)(nil).collectDirs(roots) {
out[dir] = struct{}{}
}
return out
}
// WouldResolveOn exposes eventRelevant using the roles the last Sync
// published.
func (w *Watcher) WouldResolveOn(ev fsnotify.Event) bool {
return w.eventRelevant(ev)
}
+20 -8
View File
@@ -112,6 +112,10 @@ type Manager struct {
runStartedCh chan struct{}
watcher *Watcher
// watchSyncMu serializes watcher syncs. It is acquired before mu,
// never while mu is held.
watchSyncMu sync.Mutex
}
// NewManager validates options and canonicalizes initial sources. The
@@ -129,7 +133,7 @@ func NewManager(opts ManagerOptions) *Manager {
}
resolver := opts.Resolver
if resolver == nil {
resolver = &Resolver{}
resolver = &Resolver{Logger: opts.Logger.Named("resolver")}
}
m := &Manager{
@@ -215,9 +219,8 @@ func (m *Manager) Run(ctx context.Context) error {
}
m.mu.Lock()
m.watcher = watcher
roots := m.scanRootsLocked()
m.mu.Unlock()
watcher.Sync(ctx, roots)
m.syncWatcher(ctx, watcher)
defer watcher.Close()
@@ -228,15 +231,24 @@ func (m *Manager) Run(ctx context.Context) error {
case <-m.closedCh:
return nil
case <-m.trigger:
m.mu.Lock()
roots := m.scanRootsLocked()
m.mu.Unlock()
watcher.Sync(ctx, roots)
m.syncWatcher(ctx, watcher)
m.resolveAndBroadcast(ctx)
}
}
}
// syncWatcher applies the current scan roots to watcher. Captures
// and syncs are serialized, so the last sync to run always applies
// the newest roots. m.mu must not be held.
func (m *Manager) syncWatcher(ctx context.Context, watcher *Watcher) {
m.watchSyncMu.Lock()
defer m.watchSyncMu.Unlock()
m.mu.Lock()
roots := m.scanRootsLocked()
m.mu.Unlock()
watcher.Sync(ctx, roots)
}
// started returns a channel that is closed once Run has
// claimed the running flag. Tests use it to coordinate with
// the watcher loop without polling; a closed channel never
@@ -506,7 +518,7 @@ func (m *Manager) Resync(ctx context.Context) (Snapshot, error) {
m.mu.Unlock()
if watcher != nil {
watcher.Sync(ctx, roots)
m.syncWatcher(ctx, watcher)
}
// The broadcast is unconditional: Resync waiters that
+4 -4
View File
@@ -17,10 +17,10 @@ import (
// TestMain points the test binary's HOME (and USERPROFILE on
// Windows) at a fresh empty directory before any test runs.
// The package's built-in scan roots (~/.coder,
// ~/.coder/skills, ~/.claude/plugins/cache) canonicalize
// against this directory, so they resolve to non-existent
// paths and the resolver silently skips them. Without this,
// The package's built-in scan roots (~/.coder and
// ~/.coder/skills) canonicalize against this directory, so they
// resolve to non-existent paths and the resolver silently skips
// them. Without this,
// running the tests on a developer host pulls real Coder and
// Claude config files into snapshots and breaks every
// Len(Resources, N) assertion.
+574
View File
@@ -0,0 +1,574 @@
package agentcontext
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"unicode/utf8"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
strutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// Agent Plugins (agent-plugins.org) manifest conventions.
const (
pluginManifestFileName = "plugin.json"
pluginSkillsDirName = "skills"
// pluginSchemaPrefix identifies a plugin.json that belongs to the
// Agent Plugins format rather than another tool.
pluginSchemaPrefix = "https://agent-plugins.org/schemas/"
// PluginSchemaV1 is the only $schema value the resolver accepts.
PluginSchemaV1 = pluginSchemaPrefix + "1.0.0/plugin.schema.json"
// The spec sets no length limit on version or description
// (section 5.4); these caps are Coder's own.
// MaxPluginVersionRunes caps the manifest version carried on a
// plugin resource.
MaxPluginVersionRunes = 64
// MaxPluginDescriptionRunes caps the manifest description carried
// on a plugin resource.
MaxPluginDescriptionRunes = 1024
)
// maxReportedUnknownFields bounds how many unknown manifest keys are
// named in a plugin's warnings.
const maxReportedUnknownFields = 10
// pluginOrigin says where a plugin candidate directory was found.
type pluginOrigin int
const (
// pluginAtScanRoot is a scan root that holds a plugin.json.
pluginAtScanRoot pluginOrigin = iota
// pluginInGenericContainer is an entry of a scan root's plugins/
// directory, a name other tools also use.
pluginInGenericContainer
// pluginInAgentsContainer is an entry of a scan root's
// .agents/plugins/ directory.
pluginInAgentsContainer
)
// pluginResult is the outcome of inspecting a plugin candidate.
type pluginResult int
const (
// pluginAbsent means nothing was emitted: the directory has no
// plugin.json, or its plugin.json was ignored.
pluginAbsent pluginResult = iota
// pluginRejected means a plugin resource with a non-OK status
// was emitted and none of the plugin's components were loaded.
pluginRejected
// pluginValid means the plugin is StatusOK and owns its
// components.
pluginValid
// pluginShadowed means the plugin is valid but another resource
// already holds its Source, so nothing was emitted. Its directory
// is still treated as a plugin root.
pluginShadowed
)
const genericPluginContainerName = "plugins"
// pluginContainerRelPaths are the directories, relative to a scan
// root, whose immediate subdirectories are plugin candidates.
var pluginContainerRelPaths = []string{
genericPluginContainerName,
filepath.Join(".agents", "plugins"),
}
// pluginManifestFields are the top-level keys the 1.0.0 manifest
// schema defines. Any other key is reported and ignored.
var pluginManifestFields = map[string]struct{}{
"$schema": {},
"name": {},
"version": {},
"description": {},
"author": {},
"homepage": {},
"repository": {},
"license": {},
"keywords": {},
"extensions": {},
}
// pluginAuthorFields are the keys allowed inside the author object.
// The object is closed: any other key rejects the manifest.
var pluginAuthorFields = map[string]struct{}{
"name": {},
"email": {},
"url": {},
}
// canonicalExistingDir resolves symlinks in an existing directory
// path inside base. When the filesystem cannot report symlink targets
// (Windows substituted drives fail EvalSymlinks even for plain
// directories), the cleaned path is used only if it is a directory and
// no component from base down to dir is a link; otherwise the error
// stands.
func canonicalExistingDir(base, dir string) (string, error) {
resolved, err := filepath.EvalSymlinks(dir)
if err == nil {
return resolved, nil
}
info, lerr := os.Lstat(dir)
if lerr != nil || !info.IsDir() || hasLinkInside(base, dir) {
return "", err
}
return filepath.Clean(dir), nil
}
// hasLinkInside reports whether any component from path up to and
// including base is a symlink or another reparse point such as a
// Windows junction, or cannot be read. Lstat follows links in all but
// the last component, so each one is checked. Components above base are
// trusted; for a path outside base only its last component is checked.
func hasLinkInside(base, path string) bool {
base = filepath.Clean(base)
for p := filepath.Clean(path); ; p = filepath.Dir(p) {
info, err := os.Lstat(p)
if err != nil || info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return true
}
if p == base || !pathHasPrefix(p, base) {
return false
}
}
}
// pluginManifest is the validated content of a plugin.json.
type pluginManifest struct {
Name string
Version string
Description string
// Warnings lists the non-fatal deviations the spec requires a
// client to report and ignore: unknown top-level fields and a
// non-object extensions value.
Warnings []string
}
// foreignManifestError reports a plugin.json JSON object whose $schema
// is absent or is not an Agent Plugins schema.
type foreignManifestError struct{ msg string }
func (e *foreignManifestError) Error() string { return e.msg }
// parsePluginManifest validates data as an Agent Plugins 1.0.0
// manifest. recognized is false when the document is not a JSON object
// or its $schema is absent or names a different format; err then says
// why, as a *foreignManifestError for the $schema cases. When
// recognized is true, a non-nil err rejects the plugin.
func parsePluginManifest(data []byte) (m pluginManifest, recognized bool, err error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
if json.Valid(data) {
return pluginManifest{}, false, xerrors.New("plugin.json must be a JSON object")
}
return pluginManifest{}, false, xerrors.Errorf("plugin.json is not valid JSON: %w", err)
}
if fields == nil {
return pluginManifest{}, false, xerrors.New("plugin.json must be a JSON object")
}
var schema string
raw, ok := fields["$schema"]
if !ok {
return pluginManifest{}, false, &foreignManifestError{msg: fmt.Sprintf("plugin.json has no $schema; Agent Plugins manifests declare %q", PluginSchemaV1)}
}
if json.Unmarshal(raw, &schema) != nil || !strings.HasPrefix(schema, pluginSchemaPrefix) {
return pluginManifest{}, false, &foreignManifestError{msg: fmt.Sprintf("plugin.json $schema %s is not an Agent Plugins schema; Agent Plugins manifests declare %q", string(raw), PluginSchemaV1)}
}
if schema != PluginSchemaV1 {
return pluginManifest{}, true, xerrors.Errorf("unsupported $schema %q; this agent supports only %q", schema, PluginSchemaV1)
}
rawName, ok := fields["name"]
if !ok {
return pluginManifest{}, true, xerrors.New("name is required")
}
if m.Name, ok = jsonString(rawName); !ok {
return pluginManifest{}, true, xerrors.New("name must be a string")
}
if err := workspacesdk.ValidatePluginName(m.Name); err != nil {
return pluginManifest{}, true, err
}
for _, key := range []string{"version", "description", "homepage", "repository", "license"} {
raw, ok := fields[key]
if !ok {
continue
}
value, ok := jsonString(raw)
if !ok {
return pluginManifest{}, true, xerrors.Errorf("%s must be a string", key)
}
switch key {
case "version":
m.Version = value
case "description":
m.Description = value
}
}
if raw, ok := fields["keywords"]; ok {
var keywords []json.RawMessage
if err := json.Unmarshal(raw, &keywords); err != nil || keywords == nil {
return pluginManifest{}, true, xerrors.New("keywords must be an array of strings")
}
for _, keyword := range keywords {
if _, ok := jsonString(keyword); !ok {
return pluginManifest{}, true, xerrors.New("keywords must be an array of strings")
}
}
}
if raw, ok := fields["author"]; ok {
var author map[string]json.RawMessage
if err := json.Unmarshal(raw, &author); err != nil || author == nil {
return pluginManifest{}, true, xerrors.New("author must be an object")
}
for key, value := range author {
if _, known := pluginAuthorFields[key]; !known {
return pluginManifest{}, true, xerrors.Errorf("author contains unknown field %q", key)
}
if _, ok := jsonString(value); !ok {
return pluginManifest{}, true, xerrors.Errorf("author.%s must be a string", key)
}
}
}
if raw, ok := fields["extensions"]; ok {
trimmed := strings.TrimSpace(string(raw))
if !strings.HasPrefix(trimmed, "{") {
m.Warnings = append(m.Warnings, "extensions must be an object; ignored")
}
}
var unknown []string
for key := range fields {
if _, known := pluginManifestFields[key]; !known {
unknown = append(unknown, key)
}
}
slices.Sort(unknown)
for i, key := range unknown {
if i == maxReportedUnknownFields {
m.Warnings = append(m.Warnings, fmt.Sprintf("%d more unknown fields ignored", len(unknown)-i))
break
}
m.Warnings = append(m.Warnings, fmt.Sprintf("unknown field %q ignored", key))
}
m.Version = strutil.Truncate(codersdk.SanitizePromptText(m.Version), MaxPluginVersionRunes)
m.Description = strutil.Truncate(codersdk.SanitizePromptText(m.Description), MaxPluginDescriptionRunes)
return m, true, nil
}
// jsonString decodes raw as a JSON string. JSON null is not a string.
func jsonString(raw json.RawMessage) (string, bool) {
if strings.TrimSpace(string(raw)) == "null" {
return "", false
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false
}
return s, true
}
// pluginContainersFor returns the existing plugin-container
// directories under rootPath. A container that is a symlink, or whose
// resolved path leaves rootPath, is skipped.
func pluginContainersFor(rootPath string) []string {
canonicalRoot, err := canonicalExistingDir(rootPath, rootPath)
if err != nil {
return nil
}
var out []string
for _, rel := range pluginContainerRelPaths {
container := filepath.Join(rootPath, rel)
info, err := os.Lstat(container)
if err != nil || !info.IsDir() {
continue
}
resolved, err := canonicalExistingDir(rootPath, container)
if err != nil || !pathHasPrefix(resolved, canonicalRoot) {
continue
}
out = append(out, container)
}
return out
}
// discoverPlugin inspects dir as a plugin root. A rejected plugin
// emits one non-OK KindPlugin resource. A valid plugin emits a
// KindPlugin resource and one KindSkill per skill under skills/, and
// claims its name in pluginNames. A valid plugin whose name the same
// canonical root already holds emits nothing and is still valid; one
// whose Source is already held emits nothing and is shadowed.
//
// The root is canonicalized once and used for prefix checks, IDs, and
// Source. Every path read inside the plugin must resolve within it.
//
// Another tool may own a plugin.json at a scan root or in plugins/.
// At a scan root, one that is unreadable or lacks an Agent Plugins
// $schema is ignored. In plugins/, only an absent or foreign $schema
// is ignored. Every problem in .agents/plugins/ is reported.
//
// A root whose path is not valid UTF-8 or exceeds
// workspacesdk.MaxContextSourceBytes is skipped, since coderd rejects
// such a Source.
func (r *Resolver) discoverPlugin(dir string, root ScanRoot, out *[]Resource, seenSource map[string]int, pluginNames map[string]string, origin pluginOrigin) pluginResult {
pluginRoot, err := canonicalExistingDir(root.Path, dir)
if err != nil {
return pluginAbsent
}
manifestPath := filepath.Join(pluginRoot, pluginManifestFileName)
info, err := os.Lstat(manifestPath)
if err != nil {
return pluginAbsent
}
if !utf8.ValidString(pluginRoot) {
r.Logger.Warn(context.Background(), "skipping plugin whose path is not valid UTF-8; rename the directory to load it",
slog.F("path", strings.ToValidUTF8(pluginRoot, "\uFFFD")))
return pluginAbsent
}
if len(pluginRoot) > workspacesdk.MaxContextSourceBytes {
r.Logger.Warn(context.Background(), "skipping plugin whose path is too long; move or rename the directory to load it",
slog.F("path", pluginRoot), slog.F("max_bytes", workspacesdk.MaxContextSourceBytes))
return pluginAbsent
}
logIgnored := func(reason string) {
r.Logger.Debug(context.Background(), "ignoring plugin.json that is not an Agent Plugins manifest",
slog.F("path", manifestPath), slog.F("reason", reason))
}
res := Resource{
ID: resourceID(KindPlugin, pluginRoot),
Kind: KindPlugin,
Source: pluginRoot,
SizeBytes: safeUint64(info.Size()),
SourcePath: root.UserSource,
}
data, ok := r.readPluginManifest(manifestPath, info, pluginRoot, &res)
if !ok {
if origin == pluginAtScanRoot {
logIgnored(res.Error)
return pluginAbsent
}
r.appendResource(out, seenSource, res)
return pluginRejected
}
manifest, recognized, err := parsePluginManifest(data)
if !recognized {
foreign := errors.As(err, new(*foreignManifestError))
if origin == pluginAtScanRoot || (origin == pluginInGenericContainer && foreign) {
logIgnored(err.Error())
return pluginAbsent
}
}
if err != nil {
r.Logger.Debug(context.Background(), "invalid plugin manifest",
slog.F("path", manifestPath), slog.Error(err))
res.Status = StatusInvalid
res.Error = err.Error()
r.appendResource(out, seenSource, res)
return pluginRejected
}
if claimedBy, taken := pluginNames[manifest.Name]; taken {
if claimedBy == pluginRoot {
return pluginValid
}
r.Logger.Debug(context.Background(), "duplicate plugin name",
slog.F("path", manifestPath), slog.F("plugin", manifest.Name), slog.F("claimed_by", claimedBy))
res.Status = StatusInvalid
res.Error = fmt.Sprintf("plugin name %q is already used by %s; rename one of the plugins", manifest.Name, claimedBy)
r.appendResource(out, seenSource, res)
return pluginRejected
}
res.Name = manifest.Name
res.PluginVersion = manifest.Version
res.Description = manifest.Description
warnings := manifest.Warnings
skills, warn := r.pluginSkills(pluginRoot, root, manifest.Name)
if warn != "" {
warnings = append(warnings, warn)
}
res.Error = strings.Join(warnings, "; ")
if !r.appendResource(out, seenSource, res) {
return pluginShadowed
}
pluginNames[manifest.Name] = pluginRoot
for _, skill := range skills {
r.appendResource(out, seenSource, skill)
}
return pluginValid
}
// readPluginManifest reads manifestPath after checking that it
// resolves to a regular file inside pluginRoot and fits the
// per-resource cap. It records size and content hash on res; on
// failure it also sets res.Status and res.Error and returns false.
func (r *Resolver) readPluginManifest(manifestPath string, info fs.FileInfo, pluginRoot string, res *Resource) ([]byte, bool) {
readPath, readInfo, resolved, status, errMsg := resolveReadTarget(manifestPath, info, pluginRoot)
if !resolved {
res.Status = status
res.Error = errMsg
return nil, false
}
if !readInfo.Mode().IsRegular() {
res.Status = StatusInvalid
res.Error = "plugin.json is not a regular file"
return nil, false
}
res.SizeBytes = safeUint64(readInfo.Size())
if res.SizeBytes > r.MaxResourceBytes {
res.Status = StatusOversize
res.Error = fmt.Sprintf("file size %d exceeds per-resource cap of %d bytes", readInfo.Size(), r.MaxResourceBytes)
if data, err := readFileCapped(readPath, safeInt64(r.MaxResourceBytes)); err == nil {
res.ContentHash = sha256.Sum256(data)
}
return nil, false
}
data, err := os.ReadFile(readPath)
if err != nil {
res.Status = StatusUnreadable
res.Error = err.Error()
return nil, false
}
res.ContentHash = sha256.Sum256(data)
return data, true
}
// pluginSkillDir is a skill directory candidate inside a plugin's
// skills/ container.
type pluginSkillDir struct {
path string
// escapeErr is set when path is a symlink whose target leaves the
// plugin root.
escapeErr string
}
// pluginSkillDirs lists the skill directory candidates in the skills/
// container under pluginDir. Per spec section 4.1, the container and
// each skill directory may be symlinks whose targets stay inside
// pluginRoot, the canonical form of pluginDir. A missing container is
// silent; an escaping or non-directory container yields a warning. A
// skill directory with a non-UTF-8 name or a path over
// workspacesdk.MaxContextSourceBytes is skipped and counted in warning.
func pluginSkillDirs(pluginDir, pluginRoot string) (container string, dirs []pluginSkillDir, warning string) {
container = filepath.Join(pluginDir, pluginSkillsDirName)
info, err := os.Lstat(container)
if err != nil {
return "", nil, ""
}
if info.Mode()&fs.ModeSymlink != 0 {
target, err := filepath.EvalSymlinks(container)
if err != nil {
return "", nil, fmt.Sprintf("cannot resolve skills symlink: %v; skills not loaded", err)
}
if !pathHasPrefix(target, pluginRoot) {
return "", nil, fmt.Sprintf("skills symlink target %q escapes plugin root; skills not loaded", target)
}
if info, err = os.Stat(target); err != nil {
return "", nil, fmt.Sprintf("skills directory unreadable: %v; skills not loaded", err)
}
}
if !info.IsDir() {
return "", nil, "skills is not a directory; skills not loaded"
}
entries, err := os.ReadDir(container)
if err != nil {
return "", nil, fmt.Sprintf("skills directory unreadable: %v; skills not loaded", err)
}
invalidNames, longPaths := 0, 0
add := func(d pluginSkillDir) {
switch {
case !utf8.ValidString(filepath.Base(d.path)):
invalidNames++
case len(d.path) > workspacesdk.MaxContextSourceBytes:
longPaths++
default:
dirs = append(dirs, d)
}
}
for _, e := range entries {
dir := filepath.Join(container, e.Name())
if e.Type()&fs.ModeSymlink == 0 {
if e.IsDir() {
add(pluginSkillDir{path: dir})
}
continue
}
target, err := filepath.EvalSymlinks(dir)
if err != nil {
continue
}
if st, err := os.Stat(target); err != nil || !st.IsDir() {
continue
}
var escapeErr string
if !pathHasPrefix(target, pluginRoot) {
escapeErr = fmt.Sprintf("symlink target %q escapes plugin root %q", target, pluginRoot)
}
add(pluginSkillDir{path: dir, escapeErr: escapeErr})
}
var warnings []string
if invalidNames > 0 {
warnings = append(warnings, fmt.Sprintf("skill directories skipped because their names are not valid UTF-8: %d; rename them to load them", invalidNames))
}
if longPaths > 0 {
warnings = append(warnings, fmt.Sprintf("skill directories skipped because their paths are longer than %d bytes: %d; shorten them to load them", workspacesdk.MaxContextSourceBytes, longPaths))
}
return container, dirs, strings.Join(warnings, "; ")
}
// pluginSkills returns one KindSkill resource per skill directory in
// pluginRoot's skills/ container that holds a SKILL.md, plus the
// container warning. A skill directory or SKILL.md whose symlink
// target leaves pluginRoot is emitted as StatusInvalid.
func (r *Resolver) pluginSkills(pluginRoot string, root ScanRoot, pluginName string) ([]Resource, string) {
_, dirs, warning := pluginSkillDirs(pluginRoot, pluginRoot)
var skills []Resource
for _, dir := range dirs {
meta := filepath.Join(dir.path, skillMetaFileName)
metaInfo, err := os.Lstat(meta)
if err != nil {
continue
}
if dir.escapeErr != "" {
skills = append(skills, Resource{
ID: resourceID(KindSkill, dir.path),
Kind: KindSkill,
Source: dir.path,
SizeBytes: safeUint64(metaInfo.Size()),
SourcePath: root.UserSource,
Status: StatusInvalid,
Error: dir.escapeErr,
PluginName: pluginName,
})
continue
}
res, ok := r.readSkillMeta(pluginRoot, meta, metaInfo, root.UserSource)
if !ok {
continue
}
res.PluginName = pluginName
skills = append(skills, res)
}
return skills, warning
}
@@ -0,0 +1,55 @@
package agentcontext
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHasLinkInside(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
dir := t.TempDir()
target := filepath.Join(dir, "real")
require.NoError(t, os.MkdirAll(filepath.Join(target, "sub"), 0o755))
require.NoError(t, os.Symlink(target, filepath.Join(dir, "link")))
canonReal, err := filepath.EvalSymlinks(target)
require.NoError(t, err)
canonDir := filepath.Dir(canonReal)
assert.False(t, hasLinkInside(canonDir, filepath.Join(canonReal, "sub")))
assert.True(t, hasLinkInside(canonDir, filepath.Join(canonDir, "link", "sub")), "symlinked ancestor below base")
assert.True(t, hasLinkInside("/", filepath.Join(canonDir, "link", "sub")), "symlinked ancestor below a root base")
assert.True(t, hasLinkInside(canonDir, filepath.Join(canonReal, "missing")), "unreadable component")
assert.True(t, hasLinkInside(filepath.Join(canonDir, "link"), filepath.Join(canonDir, "link")), "base that is itself a link")
linkedSub := filepath.Join(canonDir, "link", "sub")
assert.False(t, hasLinkInside(linkedSub, linkedSub), "links above base are trusted")
}
func TestHasLinkInside_Junction(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" {
t.Skip("junctions exist only on Windows")
}
dir := t.TempDir()
target := filepath.Join(dir, "real")
require.NoError(t, os.MkdirAll(filepath.Join(target, "sub"), 0o755))
link := filepath.Join(dir, "link")
// Creating a junction needs no elevation, unlike a symlink.
out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput()
require.NoError(t, err, string(out))
assert.False(t, hasLinkInside(dir, filepath.Join(target, "sub")))
assert.True(t, hasLinkInside(dir, filepath.Join(link, "sub")), "junction below base")
assert.True(t, hasLinkInside(filepath.VolumeName(dir)+`\`, filepath.Join(link, "sub")), "junction below a drive root base")
}
File diff suppressed because it is too large Load Diff
+197 -59
View File
@@ -2,6 +2,7 @@ package agentcontext
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/json"
@@ -18,6 +19,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
@@ -70,6 +72,17 @@ var skillContainerRelPaths = []string{
filepath.Join(".codex", "skills"),
}
// ScanRootEntries returns the paths, relative to a scan root, that a
// resolve pass reads.
func ScanRootEntries() []string {
out := slices.Clone(instructionFileNames)
out = append(out, mcpConfigFileName)
out = append(out, skillContainerRelPaths...)
out = append(out, pluginManifestFileName)
out = append(out, pluginContainerRelPaths...)
return out
}
// recognizedInstructionFile reports whether name is one of the
// instruction-file conventions. Matching is case-sensitive:
// codex keys on the exact basename "AGENTS.md", so a lower-case
@@ -124,13 +137,15 @@ type Resolver struct {
// wires this to its MCP runner's snapshot; tests inject a
// closure directly.
MCPResources func() []Resource
// Logger receives diagnostics for plugin manifests that are
// rejected or ignored. The zero value discards them.
Logger slog.Logger
}
// ScanRoot describes a single directory or file the resolver
// should examine.
type ScanRoot struct {
// Path is the absolute path. Symlinks should already be
// resolved.
// Path is the absolute path. It may contain symlinks.
Path string
// UserSource is the canonical source path the user
// declared, when this root came from a user-added Source.
@@ -174,6 +189,7 @@ func (r *Resolver) ResolveContext(ctx context.Context, roots []ScanRoot) Snapsho
// where applyCaps left off.
resources, snapErrs = res.applyMCPCaps(resources, startIdx, totalBytes, snapErrs)
}
resources = withoutOrphanedPluginResources(resources)
// Deterministic order by ID for stable IDs and hashes.
slices.SortFunc(resources, func(a, b Resource) int {
@@ -225,10 +241,11 @@ func (r *Resolver) normalize() *Resolver {
//
// Discovery is deliberately shallow. For each scan root the
// resolver inspects only that directory's top level (instruction
// files and .mcp.json) plus a fixed set of skill-container
// locations under it. It never descends into subdirectories and
// never climbs to a parent directory; additional directories must
// be added explicitly as scan roots.
// files, .mcp.json, and plugin.json) plus a fixed
// set of skill-container and plugin-container locations under it.
// It never descends into subdirectories and never climbs to a
// parent directory; additional directories must be added
// explicitly as scan roots.
func (r *Resolver) walk(ctx context.Context, roots []ScanRoot) (resources []Resource, snapErrs []string) {
// Dedup roots by canonical path. The first occurrence
// wins so user-added roots that overlap with a built-in
@@ -246,17 +263,21 @@ func (r *Resolver) walk(ctx context.Context, roots []ScanRoot) (resources []Reso
dedup = append(dedup, root)
}
// Deduplicate resources across roots by ID so two roots that
// resolve to the same file (e.g. overlapping ancestors, or a
// built-in root nested under a project root) do not
// double-count it.
seenID := make(map[string]int)
// Deduplicate resources across roots by Source so two roots that
// resolve to the same path (e.g. overlapping ancestors, or a
// built-in root nested under a project root) do not double-count
// it. coderd rejects a push that repeats a Source, even across
// kinds.
seenSource := make(map[string]int)
// pluginNames maps each claimed plugin name to its canonical root.
// Roots are visited in priority order, so the first claim wins.
pluginNames := make(map[string]string)
for _, root := range dedup {
if err := ctx.Err(); err != nil {
return nil, []string{err.Error()}
}
r.discoverIn(root, &resources, seenID)
r.discoverIn(root, &resources, seenSource, pluginNames)
}
resources = slices.DeleteFunc(resources, func(resource Resource) bool {
return resource.ID == ""
@@ -264,18 +285,22 @@ func (r *Resolver) walk(ctx context.Context, roots []ScanRoot) (resources []Reso
return resources, snapErrs
}
// deduplicateSkills keeps the first valid skill with each name. walk returns
// resources in scan-root order, so this selects the winner before deterministic
// sorting and persistence discard root ordering.
// deduplicateSkills keeps the first valid skill per (PluginName, Name);
// resources must still be in scan-root order.
func deduplicateSkills(resources []Resource) []Resource {
seen := make(map[string]struct{})
type skillKey struct {
plugin string
name string
}
seen := make(map[skillKey]struct{})
out := resources[:0]
for _, resource := range resources {
if resource.Kind == KindSkill && resource.Status == StatusOK {
if _, ok := seen[resource.Name]; ok {
key := skillKey{plugin: resource.PluginName, name: resource.Name}
if _, ok := seen[key]; ok {
continue
}
seen[resource.Name] = struct{}{}
seen[key] = struct{}{}
}
out = append(out, resource)
}
@@ -285,8 +310,13 @@ func deduplicateSkills(resources []Resource) []Resource {
// discoverIn inspects a single scan root. A root that points at a
// file is classified directly. A directory root contributes its
// top-level instruction files and .mcp.json plus skills from the
// fixed container locations under it. The walk goes no deeper.
func (r *Resolver) discoverIn(root ScanRoot, out *[]Resource, seenID map[string]int) {
// fixed container locations under it, and plugins from the fixed
// plugin containers. A root that is itself a plugin, valid or
// rejected, keeps its instruction files, .mcp.json, and other skill
// containers, but its skills/ belongs to the plugin. A valid plugin
// root's plugin containers are not scanned, even when shadowed. The
// walk goes no deeper.
func (r *Resolver) discoverIn(root ScanRoot, out *[]Resource, seenSource map[string]int, pluginNames map[string]string) {
info, err := os.Stat(root.Path)
if err != nil {
// Missing roots silently fall through. The user either
@@ -296,13 +326,38 @@ func (r *Resolver) discoverIn(root ScanRoot, out *[]Resource, seenID map[string]
}
if !info.IsDir() {
if res, ok := r.classifyFile(root.Path, root.Path, info, root.UserSource); ok {
appendResource(out, seenID, res)
r.appendResource(out, seenSource, res)
}
return
}
r.discoverTopLevelFiles(root, out, seenID)
var pluginSkillsContainer string
result := r.discoverPlugin(root.Path, root, out, seenSource, pluginNames, pluginAtScanRoot)
if result != pluginAbsent {
pluginSkillsContainer = filepath.Join(root.Path, pluginSkillsDirName)
}
if result != pluginValid && result != pluginShadowed {
for _, container := range pluginContainersFor(root.Path) {
origin := pluginInAgentsContainer
if container == filepath.Join(root.Path, genericPluginContainerName) {
origin = pluginInGenericContainer
}
r.emitPluginsFromContainer(container, root, out, seenSource, pluginNames, origin)
}
}
r.discoverTopLevelFiles(root, out, seenSource)
for _, container := range skillContainersFor(root.Path) {
r.emitSkillsFromContainer(container, root, out, seenID)
if container == pluginSkillsContainer {
continue
}
r.emitSkillsFromContainer(container, root, out, seenSource)
}
}
// emitPluginsFromContainer treats each immediate child directory of
// container, excluding symlinks, as a plugin candidate found at origin.
func (r *Resolver) emitPluginsFromContainer(container string, root ScanRoot, out *[]Resource, seenSource map[string]int, pluginNames map[string]string, origin pluginOrigin) {
for _, dir := range immediateSubdirs(container) {
r.discoverPlugin(dir, root, out, seenSource, pluginNames, origin)
}
}
@@ -310,7 +365,7 @@ func (r *Resolver) discoverIn(root ScanRoot, out *[]Resource, seenID map[string]
// .mcp.json that sit directly in root.Path. Nested files are
// ignored: instruction files and MCP configs are recognized only
// at a scan root's top level.
func (r *Resolver) discoverTopLevelFiles(root ScanRoot, out *[]Resource, seenID map[string]int) {
func (r *Resolver) discoverTopLevelFiles(root ScanRoot, out *[]Resource, seenSource map[string]int) {
entries, err := os.ReadDir(root.Path)
if err != nil {
return
@@ -338,27 +393,69 @@ func (r *Resolver) discoverTopLevelFiles(root ScanRoot, out *[]Resource, seenID
} else {
res = r.readMCPConfig(root.Path, path, info, root.UserSource)
}
appendResource(out, seenID, res)
r.appendResource(out, seenSource, res)
}
}
// appendResource adds res to out unless an earlier resource already claimed
// its ID. A valid occurrence replaces an earlier invalid one because the same
// path can be outside a narrow scan root but valid inside a later broader root.
// Replacements tombstone the earlier occurrence so valid discovery order is
// preserved without repeatedly shifting the resource slice.
func appendResource(out *[]Resource, seenID map[string]int, res Resource) {
if previous, dup := seenID[res.ID]; dup {
if (*out)[previous].Status == StatusOK || res.Status != StatusOK {
return
// its Source, and reports whether res was added. A valid occurrence replaces
// an earlier invalid one because the same path can be outside a narrow scan
// root but valid inside a later broader root, or be both an invalid skill and
// a valid plugin. Replacements tombstone the earlier occurrence so valid
// discovery order is preserved without repeatedly shifting the resource slice.
// An OK resource that wins over a rejected plugin keeps the plugin's error
// as a warning.
func (r *Resolver) appendResource(out *[]Resource, seenSource map[string]int, res Resource) bool {
key := sourceKey(res)
if previous, dup := seenSource[key]; dup {
kept := (*out)[previous]
if kept.Status == StatusOK || res.Status != StatusOK {
if kept.Kind != res.Kind {
r.Logger.Debug(context.Background(), "skipping context resource whose path another scan root already reports as a different kind; a skipped plugin also skips its skills; remove the overlapping scan root to load it",
slog.F("source", res.Source), slog.F("kind", res.Kind.String()), slog.F("kept_kind", kept.Kind.String()))
if kept.Status == StatusOK {
(*out)[previous].Error = withRejectedPluginWarning(kept.Error, res)
}
}
return false
}
if kept.Kind != res.Kind {
res.Error = withRejectedPluginWarning(res.Error, kept)
}
(*out)[previous] = Resource{}
seenID[res.ID] = len(*out)
seenSource[key] = len(*out)
*out = append(*out, res)
return
return true
}
seenID[res.ID] = len(*out)
seenSource[key] = len(*out)
*out = append(*out, res)
return true
}
// sourceKey returns the key that deduplicates res across scan roots. A skill
// Source is the walked path, which may go through a symlinked scan root, so
// it is keyed on its resolved path; plugin Sources are already resolved.
func sourceKey(res Resource) string {
if res.Kind == KindSkill {
if resolved, err := filepath.EvalSymlinks(res.Source); err == nil {
return resolved
}
}
return res.Source
}
// withRejectedPluginWarning appends the error of a rejected plugin to
// warning. It returns warning unchanged when rejected is not a non-OK
// plugin.
func withRejectedPluginWarning(warning string, rejected Resource) string {
if rejected.Kind != KindPlugin || rejected.Status == StatusOK {
return warning
}
pluginWarning := "plugin.json in this directory was rejected: " + rejected.Error
if warning == "" {
return pluginWarning
}
return warning + "; " + pluginWarning
}
// resolveReadTarget produces the path and FileInfo that should
@@ -538,8 +635,8 @@ func (r *Resolver) readFileResource(kind ResourceKind, scanRoot, path string, in
// Attribute the resource to the resolved target rather than
// the path we walked. When several names point at the same
// file (e.g. CLAUDE.md and .cursorrules symlinked to
// AGENTS.md), they share an ID and collapse to a single
// resource via the walk's ID-based dedup, instead of shipping
// AGENTS.md), they share a Source and collapse to a single
// resource via the walk's Source-based dedup, instead of shipping
// identical content multiple times. On resolve failure the
// original path is kept so the error points at the offending
// link.
@@ -645,7 +742,7 @@ func (r *Resolver) readSkillMeta(scanRoot, path string, info fs.FileInfo, userSo
// emitSkillsFromContainer scans the immediate children of a
// recognized skills-container directory and emits one Skill
// resource per subdirectory whose SKILL.md parses cleanly.
func (r *Resolver) emitSkillsFromContainer(container string, root ScanRoot, out *[]Resource, seenID map[string]int) {
func (r *Resolver) emitSkillsFromContainer(container string, root ScanRoot, out *[]Resource, seenSource map[string]int) {
entries, err := os.ReadDir(container)
if err != nil {
return
@@ -666,7 +763,7 @@ func (r *Resolver) emitSkillsFromContainer(container string, root ScanRoot, out
if !ok {
continue
}
appendResource(out, seenID, res)
r.appendResource(out, seenSource, res)
}
}
@@ -677,11 +774,12 @@ func (r *Resolver) emitSkillsFromContainer(container string, root ScanRoot, out
// append additional resources (e.g. MCP server tool lists) can
// apply the same byte cap to the appended slice.
func (r *Resolver) applyCaps(resources []Resource) ([]Resource, uint64) {
// Stable sort by (Kind asc, Source asc) so excluded
// resources are deterministic.
// Stable sort by (capRank, Source) so excluded resources are
// deterministic. capRank puts plugins ahead of skills, so a
// count cut that keeps a plugin's skills also keeps the plugin.
slices.SortStableFunc(resources, func(a, b Resource) int {
if a.Kind != b.Kind {
return int(a.Kind) - int(b.Kind)
return cmp.Compare(capRank(a.Kind), capRank(b.Kind))
}
return strings.Compare(a.Source, b.Source)
})
@@ -707,6 +805,34 @@ func (r *Resolver) applyCaps(resources []Resource) ([]Resource, uint64) {
return resources, total
}
// withoutOrphanedPluginResources drops skills and MCP servers whose
// PluginName names no StatusOK plugin in resources, such as those of
// a plugin excluded by a cap; coderd rejects a push that holds one.
func withoutOrphanedPluginResources(resources []Resource) []Resource {
okPlugins := make(map[string]struct{})
for _, r := range resources {
if r.Kind == KindPlugin && r.Status == StatusOK {
okPlugins[r.Name] = struct{}{}
}
}
return slices.DeleteFunc(resources, func(r Resource) bool {
if r.Kind == KindPlugin || r.PluginName == "" {
return false
}
_, ok := okPlugins[r.PluginName]
return !ok
})
}
// capRank orders kinds for applyCaps: kind order, with KindPlugin
// moved directly ahead of KindSkill.
func capRank(kind ResourceKind) int {
if kind == KindPlugin {
return 2*int(KindSkill) - 1
}
return 2 * int(kind)
}
// applyMCPCaps enforces both the count cap and the remaining
// aggregate byte cap on MCP resources appended after
// applyCaps. startIdx is the first index of the appended tail.
@@ -758,9 +884,9 @@ func excluded(r Resource, reason string) Resource {
return r
}
// resourceID builds a stable resource ID. Kind plus canonical
// source path is enough; sources never collide across kinds for
// v1 because each kind owns a distinct file-name pattern.
// resourceID builds a stable resource ID from kind and source path.
// A Source can repeat across kinds (a directory can be both a skill
// and a plugin), so IDs do not deduplicate paths.
func resourceID(kind ResourceKind, source string) string {
return kind.String() + ":" + source
}
@@ -817,9 +943,9 @@ func safeInt64(n uint64) int64 {
// ResourceKind describes the category of a resolved context
// resource. The values mirror the proto ContextResource.Kind
// enum reserved in the RFC; future kinds (PLUGIN, HOOK,
// SUBAGENT, COMMAND) are defined here so callers can switch
// exhaustively, but no v1 resolver emits them.
// enum reserved in the RFC; future kinds (HOOK, SUBAGENT,
// COMMAND) are defined here so callers can switch exhaustively,
// but no resolver emits them.
type ResourceKind int
const (
@@ -839,15 +965,17 @@ const (
// populated from the MCP runner's snapshot after the server
// has been connected.
KindMCPServer
// KindPlugin is an Agent Plugins manifest. Not emitted by v1.
// KindPlugin is an Agent Plugins manifest. Source is the
// canonical plugin root and ContentHash covers the manifest
// bytes.
KindPlugin
// KindHook is reserved for plugin hooks. Not emitted by v1.
// KindHook is reserved for plugin hooks. Not emitted.
KindHook
// KindSubagent is reserved for plugin-declared subagents.
// Not emitted by v1.
// Not emitted.
KindSubagent
// KindCommand is reserved for plugin slash commands.
// Not emitted by v1.
// Not emitted.
KindCommand
)
@@ -955,13 +1083,15 @@ type Resource struct {
// also carry a non-fatal warning when Status == StatusOK.
Error string
// Name is the resource's own short identifier. Currently
// populated for KindSkill (from front-matter) and
// KindMCPServer (server name); empty for other kinds.
// populated for KindSkill (from front-matter), KindMCPServer
// (server name), and KindPlugin (manifest name); empty for
// other kinds.
Name string
// Description is a short human-readable summary (skill
// front-matter description, MCP server description,
// instruction-file first line). Shipped on the wire only
// for kinds whose body type carries a description field.
// instruction-file first line, plugin manifest description).
// Shipped on the wire only for kinds whose body type carries
// a description field.
Description string
// SourcePath is the user-declared source that contributed
// the resource; empty for built-in scan roots.
@@ -969,6 +1099,13 @@ type Resource struct {
// Tools is populated for KindMCPServer with the live
// server's tool list; empty otherwise.
Tools []MCPTool
// PluginName is the name of the Agent Plugin a KindSkill or
// KindMCPServer resource belongs to; empty for everything else,
// including KindPlugin, whose name is in Name.
PluginName string
// PluginVersion is the manifest version for KindPlugin, sanitized
// and capped at MaxPluginVersionRunes.
PluginVersion string
}
// MCPTool mirrors the wire MCPTool message. InputSchema is the
@@ -1004,8 +1141,9 @@ type Snapshot struct {
}
// driftResources excludes MCP resources because agents discover them
// asynchronously and coderd live-syncs them onto bound chats. Instructions
// and skills remain drift-relevant because their content is pinned.
// asynchronously and coderd live-syncs them onto bound chats. Instructions,
// skills, and plugin manifests remain drift-relevant because their content
// is pinned.
func driftResources(resources []Resource) []Resource {
out := make([]Resource, 0, len(resources))
for _, r := range resources {
+129 -38
View File
@@ -5,7 +5,9 @@ import (
"errors"
"os"
"path/filepath"
"slices"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -34,20 +36,23 @@ type WatcherOptions struct {
}
// Watcher is a fixed-location fsnotify wrapper. It watches only
// the directories that can hold recognized resources (each scan
// root plus its skill containers and immediate skill dirs) rather
// than walking the tree, mirroring the resolver's fixed-location
// discovery. Inotify ENOSPC degrades the watcher into a poll-only
// mode that still re-resolves on Sync calls.
// the directories that can hold recognized resources (see
// collectDirs) rather than walking the tree. Inotify ENOSPC
// degrades the watcher into a poll-only mode that still
// re-resolves on Sync calls.
type Watcher struct {
logger slog.Logger
clock quartz.Clock
debounce time.Duration
onChange func()
mu sync.Mutex
watcher *fsnotify.Watcher
watched map[string]struct{}
mu sync.Mutex
watcher *fsnotify.Watcher
// watched maps each directory with an active fsnotify watch to its
// role. Sync replaces it under w.mu; the event loop reads it without
// w.mu because on Windows fsnotify Add and Remove wait on the event
// goroutine, so locking per event can deadlock against Sync.
watched atomic.Pointer[map[string]dirRole]
timer *quartz.Timer
degraded string // non-empty when the watcher dropped events
closed bool
@@ -81,7 +86,6 @@ func NewWatcher(opts WatcherOptions) (*Watcher, error) {
clock: clock,
debounce: debounce,
onChange: opts.OnChange,
watched: make(map[string]struct{}),
degraded: "fsnotify init failed: " + err.Error(),
closedCh: make(chan struct{}),
runDoneCh: closedChan(),
@@ -95,7 +99,6 @@ func NewWatcher(opts WatcherOptions) (*Watcher, error) {
debounce: debounce,
onChange: opts.OnChange,
watcher: w,
watched: make(map[string]struct{}),
closedCh: make(chan struct{}),
runDoneCh: make(chan struct{}),
}
@@ -121,9 +124,8 @@ func (w *Watcher) Degraded() string {
return w.degraded
}
// Sync replaces the set of watched directories with the fixed
// locations that can hold recognized resources: each scan root,
// its skill containers, and the immediate skill subdirectories.
// Sync replaces the set of watched directories with the set
// collectDirs returns for roots.
// Files are not watched directly; watching the parent directory
// catches creates, renames, removes, and writes that touch any
// recognized basename. Files that are themselves scan roots are
@@ -167,20 +169,25 @@ func (w *Watcher) Sync(ctx context.Context, roots []ScanRoot) {
return
}
var cur map[string]dirRole
if p := w.watched.Load(); p != nil {
cur = *p
}
next := make(map[string]dirRole, len(desired))
// Remove directories no longer wanted.
for path := range w.watched {
if _, ok := desired[path]; ok {
for path := range cur {
if role, ok := desired[path]; ok {
next[path] = role
continue
}
_ = w.watcher.Remove(path)
delete(w.watched, path)
}
// Track whether every Add in this pass succeeded so a
// recovered ENOSPC clears the degraded marker.
addedAll := true
// Add directories that are new.
for path := range desired {
if _, ok := w.watched[path]; ok {
for path, role := range desired {
if _, ok := cur[path]; ok {
continue
}
if err := w.watcher.Add(path); err != nil {
@@ -199,8 +206,9 @@ func (w *Watcher) Sync(ctx context.Context, roots []ScanRoot) {
slog.F("dir", path), slog.Error(err))
continue
}
w.watched[path] = struct{}{}
next[path] = role
}
w.watched.Store(&next)
// Clear a previously-set ENOSPC mark when every Add in this
// pass succeeded. A user who bumps the kernel's inotify
// limit and re-syncs now sees a clean snapshot instead of a
@@ -275,10 +283,22 @@ func (w *Watcher) run() {
// eventRelevant filters out events that cannot affect any
// recognized resource. The check is conservative: any event on
// a directory triggers a re-resolve so newly created subtrees
// are picked up.
func (*Watcher) eventRelevant(ev fsnotify.Event) bool {
// are picked up. In a directory watched only as a plugin candidate,
// only plugin.json and skills matter.
func (w *Watcher) eventRelevant(ev fsnotify.Event) bool {
name := filepath.Base(ev.Name)
if recognizedInstructionFile(name) || name == mcpConfigFileName || name == skillMetaFileName {
var parentRole dirRole
if roles := w.watched.Load(); roles != nil {
parentRole = (*roles)[filepath.Dir(ev.Name)]
}
if parentRole == dirPluginCandidate {
return name == pluginManifestFileName || name == pluginSkillsDirName
}
switch name {
case mcpConfigFileName, skillMetaFileName, pluginManifestFileName:
return true
}
if recognizedInstructionFile(name) {
return true
}
// Directory create/remove flips re-resolve so new subtrees
@@ -311,14 +331,30 @@ func (w *Watcher) schedule() {
w.mu.Unlock()
}
// dirRole records why a directory is watched, as a bit set.
type dirRole uint8
const (
// dirGeneral marks a directory watched for any reason other than
// being a plugin candidate.
dirGeneral dirRole = 1 << iota
dirPluginCandidate
)
// collectDirs returns the set of directories to watch. Discovery
// is fixed-location, mirroring the resolver: for each scan root we
// watch the root directory itself (catching top-level instruction
// and .mcp.json changes), plus every existing skill container and
// its immediate skill subdirectories (catching skill add/remove
// and SKILL.md writes). The watcher never recurses the tree.
func (*Watcher) collectDirs(roots []ScanRoot) map[string]struct{} {
out := make(map[string]struct{})
// watch the root directory itself (catching top-level instruction,
// .mcp.json, and plugin.json changes), every existing skill
// container and its immediate skill subdirectories (catching skill
// add/remove and SKILL.md writes), and every existing plugin
// container with each immediate plugin directory, its skills
// container, and that container's immediate subdirectories.
// Plugin skills, including a plugin scan root's, are watched whenever
// plugin.json exists, valid or not. When a nested plugin container is
// missing, its existing parent (such as .agents) is watched so creating
// the container fires. The watcher never recurses the tree.
func (*Watcher) collectDirs(roots []ScanRoot) map[string]dirRole {
out := make(map[string]dirRole)
for _, root := range roots {
if root.Path == "" {
continue
@@ -328,27 +364,82 @@ func (*Watcher) collectDirs(roots []ScanRoot) map[string]struct{} {
// Watch the deepest existing ancestor so the
// root being created later still fires.
if ancestor := existingAncestor(root.Path); ancestor != "" {
out[ancestor] = struct{}{}
out[ancestor] |= dirGeneral
}
continue
}
if !info.IsDir() {
out[filepath.Dir(root.Path)] = struct{}{}
out[filepath.Dir(root.Path)] |= dirGeneral
continue
}
out[root.Path] = struct{}{}
out[root.Path] |= dirGeneral
for _, container := range skillContainersFor(root.Path) {
out[container] = struct{}{}
entries, err := os.ReadDir(container)
if err != nil {
collectContainerDirs(container, out)
}
collectPluginSkillDirs(root.Path, root.Path, out)
containers := pluginContainersFor(root.Path)
for _, rel := range pluginContainerRelPaths {
container := filepath.Join(root.Path, rel)
parent := filepath.Dir(container)
if slices.Contains(containers, container) || parent == root.Path {
continue
}
for _, e := range entries {
if e.IsDir() {
out[filepath.Join(container, e.Name())] = struct{}{}
}
if info, err := os.Lstat(parent); err == nil && info.IsDir() {
out[parent] |= dirGeneral
}
}
for _, container := range containers {
out[container] |= dirGeneral
for _, pluginDir := range immediateSubdirs(container) {
out[pluginDir] |= dirPluginCandidate
collectPluginSkillDirs(root.Path, pluginDir, out)
}
}
}
return out
}
// collectPluginSkillDirs adds, when pluginDir under rootPath holds a
// plugin.json, its skills container and the skill directories
// pluginSkillDirs lists inside the plugin root.
func collectPluginSkillDirs(rootPath, pluginDir string, out map[string]dirRole) {
if _, err := os.Lstat(filepath.Join(pluginDir, pluginManifestFileName)); err != nil {
return
}
pluginRoot, err := canonicalExistingDir(rootPath, pluginDir)
if err != nil {
return
}
container, dirs, _ := pluginSkillDirs(pluginDir, pluginRoot)
if container == "" {
return
}
out[container] |= dirGeneral
for _, dir := range dirs {
if dir.escapeErr == "" {
out[dir.path] |= dirGeneral
}
}
}
func collectContainerDirs(container string, out map[string]dirRole) {
out[container] |= dirGeneral
for _, dir := range immediateSubdirs(container) {
out[dir] |= dirGeneral
}
}
// immediateSubdirs omits symlinks, including symlinks to directories.
func immediateSubdirs(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, filepath.Join(dir, e.Name()))
}
}
return out
}
+181
View File
@@ -4,10 +4,12 @@ import (
"context"
"os"
"path/filepath"
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/fsnotify/fsnotify"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentcontext"
@@ -72,6 +74,62 @@ func TestWatcher_FiresOnNewSkillFile(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast, "expected fire after SKILL.md create")
}
func TestWatcher_FiresOnPluginSkillFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
pluginDir := filepath.Join(dir, ".agents", "plugins", "p")
mustWritePlugin(t, pluginDir, "p")
// The skill directory exists before Sync so the test exercises
// the watch on the plugin's immediate skill directories, not
// the directory-create path on its skills container.
skillDir := filepath.Join(pluginDir, "skills", "foo")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
var fires atomic.Int32
w, err := agentcontext.NewWatcher(agentcontext.WatcherOptions{
Logger: testutil.Logger(t).Named("watcher"),
Debounce: 10 * time.Millisecond,
OnChange: func() { fires.Add(1) },
})
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
ctx := testutil.Context(t, testutil.WaitShort)
w.Sync(ctx, []agentcontext.ScanRoot{{Path: dir}})
// Write SKILL.md inside Eventually so the test does not race
// fsnotify's watch-setup window.
require.Eventually(t, func() bool {
_ = os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: foo\ndescription: bar\n---\nbody"), 0o600)
return fires.Load() >= 1
}, testutil.WaitShort, testutil.IntervalFast, "expected fire after plugin SKILL.md write")
}
func TestWatcher_FiresOnPluginManifestEdit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins", "p")
mustWritePlugin(t, pluginDir, "p")
var fires atomic.Int32
w, err := agentcontext.NewWatcher(agentcontext.WatcherOptions{
Logger: testutil.Logger(t).Named("watcher"),
Debounce: 10 * time.Millisecond,
OnChange: func() { fires.Add(1) },
})
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
ctx := testutil.Context(t, testutil.WaitShort)
w.Sync(ctx, []agentcontext.ScanRoot{{Path: dir}})
manifest := pluginJSON(t, map[string]any{"name": "p", "version": "2"})
require.Eventually(t, func() bool {
_ = os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600)
return fires.Load() >= 1
}, testutil.WaitShort, testutil.IntervalFast, "expected fire after plugin.json edit")
}
func TestWatcher_CloseIsIdempotent(t *testing.T) {
t.Parallel()
w, err := agentcontext.NewWatcher(agentcontext.WatcherOptions{
@@ -95,3 +153,126 @@ func TestWatcher_SyncAfterCloseNoop(t *testing.T) {
// Must not panic.
w.Sync(context.Background(), []agentcontext.ScanRoot{{Path: t.TempDir()}})
}
func TestWatcher_FiresOnNestedPluginContainerCreate(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "s"), 0o755))
var fires atomic.Int32
w, err := agentcontext.NewWatcher(agentcontext.WatcherOptions{
Logger: testutil.Logger(t).Named("watcher"),
Debounce: 10 * time.Millisecond,
OnChange: func() { fires.Add(1) },
})
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
ctx := testutil.Context(t, testutil.WaitShort)
w.Sync(ctx, []agentcontext.ScanRoot{{Path: dir}})
// Toggle the container inside Eventually so the test does not
// race fsnotify's watch-setup window.
container := filepath.Join(dir, ".agents", "plugins")
require.Eventually(t, func() bool {
if err := os.Mkdir(container, 0o755); err != nil {
_ = os.Remove(container)
}
return fires.Load() >= 1
}, testutil.WaitShort, testutil.IntervalFast, "expected fire after .agents/plugins create")
}
func TestWatcher_PluginDirsWatched(t *testing.T) {
t.Parallel()
dir := testutil.TempDirResolved(t)
pluginDir := filepath.Join(dir, "plugins", "auth")
require.NoError(t, os.MkdirAll(pluginDir, 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents"), 0o755))
roots := []agentcontext.ScanRoot{{Path: dir}}
dirs := agentcontext.WatchDirs(roots)
require.Contains(t, dirs, filepath.Join(dir, "plugins"))
require.Contains(t, dirs, pluginDir)
require.Contains(t, dirs, filepath.Join(dir, ".agents"), "parent of the missing .agents/plugins")
}
// A plugin candidate directory is watched so a new plugin.json is
// seen, but other edits in it do not re-resolve, and its skills are
// watched only once it holds a plugin.json.
func TestWatcher_PluginCandidateEventsFiltered(t *testing.T) {
t.Parallel()
dir := testutil.TempDirResolved(t)
pluginDir := filepath.Join(dir, "plugins", "auth")
skillDir := filepath.Join(pluginDir, "skills", "s")
require.NoError(t, os.MkdirAll(skillDir, 0o755))
roots := []agentcontext.ScanRoot{{Path: dir}}
require.NotContains(t, agentcontext.WatchDirs(roots), skillDir)
w, err := agentcontext.NewWatcher(agentcontext.WatcherOptions{
Logger: testutil.Logger(t).Named("watcher"),
OnChange: func() {},
})
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
w.Sync(testutil.Context(t, testutil.WaitShort), roots)
relevant := func(name string, op fsnotify.Op) bool {
return w.WouldResolveOn(fsnotify.Event{Name: filepath.Join(pluginDir, name), Op: op})
}
require.False(t, relevant("main.go", fsnotify.Create))
require.False(t, relevant("main.go", fsnotify.Write))
require.False(t, relevant("AGENTS.md", fsnotify.Write))
require.True(t, relevant("plugin.json", fsnotify.Create))
require.True(t, relevant("skills", fsnotify.Remove))
require.True(t, w.WouldResolveOn(fsnotify.Event{Name: filepath.Join(dir, "plugins", "new"), Op: fsnotify.Create}))
mustWritePlugin(t, pluginDir, "auth")
require.Contains(t, agentcontext.WatchDirs(roots), skillDir)
}
// A directory that is both a plugin candidate and a scan root keeps the
// scan root's role, so instruction file edits in it re-resolve.
func TestWatcher_PluginCandidateAlsoScanRoot(t *testing.T) {
t.Parallel()
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins", "p")
mustWritePlugin(t, pluginDir, "p")
agentsMD := filepath.Join(pluginDir, "AGENTS.md")
mustWriteFile(t, agentsMD, "v1")
var fires atomic.Int32
w, err := agentcontext.NewWatcher(agentcontext.WatcherOptions{
Logger: testutil.Logger(t).Named("watcher"),
Debounce: 10 * time.Millisecond,
OnChange: func() { fires.Add(1) },
})
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
ctx := testutil.Context(t, testutil.WaitShort)
w.Sync(ctx, []agentcontext.ScanRoot{{Path: dir}})
w.Sync(ctx, []agentcontext.ScanRoot{{Path: dir}, {Path: pluginDir}})
require.Eventually(t, func() bool {
_ = os.WriteFile(agentsMD, []byte("v2"), 0o600)
return fires.Load() >= 1
}, testutil.WaitShort, testutil.IntervalFast, "expected fire after AGENTS.md edit in a plugin that is also a scan root")
}
// A scan root that is a plugin has its skills/ read through the plugin
// rules, so an in-root symlinked skill directory there is watched.
func TestWatcher_RootPluginSymlinkedSkillDirWatched(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin privileges on Windows runners")
}
t.Parallel()
root := testutil.TempDirResolved(t)
mustWritePlugin(t, root, "rp")
shared := filepath.Join(root, "shared", "s")
require.NoError(t, os.MkdirAll(shared, 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(root, "skills"), 0o755))
link := filepath.Join(root, "skills", "s")
require.NoError(t, os.Symlink(shared, link))
roots := []agentcontext.ScanRoot{{Path: root}}
require.Contains(t, agentcontext.WatchDirs(roots), link)
}
+24 -20
View File
@@ -237,16 +237,18 @@ func contextSnapshotFromProto(s *proto.ContextSnapshot) ContextSnapshot {
}
for _, r := range s.GetResources() {
out.Resources = append(out.Resources, ContextResource{
ID: r.GetId(),
Kind: r.GetKind(),
Source: r.GetSource(),
SourcePath: r.GetSourcePath(),
ContentHash: r.GetContentHash(),
SizeBytes: r.GetSizeBytes(),
Status: r.GetStatus(),
Error: r.GetError(),
Name: r.GetName(),
Description: r.GetDescription(),
ID: r.GetId(),
Kind: r.GetKind(),
Source: r.GetSource(),
SourcePath: r.GetSourcePath(),
ContentHash: r.GetContentHash(),
SizeBytes: r.GetSizeBytes(),
Status: r.GetStatus(),
Error: r.GetError(),
Name: r.GetName(),
Description: r.GetDescription(),
PluginName: r.GetPluginName(),
PluginVersion: r.GetPluginVersion(),
})
}
return out
@@ -283,16 +285,18 @@ type ContextSource struct {
// ContextResource is a resolved workspace-context resource. Payload bytes are
// never carried over the socket.
type ContextResource struct {
Kind string `table:"kind,default_sort" json:"kind"`
Name string `table:"name" json:"name"`
Source string `table:"source" json:"source"`
SourcePath string `table:"source path" json:"source_path"`
Status string `table:"status" json:"status"`
SizeBytes uint64 `table:"size bytes" json:"size_bytes"`
Error string `table:"error" json:"error"`
Description string `table:"-" json:"description"`
ID string `table:"-" json:"id"`
ContentHash string `table:"-" json:"content_hash"`
Kind string `table:"kind,default_sort" json:"kind"`
Name string `table:"name" json:"name"`
PluginName string `table:"plugin" json:"plugin_name"`
Source string `table:"source" json:"source"`
SourcePath string `table:"source path" json:"source_path"`
Status string `table:"status" json:"status"`
SizeBytes uint64 `table:"size bytes" json:"size_bytes"`
Error string `table:"error" json:"error"`
Description string `table:"-" json:"description"`
ID string `table:"-" json:"id"`
ContentHash string `table:"-" json:"content_hash"`
PluginVersion string `table:"-" json:"plugin_version"`
}
// ContextSnapshot is the agent's resolved workspace-context state.
+19 -1
View File
@@ -85,6 +85,22 @@ func TestDRPCAgentSocketService_Context(t *testing.T) {
Kind: agentcontext.KindInstructionFile,
Source: "/home/coder/.coder/AGENTS.md",
Status: agentcontext.StatusOK,
}, {
ID: "skill:" + sourcePath + "/.agents/plugins/acme/skills/deploy",
Kind: agentcontext.KindSkill,
Source: sourcePath + "/.agents/plugins/acme/skills/deploy",
SourcePath: sourcePath,
Status: agentcontext.StatusOK,
Name: "deploy",
PluginName: "acme",
}, {
ID: "plugin:" + sourcePath + "/.agents/plugins/acme",
Kind: agentcontext.KindPlugin,
Source: sourcePath + "/.agents/plugins/acme",
SourcePath: sourcePath,
Status: agentcontext.StatusOK,
Name: "acme",
PluginVersion: "1.2.0",
}},
},
}
@@ -125,10 +141,12 @@ func TestDRPCAgentSocketService_Context(t *testing.T) {
snap, err := client.GetContextSnapshot(ctx)
require.NoError(t, err)
require.EqualValues(t, 7, snap.Version)
require.Len(t, snap.Resources, 2)
require.Len(t, snap.Resources, 4)
require.Equal(t, agentcontext.KindInstructionFile.String(), snap.Resources[0].Kind)
require.Equal(t, sourcePath, snap.Resources[0].SourcePath)
require.EqualValues(t, 42, snap.Resources[0].SizeBytes)
require.Equal(t, "acme", snap.Resources[2].PluginName)
require.Equal(t, "1.2.0", snap.Resources[3].PluginVersion)
// Remove the source; removing again reports not found.
require.NoError(t, client.RemoveContextSource(ctx, sourcePath))
+153 -133
View File
@@ -1213,16 +1213,18 @@ type ContextResource struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"`
Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
SourcePath string `protobuf:"bytes,4,opt,name=source_path,json=sourcePath,proto3" json:"source_path,omitempty"`
ContentHash string `protobuf:"bytes,5,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"`
SizeBytes uint64 `protobuf:"varint,6,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"`
Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"`
Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"`
Name string `protobuf:"bytes,9,opt,name=name,proto3" json:"name,omitempty"`
Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"`
Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
SourcePath string `protobuf:"bytes,4,opt,name=source_path,json=sourcePath,proto3" json:"source_path,omitempty"`
ContentHash string `protobuf:"bytes,5,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"`
SizeBytes uint64 `protobuf:"varint,6,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"`
Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"`
Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"`
Name string `protobuf:"bytes,9,opt,name=name,proto3" json:"name,omitempty"`
Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"`
PluginName string `protobuf:"bytes,11,opt,name=plugin_name,json=pluginName,proto3" json:"plugin_name,omitempty"`
PluginVersion string `protobuf:"bytes,12,opt,name=plugin_version,json=pluginVersion,proto3" json:"plugin_version,omitempty"`
}
func (x *ContextResource) Reset() {
@@ -1327,6 +1329,20 @@ func (x *ContextResource) GetDescription() string {
return ""
}
func (x *ContextResource) GetPluginName() string {
if x != nil {
return x.PluginName
}
return ""
}
func (x *ContextResource) GetPluginVersion() string {
if x != nil {
return x.PluginVersion
}
return ""
}
// ContextSnapshot is the agent's resolved context state.
type ContextSnapshot struct {
state protoimpl.MessageState
@@ -1672,7 +1688,7 @@ var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{
0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x1d, 0x0a, 0x1b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43,
0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x22, 0x94, 0x02, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74,
0x6f, 0x6e, 0x73, 0x65, 0x22, 0xdc, 0x02, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06,
@@ -1689,131 +1705,135 @@ var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{
0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x09, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73,
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b,
0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xe3, 0x01, 0x0a, 0x0f,
0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12,
0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04,
0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x67,
0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0d, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x48, 0x61, 0x73, 0x68,
0x12, 0x43, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65,
0x78, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x70, 0x61,
0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x6e,
0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0d, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x45, 0x72, 0x72, 0x6f,
0x72, 0x22, 0x18, 0x0a, 0x16, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70,
0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5c, 0x0a, 0x17, 0x43,
0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68,
0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52,
0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x73,
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x22, 0x5a, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65,
0x78, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x73, 0x6e,
0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73,
0x68, 0x6f, 0x74, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x32, 0xa6, 0x0b,
0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a,
0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e,
0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09,
0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61,
0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79,
0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79,
0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d,
0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e,
0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70,
0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09,
0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61,
0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79,
0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61,
0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53,
0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6b, 0x0a, 0x0e, 0x43, 0x6f,
0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x2b, 0x2e, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f,
0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2d, 0x2e, 0x63, 0x6f,
0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x70,
0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0a, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x25, 0x0a, 0x0e,
0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x56, 0x65, 0x72, 0x73,
0x69, 0x6f, 0x6e, 0x22, 0xe3, 0x01, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53,
0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69,
0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x5f, 0x68,
0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x67, 0x67, 0x72, 0x65,
0x67, 0x61, 0x74, 0x65, 0x48, 0x61, 0x73, 0x68, 0x12, 0x43, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x10, 0x41, 0x64,
0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2d,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74,
0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e,
0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x23, 0x0a,
0x0d, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04,
0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x79, 0x74,
0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x65,
0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x6e, 0x61, 0x70,
0x73, 0x68, 0x6f, 0x74, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x18, 0x0a, 0x16, 0x43, 0x6f, 0x6e,
0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x22, 0x5c, 0x0a, 0x17, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e,
0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41,
0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53,
0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f,
0x74, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65,
0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5a, 0x0a, 0x15, 0x52, 0x65, 0x73,
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74,
0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x08, 0x73, 0x6e, 0x61,
0x70, 0x73, 0x68, 0x6f, 0x74, 0x32, 0xa6, 0x0b, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a,
0x13, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x12, 0x30, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f,
0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65,
0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x12, 0x47, 0x65, 0x74,
0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12,
0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e,
0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70,
0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0d,
0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x2a, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65,
0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72,
0x74, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61,
0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62,
0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e,
0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a,
0x0c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74,
0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64,
0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61,
0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x12,
0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79,
0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62,
0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75,
0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x6b, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74,
0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74,
0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x71, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f,
0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e,
0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x71, 0x0a, 0x10, 0x41, 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74,
0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64,
0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64,
0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x13, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43,
0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x30, 0x2e, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78,
0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74,
0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x71, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53,
0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43,
0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e,
0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f,
0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73,
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43,
0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33,
0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -121,6 +121,8 @@ message ContextResource {
string error = 8;
string name = 9;
string description = 10;
string plugin_name = 11;
string plugin_version = 12;
}
// ContextSnapshot is the agent's resolved context state.
+12 -10
View File
@@ -317,16 +317,18 @@ func contextSnapshotToProto(s agentcontext.Snapshot) *proto.ContextSnapshot {
}
for _, r := range s.Resources {
out.Resources = append(out.Resources, &proto.ContextResource{
Id: r.ID,
Kind: r.Kind.String(),
Source: r.Source,
SourcePath: r.SourcePath,
ContentHash: hex.EncodeToString(r.ContentHash[:]),
SizeBytes: r.SizeBytes,
Status: r.Status.String(),
Error: r.Error,
Name: r.Name,
Description: r.Description,
Id: r.ID,
Kind: r.Kind.String(),
Source: r.Source,
SourcePath: r.SourcePath,
ContentHash: hex.EncodeToString(r.ContentHash[:]),
SizeBytes: r.SizeBytes,
Status: r.Status.String(),
Error: r.Error,
Name: r.Name,
Description: r.Description,
PluginName: r.PluginName,
PluginVersion: r.PluginVersion,
})
}
return out
+25 -4
View File
@@ -274,11 +274,17 @@ func (c *Client) GetSubAgentApps(id uuid.UUID) ([]*agentproto.CreateSubAgentRequ
}
// ContextStatePushes returns every PushContextState request the
// agent has issued to the fake server so far.
// fake server has accepted so far.
func (c *Client) ContextStatePushes() []*agentproto.PushContextStateRequest {
return c.fakeAgentAPI.ContextStatePushes()
}
// SetPushContextStateFunc sets a check run on every PushContextState
// request. A non-nil error rejects the request.
func (c *Client) SetPushContextStateFunc(fn func(*agentproto.PushContextStateRequest) error) {
c.fakeAgentAPI.SetPushContextStateFunc(fn)
}
type FakeAgentAPI struct {
sync.Mutex
t testing.TB
@@ -307,7 +313,8 @@ type FakeAgentAPI struct {
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error)
pushResourcesMonitoringUsageFunc func(*agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error)
contextStatePushes []*agentproto.PushContextStateRequest
pushContextStateFunc func(*agentproto.PushContextStateRequest) error
contextStatePushes []*agentproto.PushContextStateRequest
}
func (*FakeAgentAPI) UpdateAppStatus(context.Context, *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
@@ -315,17 +322,23 @@ func (*FakeAgentAPI) UpdateAppStatus(context.Context, *agentproto.UpdateAppStatu
}
// PushContextState records the incoming snapshot and returns
// Accepted=true. Tests that need to assert against the captured
// Accepted=true, unless the function set by SetPushContextStateFunc
// rejects it. Tests that need to assert against the captured
// pushes can read them via ContextStatePushes.
func (f *FakeAgentAPI) PushContextState(_ context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) {
f.Lock()
defer f.Unlock()
if f.pushContextStateFunc != nil {
if err := f.pushContextStateFunc(req); err != nil {
return nil, err
}
}
f.contextStatePushes = append(f.contextStatePushes, req)
return &agentproto.PushContextStateResponse{Accepted: true}, nil
}
// ContextStatePushes returns a snapshot of every
// PushContextState request received so far.
// PushContextState request accepted so far.
func (f *FakeAgentAPI) ContextStatePushes() []*agentproto.PushContextStateRequest {
f.Lock()
defer f.Unlock()
@@ -334,6 +347,14 @@ func (f *FakeAgentAPI) ContextStatePushes() []*agentproto.PushContextStateReques
return out
}
// SetPushContextStateFunc sets a check run on every PushContextState
// request. A non-nil error rejects the request.
func (f *FakeAgentAPI) SetPushContextStateFunc(fn func(*agentproto.PushContextStateRequest) error) {
f.Lock()
defer f.Unlock()
f.pushContextStateFunc = fn
}
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
return f.manifest, nil
}
+1 -1
View File
@@ -144,7 +144,7 @@ func (*RootCmd) chatContextShowCommand(socketPath *string) *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.TableFormat(
[]agentsocket.ContextResource{},
[]string{"kind", "name", "source", "status", "size bytes", "error"},
[]string{"kind", "name", "plugin", "source", "status", "size bytes", "error"},
),
cliui.JSONFormat(),
)
+8 -10
View File
@@ -43,8 +43,6 @@ const (
maxContextResourcesPerPush = 1000
maxContextResourceBodyBytes = 256 * 1024
maxContextAggregateBodyBytes = 4 * 1024 * 1024
maxContextSourceBytes = 1024
maxContextErrorBytes = 4096
maxContextHashBytes = 64
)
@@ -269,8 +267,8 @@ func validateContextPushRequest(req *agentproto.PushContextStateRequest) error {
if len(req.AggregateHash) > maxContextHashBytes {
return xerrors.Errorf("agentapi: PushContextState aggregate hash is %d bytes, exceeds %d byte cap", len(req.AggregateHash), maxContextHashBytes)
}
if len(req.SnapshotError) > maxContextErrorBytes {
return xerrors.Errorf("agentapi: PushContextState snapshot error is %d bytes, exceeds %d byte cap", len(req.SnapshotError), maxContextErrorBytes)
if len(req.SnapshotError) > workspacesdk.MaxContextErrorBytes {
return xerrors.Errorf("agentapi: PushContextState snapshot error is %d bytes, exceeds %d byte cap", len(req.SnapshotError), workspacesdk.MaxContextErrorBytes)
}
if len(req.Resources) > maxContextResourcesPerPush {
return xerrors.Errorf("agentapi: PushContextState has %d resources, exceeds %d resource cap", len(req.Resources), maxContextResourcesPerPush)
@@ -314,19 +312,19 @@ func validateAndConvertContextResources(resources []*agentproto.ContextResource,
if r.Source == "" {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d has empty source", i)
}
if len(r.Source) > maxContextSourceBytes {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d has %d byte source, exceeds %d byte cap", i, len(r.Source), maxContextSourceBytes)
if len(r.Source) > workspacesdk.MaxContextSourceBytes {
return nil, xerrors.Errorf("agentapi: PushContextState resource at index %d has %d byte source, exceeds %d byte cap", i, len(r.Source), workspacesdk.MaxContextSourceBytes)
}
if _, ok := seen[r.Source]; ok {
return nil, xerrors.Errorf("agentapi: PushContextState duplicate source %q", r.Source)
}
seen[r.Source] = struct{}{}
if len(r.GetSourcePath()) > maxContextSourceBytes {
return nil, xerrors.Errorf("resource %q: source path is %d bytes, exceeds %d byte cap", r.Source, len(r.GetSourcePath()), maxContextSourceBytes)
if len(r.GetSourcePath()) > workspacesdk.MaxContextSourceBytes {
return nil, xerrors.Errorf("resource %q: source path is %d bytes, exceeds %d byte cap", r.Source, len(r.GetSourcePath()), workspacesdk.MaxContextSourceBytes)
}
if len(r.Error) > maxContextErrorBytes {
return nil, xerrors.Errorf("resource %q: error is %d bytes, exceeds %d byte cap", r.Source, len(r.Error), maxContextErrorBytes)
if len(r.Error) > workspacesdk.MaxContextErrorBytes {
return nil, xerrors.Errorf("resource %q: error is %d bytes, exceeds %d byte cap", r.Source, len(r.Error), workspacesdk.MaxContextErrorBytes)
}
if len(r.ContentHash) > maxContextHashBytes {
return nil, xerrors.Errorf("resource %q: content hash is %d bytes, exceeds %d byte cap", r.Source, len(r.ContentHash), maxContextHashBytes)
+7
View File
@@ -55,6 +55,13 @@ const (
CoordinateAPIInvalidResumeToken = "Invalid resume token"
)
// PushContextState caps, in bytes. A longer resource source or source
// path, or a longer snapshot or resource error, fails the whole push.
const (
MaxContextSourceBytes = 1024
MaxContextErrorBytes = 4096
)
// AgentIgnoredListeningPorts contains a list of ports to ignore when looking for
// running applications inside a workspace. We want to ignore non-HTTP servers,
// so we pre-populate this list with common ports that are not HTTP servers.
-1
View File
@@ -37,7 +37,6 @@ The agent scans the following:
- The workspace working directory
- `~/.coder` and `~/.coder/skills`
- `~/.claude/plugins/cache`
- Any additional source you declare through the agent's context sources API
Declared sources contribute instruction files and skills only.
+4 -4
View File
@@ -30,13 +30,13 @@ func NewFakeSink(t testing.TB) *FakeSink {
}
// LogEntry implements slog.Sink. It appends the entry to the
// internal slice.
// internal slice. The lock is held across t.Log so the cleanup that
// marks the test done cannot run between the check and the call.
func (s *FakeSink) LogEntry(_ context.Context, e slog.SinkEntry) {
s.mu.Lock()
defer s.mu.Unlock()
s.entries = append(s.entries, e)
shouldLog := !s.tDone
s.mu.Unlock()
if shouldLog {
if !s.tDone {
s.t.Log(e.Message, e.Fields)
}
}