mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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.
575 lines
19 KiB
Go
575 lines
19 KiB
Go
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
|
|
}
|