mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
## What Live MCP servers and their tools now flow into the `agentcontext` snapshot and are pushed to coderd via `PushContextState`, stored alongside instruction files and skills. Previously the resolver's MCP seam was unimplemented, so live MCP tool lists never reached the pushed snapshot. `agentcontext` is now **fully self-contained** for MCP: it connects to the MCP servers declared in the `.mcp.json` files its own watcher already discovers, lists their tools, and emits `KindMCPServer` resources. It does **not** depend on or modify `agent/x/agentmcp` — that package is left pristine and keeps serving the agent's MCP HTTP API. The two MCP paths run independently, which means the legacy package can be deleted later without touching this code. ## How - **Self-contained runner** (`agentcontext/mcprunner.go`): a one-shot MCP client (connect → initialize → list tools → close) with its own `.mcp.json` parser. A Manager goroutine (`runMCPSync`) reloads it whenever the discovered `KindMCPConfig` `path:contenthash` set changes, then re-resolves so the new tools are published. Per-server connects run in parallel (bounded) with a per-server timeout; a server that fails to connect is recorded as a failure rather than aborting the batch. Each connect also force-kills its subprocess on close, because mcp-go's stdio `Close()` closes stdin and then blocks on `cmd.Wait()` with no kill — a server that ignores stdin-close would otherwise stall the whole reload loop. - **Resource production** (`agentcontext/mcp.go`): `buildMCPServerResources` turns the runner's non-blocking per-server snapshot into `KindMCPServer` resources. Connected servers carry their sorted tools (`StatusOK`); failed servers surface as `StatusUnreadable` issues instead of vanishing; connected-but-no-tools-yet are skipped until a later reload. The content hash is tool-set sensitive. The resolver consumes this through a plain `MCPResources func() []Resource` field (no `MCPProvider` interface). - **Tool names**: emitted exactly as the server reports them. Flattening into a single namespace (e.g. `server__tool`) is left to the control plane in the next step, since each resource already carries the server name. - **Drift**: MCP resources are excluded from the snapshot aggregate/drift hash (`driftResources`). MCP servers connect asynchronously after boot; without this, a server finishing its connect would dirty every hydrated chat even though nothing the user pinned changed. - **Wiring** (`agent.go`): the manager is given `ManagerOptions.MCPExecer`/`MCPUpdateEnv`; `agent/x/agentmcp` is untouched. - **Config validation**: a structurally broken `.mcp.json` surfaces as `StatusInvalid` rather than silently dropping all its servers. coderd already persists `mcp_server`/`mcp_config` resource bodies (including tools), so no coderd or proto changes were required. ## Testing - **Unit**: `buildMCPServerResources` (grouping/sort/skip/failed/hash sensitivity), MCP resources applied via the resolver seam, MCP exclusion from the aggregate hash, `.mcp.json` parsing (transport inference, env expansion), `toolInputSchema`, and `mcpConfigSet` change detection. - **Proto serialization** (`TestDRPCPusher_HappyPathSerializesAllFields`): a `KindMCPServer` resource (tools + input schema) round-trips through `PushContextState` into the `MCPServerBody` wire form, asserting the server name, tool name/description, and the decoded `input_schema`. - **Manager-level, real subprocess** (`TestManager_MCPServerToolsInSnapshot`): a `.mcp.json` points at a re-exec'd fake stdio MCP server; the runner connects it and its `echo` tool surfaces as a `KindMCPServer` resource in the Manager snapshot — the same snapshot pushed to coderd — exercising `runMCPSync` and the resolver wiring end to end. - **Regression** (`TestManager_MCPServerHangingCloseDoesNotStall`): the fake server ignores stdin-close; the test asserts its tool still surfaces, proving the runner force-kills the subprocess instead of stalling the reload. Verified to fail without the fix. - All pass under `-race`; `go build ./...`, `go vet`, and `golangci-lint` are clean on the touched packages. ## Scope / follow-ups This is the agent-side production+push half. The chatd consumer (reading the pinned MCP resources for prompt/tool injection, including any server-prefix flattening of tool names) and removing the legacy `workspaceMCPToolsCache` pull path remain follow-ups, per the RFC rollout. While both `agent/x/agentmcp` and `agentcontext` exist, stdio MCP servers are spawned by both; this is intentional and temporary until `agentmcp` is removed. <details> <summary>Implementation plan and decisions</summary> **Goal:** produce live MCP server resources (with tools) from `agentcontext` and push them to coderd. **Starting state (main):** proto (`PushContextState`, `MCPServerBody`, `MCPTool`), the drpc adapter, coderd storage (`workspace_agent_context_resources`, body kind `mcp_server`), and the resolver's MCP seam already existed; nothing implemented the seam or fed live tools into the snapshot. **Decision (agentcontext fully separate from agentmcp):** `agentcontext` starts and lists its own MCP servers using only the connect-and-list half of an mcp-go client, driven by the `.mcp.json` files its existing watcher discovers. It shares no state with `agent/x/agentmcp` and does not import it. Two earlier revisions of this branch were discarded: (1) relocating `agentmcp` into `agentcontext` (rejected — it duplicates config parsing and file watching `agentcontext` already does); (2) reading `agentmcp`'s cached server snapshot via new accessors (rejected — unnecessary coupling between two packages that should simply run independently while one is being retired). The temporary double-spawn of stdio servers is the accepted cost of keeping the two paths cleanly separated until `agentmcp` is removed. **Decision (no tool-name prefixing, no MCPProvider interface):** the agent pushes raw, unflattened data — server name plus verbatim tool names — and lets the control plane own any `server__tool` flattening. With a single self-contained producer, the `MCPProvider` interface was collapsed into a `func() []Resource` field on the resolver. **Invariants held:** no secrets (env/headers) in pushed resources, only server/tool metadata; MCP excluded from the drift hash; the seam is non-blocking so the resolver never stalls on MCP I/O. </details> --- *This PR was created by Coder Agents on behalf of @kylecarbs.*
1070 lines
36 KiB
Go
1070 lines
36 KiB
Go
package agentcontext
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
// Default caps. Copied from the RFC. The Manager exposes
|
|
// overrides via Options.
|
|
const (
|
|
// DefaultMaxResourceBytes is the per-resource payload cap.
|
|
// Resources whose payload exceeds this size are emitted
|
|
// with Status == StatusOversize and an empty Payload.
|
|
DefaultMaxResourceBytes = 64 * 1024
|
|
// DefaultMaxSnapshotBytes is the aggregate payload cap.
|
|
// Resources past this cap are emitted with Status ==
|
|
// StatusExcluded.
|
|
DefaultMaxSnapshotBytes = 2 * 1024 * 1024
|
|
// DefaultMaxResources is the resource count cap. Resources
|
|
// past this cap are emitted with Status == StatusExcluded.
|
|
DefaultMaxResources = 500
|
|
// DefaultMaxScanDepth bounds how deep the recursive walk
|
|
// descends from each scan root. The default avoids runaway
|
|
// scans in node_modules / vendor / .git trees while still
|
|
// covering realistic monorepo layouts.
|
|
DefaultMaxScanDepth = 8
|
|
)
|
|
|
|
// File-name conventions recognized by the v1 resolver.
|
|
var (
|
|
// instructionFileNames are picked up from any scan root.
|
|
// Matching is case-insensitive on the basename.
|
|
instructionFileNames = []string{
|
|
"AGENTS.md",
|
|
"CLAUDE.md",
|
|
".cursorrules",
|
|
}
|
|
// mcpConfigFileName is recognized at any depth under a
|
|
// scan root.
|
|
mcpConfigFileName = ".mcp.json"
|
|
// skillMetaFileName is the file inside a skill directory
|
|
// that carries the skill front-matter.
|
|
skillMetaFileName = "SKILL.md"
|
|
)
|
|
|
|
// skipDirNames are directory basenames that the recursive walk
|
|
// never descends into. The list mirrors what most language
|
|
// tool-chains treat as opaque.
|
|
var skipDirNames = map[string]struct{}{
|
|
".git": {},
|
|
".hg": {},
|
|
".svn": {},
|
|
"node_modules": {},
|
|
"vendor": {},
|
|
"target": {},
|
|
"dist": {},
|
|
"build": {},
|
|
".venv": {},
|
|
"__pycache__": {},
|
|
}
|
|
|
|
// recognizedInstructionFile reports whether name is one of the
|
|
// instruction-file conventions, case-insensitively.
|
|
func recognizedInstructionFile(name string) bool {
|
|
for _, candidate := range instructionFileNames {
|
|
if strings.EqualFold(name, candidate) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Resolver walks one or more scan roots and produces a snapshot
|
|
// of every recognized resource it finds. The Resolver is
|
|
// stateless; the Manager owns the scan-root list and orchestrates
|
|
// successive resolves.
|
|
type Resolver struct {
|
|
// MaxResourceBytes caps the per-resource payload size. Use
|
|
// DefaultMaxResourceBytes if zero.
|
|
MaxResourceBytes uint64
|
|
// MaxSnapshotBytes caps the aggregate payload size. Use
|
|
// DefaultMaxSnapshotBytes if zero.
|
|
MaxSnapshotBytes uint64
|
|
// MaxResources caps the resource count. Use
|
|
// DefaultMaxResources if zero.
|
|
MaxResources int
|
|
// MaxDepth caps the directory walk depth. Use
|
|
// DefaultMaxScanDepth if zero.
|
|
MaxDepth int
|
|
// MCPResources, when non-nil, is consulted after the
|
|
// filesystem pass and returns the KindMCPServer resources
|
|
// for live MCP servers. It must not block: the resolver
|
|
// calls it on every re-resolve. In production the manager
|
|
// wires this to its MCP runner's snapshot; tests inject a
|
|
// closure directly.
|
|
MCPResources func() []Resource
|
|
}
|
|
|
|
// 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 string
|
|
// UserSource is the canonical source path the user
|
|
// declared, when this root came from a user-added Source.
|
|
// Empty for built-in roots.
|
|
UserSource string
|
|
}
|
|
|
|
// Resolve walks the supplied scan roots and returns a Snapshot.
|
|
// The version and schemaVersion fields are stamped by the
|
|
// caller; Resolve fills everything else. Resolve is the
|
|
// non-cancellable convenience wrapper around ResolveContext
|
|
// using context.Background.
|
|
func (r *Resolver) Resolve(roots []ScanRoot) Snapshot {
|
|
return r.ResolveContext(context.Background(), roots)
|
|
}
|
|
|
|
// ResolveContext is the cancellable variant of Resolve. The
|
|
// context is checked between scan roots so callers can bail out
|
|
// of a long pass without waiting for the current root's walk to
|
|
// finish. Cancellation never partially populates the returned
|
|
// Snapshot: a canceled context returns an empty Snapshot with
|
|
// SnapshotError set to the context error.
|
|
func (r *Resolver) ResolveContext(ctx context.Context, roots []ScanRoot) Snapshot {
|
|
res := r.normalize()
|
|
resources, snapErrs := res.walk(ctx, roots)
|
|
if err := ctx.Err(); err != nil {
|
|
return Snapshot{SnapshotError: err.Error()}
|
|
}
|
|
resources, totalBytes := res.applyCaps(resources)
|
|
|
|
// Append MCP server resources after the filesystem caps
|
|
// are applied so a runaway MCP server cannot crowd out
|
|
// instruction files.
|
|
if r.MCPResources != nil {
|
|
mcp := r.MCPResources()
|
|
startIdx := len(resources)
|
|
resources = append(resources, mcp...)
|
|
// MCP resources may push the aggregate over the
|
|
// count or byte cap. Apply both, picking up
|
|
// where applyCaps left off.
|
|
resources, snapErrs = res.applyMCPCaps(resources, startIdx, totalBytes, snapErrs)
|
|
}
|
|
|
|
// Deterministic order by ID for stable IDs and hashes.
|
|
slices.SortFunc(resources, func(a, b Resource) int {
|
|
return strings.Compare(a.ID, b.ID)
|
|
})
|
|
|
|
var payloadBytes uint64
|
|
for _, r := range resources {
|
|
payloadBytes += uint64(len(r.Payload))
|
|
}
|
|
|
|
// The drift hash covers only pinned prompt content; MCP resources are
|
|
// excluded (see driftResources). Snapshot.Resources still carries the
|
|
// full set so MCP servers stay visible in the chat-context snapshot.
|
|
hash := ComputeAggregateHash(driftResources(resources))
|
|
|
|
snap := Snapshot{
|
|
Resources: resources,
|
|
AggregateHash: hash,
|
|
PayloadBytes: payloadBytes,
|
|
}
|
|
if len(snapErrs) > 0 {
|
|
// Pick the most severe single error. Today every
|
|
// snapshot-level problem is "warning equivalent" so
|
|
// the first one wins; the design reserves the field
|
|
// for a singular message.
|
|
snap.SnapshotError = snapErrs[0]
|
|
}
|
|
return snap
|
|
}
|
|
|
|
func (r *Resolver) normalize() *Resolver {
|
|
out := *r
|
|
if out.MaxResourceBytes == 0 {
|
|
out.MaxResourceBytes = DefaultMaxResourceBytes
|
|
}
|
|
if out.MaxSnapshotBytes == 0 {
|
|
out.MaxSnapshotBytes = DefaultMaxSnapshotBytes
|
|
}
|
|
if out.MaxResources == 0 {
|
|
out.MaxResources = DefaultMaxResources
|
|
}
|
|
if out.MaxDepth == 0 {
|
|
out.MaxDepth = DefaultMaxScanDepth
|
|
}
|
|
return &out
|
|
}
|
|
|
|
// walk traverses every scan root and produces an unordered
|
|
// resource list. Aggregate caps are applied separately. The ctx
|
|
// is checked between roots so callers can bail out promptly.
|
|
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
|
|
// root attribute resources to the built-in.
|
|
seenRoot := make(map[string]struct{}, len(roots))
|
|
dedup := make([]ScanRoot, 0, len(roots))
|
|
for _, root := range roots {
|
|
if root.Path == "" {
|
|
continue
|
|
}
|
|
if _, ok := seenRoot[root.Path]; ok {
|
|
continue
|
|
}
|
|
seenRoot[root.Path] = struct{}{}
|
|
dedup = append(dedup, root)
|
|
}
|
|
|
|
// Deduplicate resources across roots by ID. Without this,
|
|
// a built-in root and a user root that both cover the
|
|
// same project tree would double-count AGENTS.md.
|
|
seenID := make(map[string]struct{})
|
|
|
|
for _, root := range dedup {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, []string{err.Error()}
|
|
}
|
|
info, err := os.Stat(root.Path)
|
|
if err != nil {
|
|
// Missing roots silently fall through. The user
|
|
// either added a path that does not exist yet or
|
|
// removed it later. The watcher will surface
|
|
// re-creation as a change event.
|
|
continue
|
|
}
|
|
if !info.IsDir() {
|
|
// Single-file roots are classified directly.
|
|
if res, ok := r.classifyFile(root.Path, root.Path, info, root.UserSource); ok {
|
|
if _, dup := seenID[res.ID]; !dup {
|
|
seenID[res.ID] = struct{}{}
|
|
resources = append(resources, res)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
walkErr := r.walkDir(ctx, root, &resources, seenID)
|
|
if walkErr != nil {
|
|
snapErrs = append(snapErrs, fmt.Sprintf("walk %q: %s", root.Path, walkErr))
|
|
}
|
|
}
|
|
return resources, snapErrs
|
|
}
|
|
|
|
// walkDir performs the recursive descent for a single scan
|
|
// directory. It honors r.MaxDepth and skipDirNames. The ctx is
|
|
// checked inside the WalkDir callback so cancellation
|
|
// terminates the walk even mid-root.
|
|
func (r *Resolver) walkDir(ctx context.Context, root ScanRoot, out *[]Resource, seenID map[string]struct{}) error {
|
|
rootDepth := strings.Count(filepath.Clean(root.Path), string(os.PathSeparator))
|
|
maxDepth := rootDepth + r.MaxDepth
|
|
|
|
return filepath.WalkDir(root.Path, func(path string, d fs.DirEntry, err error) error {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
if err != nil {
|
|
// Surface the error as Unreadable when we can
|
|
// associate it with a single recognized file;
|
|
// otherwise let the walk continue.
|
|
if d != nil && !d.IsDir() {
|
|
kind, recognized := kindFromFilename(d.Name())
|
|
if recognized {
|
|
res := Resource{
|
|
ID: resourceID(kind, path),
|
|
Kind: kind,
|
|
Source: path,
|
|
SizeBytes: 0,
|
|
Status: StatusUnreadable,
|
|
Error: err.Error(),
|
|
SourcePath: root.UserSource,
|
|
}
|
|
if _, dup := seenID[res.ID]; !dup {
|
|
seenID[res.ID] = struct{}{}
|
|
*out = append(*out, res)
|
|
}
|
|
}
|
|
}
|
|
if errors.Is(err, fs.ErrPermission) {
|
|
// Permission errors on a directory: skip the
|
|
// subtree but continue walking siblings.
|
|
if d != nil && d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if d.IsDir() {
|
|
if strings.Count(path, string(os.PathSeparator)) > maxDepth {
|
|
return fs.SkipDir
|
|
}
|
|
if _, skip := skipDirNames[d.Name()]; skip && path != root.Path {
|
|
return fs.SkipDir
|
|
}
|
|
// If we are entering a "skills container"
|
|
// directory (".agents/skills", "~/.coder/skills",
|
|
// "plugins/<plugin>/skills"), eagerly emit skill
|
|
// resources for its immediate subdirectories.
|
|
if isSkillsContainer(path) {
|
|
r.emitSkillsFromContainer(path, root, out, seenID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Regular file.
|
|
info, statErr := d.Info()
|
|
if statErr != nil {
|
|
return nil
|
|
}
|
|
if res, ok := r.classifyFile(root.Path, path, info, root.UserSource); ok {
|
|
if _, dup := seenID[res.ID]; dup {
|
|
return nil
|
|
}
|
|
seenID[res.ID] = struct{}{}
|
|
*out = append(*out, res)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// kindFromFilename maps a file basename to its ResourceKind.
|
|
// recognized=false when the name matches no convention.
|
|
func kindFromFilename(name string) (kind ResourceKind, recognized bool) {
|
|
switch {
|
|
case recognizedInstructionFile(name):
|
|
return KindInstructionFile, true
|
|
case name == mcpConfigFileName:
|
|
return KindMCPConfig, true
|
|
case name == skillMetaFileName:
|
|
return KindSkill, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// resolveReadTarget produces the path and FileInfo that should
|
|
// be used to read the resource. When the input is not a
|
|
// symlink the original path and info are returned unchanged.
|
|
// When it is a symlink the target is resolved and validated
|
|
// against scanRoot so a malicious AGENTS.md ->
|
|
// ~/.ssh/id_rsa cannot exfiltrate files outside the
|
|
// contributing scan root.
|
|
//
|
|
// codex follows symlinks unconditionally because it trusts the
|
|
// local user's filesystem. Coder workspaces may execute
|
|
// templates and repositories that the agent operator did not
|
|
// author, so the resolver follows symlinks only within the
|
|
// scan-root boundary. Symlinks whose targets escape the
|
|
// boundary are emitted as StatusInvalid; broken symlinks and
|
|
// non-regular targets are emitted as StatusUnreadable.
|
|
func resolveReadTarget(path string, info fs.FileInfo, scanRoot string) (readPath string, readInfo fs.FileInfo, ok bool, status ResourceStatus, errMsg string) {
|
|
if info.Mode()&fs.ModeSymlink == 0 {
|
|
return path, info, true, StatusOK, ""
|
|
}
|
|
target, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return "", nil, false, StatusUnreadable, fmt.Sprintf("symlink resolve: %v", err)
|
|
}
|
|
// Canonicalize scanRoot symmetrically with the target so the
|
|
// boundary check survives platform-level symlinks in the scan
|
|
// root prefix. macOS, for example, exposes /var as a symlink
|
|
// to /private/var; EvalSymlinks on the target produces a
|
|
// /private/var path while the caller's scanRoot may still be
|
|
// /var, which would incorrectly trip the prefix check.
|
|
rootClean := filepath.Clean(scanRoot)
|
|
if resolved, err := filepath.EvalSymlinks(rootClean); err == nil {
|
|
rootClean = resolved
|
|
}
|
|
if !pathHasPrefix(target, rootClean) {
|
|
return "", nil, false, StatusInvalid, fmt.Sprintf("symlink target %q escapes scan root %q", target, scanRoot)
|
|
}
|
|
tgtInfo, err := os.Stat(target)
|
|
if err != nil {
|
|
return "", nil, false, StatusUnreadable, err.Error()
|
|
}
|
|
if !tgtInfo.Mode().IsRegular() {
|
|
return "", nil, false, StatusInvalid, fmt.Sprintf("symlink target %q is not a regular file", target)
|
|
}
|
|
return target, tgtInfo, true, StatusOK, ""
|
|
}
|
|
|
|
// classifyFile inspects a single file path and produces a
|
|
// Resource when the basename matches a recognized convention.
|
|
func (r *Resolver) classifyFile(scanRoot, path string, info fs.FileInfo, userSource string) (Resource, bool) {
|
|
name := info.Name()
|
|
switch {
|
|
case recognizedInstructionFile(name):
|
|
return r.readInstructionFile(scanRoot, path, info, userSource), true
|
|
case name == mcpConfigFileName:
|
|
return r.readMCPConfig(scanRoot, path, info, userSource), true
|
|
case name == skillMetaFileName:
|
|
// SKILL.md outside a skills container is still a
|
|
// valid skill if its parent directory name matches
|
|
// the front-matter name. emitSkillsFromContainer
|
|
// already handles the common case; here we cover
|
|
// "user adds a single SKILL.md file as a source".
|
|
res, ok := r.readSkillMeta(scanRoot, path, info, userSource)
|
|
return res, ok
|
|
default:
|
|
return Resource{}, false
|
|
}
|
|
}
|
|
|
|
// readInstructionFile reads an instruction file and produces a
|
|
// KindInstructionFile resource. The file is read into memory
|
|
// with the per-resource cap applied.
|
|
//
|
|
// The bytes are returned verbatim. The legacy code path in
|
|
// agentcontextconfig/api.go strips HTML comments and invisible
|
|
// Unicode before serving instruction-file contents to chat; the
|
|
// equivalent sanitization for this pipeline lives in the
|
|
// follow-up chatd integration that consumes Snapshot.Resources.
|
|
// Until that lands, downstream consumers that render these
|
|
// payloads must sanitize themselves.
|
|
func (r *Resolver) readInstructionFile(scanRoot, path string, info fs.FileInfo, userSource string) Resource {
|
|
res := r.readFileResource(KindInstructionFile, scanRoot, path, info, userSource)
|
|
if res.Status == StatusOK {
|
|
res.Description = firstLine(string(res.Payload))
|
|
}
|
|
return res
|
|
}
|
|
|
|
// readMCPConfig reads a .mcp.json file and produces a
|
|
// KindMCPConfig resource carrying only path metadata and a
|
|
// content hash.
|
|
//
|
|
// .mcp.json fragments frequently embed secret-bearing fields
|
|
// (Env tokens, Authorization headers). The resolver hashes the
|
|
// file for change detection but intentionally does not ship
|
|
// the bytes; the live MCP server's tool list arrives
|
|
// separately as a KindMCPServer resource, which is what
|
|
// downstream consumers actually need.
|
|
func (r *Resolver) readMCPConfig(scanRoot, path string, info fs.FileInfo, userSource string) Resource {
|
|
res := Resource{
|
|
ID: resourceID(KindMCPConfig, path),
|
|
Kind: KindMCPConfig,
|
|
Source: path,
|
|
SizeBytes: safeUint64(info.Size()),
|
|
SourcePath: userSource,
|
|
}
|
|
readPath, readInfo, ok, status, errMsg := resolveReadTarget(path, info, scanRoot)
|
|
if !ok {
|
|
res.Status = status
|
|
res.Error = errMsg
|
|
return res
|
|
}
|
|
res.SizeBytes = safeUint64(readInfo.Size())
|
|
if safeUint64(readInfo.Size()) > 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 res
|
|
}
|
|
data, err := os.ReadFile(readPath)
|
|
if err != nil {
|
|
res.Status = StatusUnreadable
|
|
res.Error = err.Error()
|
|
return res
|
|
}
|
|
res.ContentHash = sha256.Sum256(data)
|
|
// A .mcp.json with broken JSON yields no MCP servers at all; the
|
|
// MCP manager logs and skips it, so the failure is otherwise
|
|
// invisible. Flag structural problems here as StatusInvalid so the
|
|
// chat context surfaces them as an issue rather than silently
|
|
// dropping every server in the file.
|
|
if err := validateMCPConfig(data); err != nil {
|
|
res.Status = StatusInvalid
|
|
res.Error = err.Error()
|
|
}
|
|
return res
|
|
}
|
|
|
|
// validateMCPConfig performs lightweight structural validation of a
|
|
// .mcp.json document so syntactically broken files surface as
|
|
// StatusInvalid instead of silently producing no MCP servers. It is
|
|
// deliberately self-contained and does not import the MCP package: it
|
|
// only checks that the document is valid JSON shaped like
|
|
// {"mcpServers": {<name>: {...}}}. Individual server fields
|
|
// (command/url/env/...) are not validated here; the MCP manager owns
|
|
// that when it connects. An absent or empty mcpServers map is valid.
|
|
func validateMCPConfig(data []byte) error {
|
|
var shape struct {
|
|
MCPServers map[string]json.RawMessage `json:"mcpServers"`
|
|
}
|
|
if err := json.Unmarshal(data, &shape); err != nil {
|
|
return err
|
|
}
|
|
// Each server entry must be a JSON object; a scalar or array
|
|
// entry is a structural error the MCP manager would reject.
|
|
// The top-level Unmarshal above already rejects malformed JSON,
|
|
// so a well-formed value starting with '{' is a complete object.
|
|
for name, raw := range shape.MCPServers {
|
|
trimmed := bytes.TrimSpace(raw)
|
|
if len(trimmed) == 0 || trimmed[0] != '{' {
|
|
return xerrors.Errorf("server %q must be a JSON object", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readFileResource is the shared plumbing for kinds whose only
|
|
// difference is the enum stamped on the Resource: build the
|
|
// Resource header, enforce the per-resource size cap, read the
|
|
// file, hash it, attach the bytes. Callers add kind-specific
|
|
// post-processing (e.g. firstLine for instruction files) by
|
|
// inspecting Status==StatusOK.
|
|
func (r *Resolver) readFileResource(kind ResourceKind, scanRoot, path string, info fs.FileInfo, userSource string) Resource {
|
|
res := Resource{
|
|
ID: resourceID(kind, path),
|
|
Kind: kind,
|
|
Source: path,
|
|
SizeBytes: safeUint64(info.Size()),
|
|
SourcePath: userSource,
|
|
}
|
|
readPath, readInfo, ok, status, errMsg := resolveReadTarget(path, info, scanRoot)
|
|
if !ok {
|
|
res.Status = status
|
|
res.Error = errMsg
|
|
return res
|
|
}
|
|
res.SizeBytes = safeUint64(readInfo.Size())
|
|
if safeUint64(readInfo.Size()) > r.MaxResourceBytes {
|
|
res.Status = StatusOversize
|
|
res.Error = fmt.Sprintf("file size %d exceeds per-resource cap of %d bytes", readInfo.Size(), r.MaxResourceBytes)
|
|
// Still hash the (capped) content so a fix is
|
|
// detectable.
|
|
if data, err := readFileCapped(readPath, safeInt64(r.MaxResourceBytes)); err == nil {
|
|
res.ContentHash = sha256.Sum256(data)
|
|
}
|
|
return res
|
|
}
|
|
data, err := os.ReadFile(readPath)
|
|
if err != nil {
|
|
res.Status = StatusUnreadable
|
|
res.Error = err.Error()
|
|
return res
|
|
}
|
|
res.Payload = data
|
|
res.ContentHash = sha256.Sum256(data)
|
|
return res
|
|
}
|
|
|
|
// readSkillMeta reads a SKILL.md file, parses its front-matter,
|
|
// and emits a KindSkill resource. The name encoded in the
|
|
// front-matter must match the parent directory's basename to
|
|
// be considered valid; otherwise Status is StatusInvalid.
|
|
func (r *Resolver) readSkillMeta(scanRoot, path string, info fs.FileInfo, userSource string) (Resource, bool) {
|
|
parent := filepath.Base(filepath.Dir(path))
|
|
res := Resource{
|
|
ID: resourceID(KindSkill, filepath.Dir(path)),
|
|
Kind: KindSkill,
|
|
Source: filepath.Dir(path),
|
|
SizeBytes: safeUint64(info.Size()),
|
|
SourcePath: userSource,
|
|
}
|
|
readPath, readInfo, ok, status, errMsg := resolveReadTarget(path, info, scanRoot)
|
|
if !ok {
|
|
res.Status = status
|
|
res.Error = errMsg
|
|
return res, true
|
|
}
|
|
res.SizeBytes = safeUint64(readInfo.Size())
|
|
if safeUint64(readInfo.Size()) > r.MaxResourceBytes {
|
|
res.Status = StatusOversize
|
|
res.Error = fmt.Sprintf("file size %d exceeds per-resource cap of %d bytes", readInfo.Size(), r.MaxResourceBytes)
|
|
// Hash the (capped) prefix so an edit that keeps
|
|
// the file oversize still shifts the aggregate
|
|
// hash and triggers a re-broadcast. Mirrors the
|
|
// behavior in readFileResource.
|
|
if data, err := readFileCapped(readPath, safeInt64(r.MaxResourceBytes)); err == nil {
|
|
res.ContentHash = sha256.Sum256(data)
|
|
}
|
|
return res, true
|
|
}
|
|
data, err := os.ReadFile(readPath)
|
|
if err != nil {
|
|
res.Status = StatusUnreadable
|
|
res.Error = err.Error()
|
|
return res, true
|
|
}
|
|
res.ContentHash = sha256.Sum256(data)
|
|
name, description, _, err := workspacesdk.ParseSkillFrontmatter(string(data))
|
|
if err != nil {
|
|
res.Status = StatusInvalid
|
|
res.Error = err.Error()
|
|
return res, true
|
|
}
|
|
if name != parent {
|
|
res.Status = StatusInvalid
|
|
res.Error = fmt.Sprintf("front-matter name %q does not match directory %q", name, parent)
|
|
return res, true
|
|
}
|
|
if !workspacesdk.SkillNamePattern.MatchString(name) {
|
|
res.Status = StatusInvalid
|
|
res.Error = fmt.Sprintf("skill name %q is not kebab-case", name)
|
|
return res, true
|
|
}
|
|
res.Description = description
|
|
res.Name = name
|
|
res.Payload = data
|
|
return res, true
|
|
}
|
|
|
|
// 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]struct{}) {
|
|
entries, err := os.ReadDir(container)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
meta := filepath.Join(container, e.Name(), skillMetaFileName)
|
|
// Lstat (not Stat) so a symlinked SKILL.md is
|
|
// detected and routed through resolveReadTarget,
|
|
// which enforces the scan-root boundary.
|
|
info, err := os.Lstat(meta)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
res, ok := r.readSkillMeta(root.Path, meta, info, root.UserSource)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, dup := seenID[res.ID]; dup {
|
|
continue
|
|
}
|
|
seenID[res.ID] = struct{}{}
|
|
*out = append(*out, res)
|
|
}
|
|
}
|
|
|
|
// applyCaps enforces the resource-count cap and aggregate
|
|
// payload cap. Resources past either cap have their Status set
|
|
// to StatusExcluded and their Payload cleared. The returned
|
|
// byte total is the sum of surviving payloads, so callers that
|
|
// 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.
|
|
slices.SortStableFunc(resources, func(a, b Resource) int {
|
|
if a.Kind != b.Kind {
|
|
return int(a.Kind) - int(b.Kind)
|
|
}
|
|
return strings.Compare(a.Source, b.Source)
|
|
})
|
|
|
|
var total uint64
|
|
for i := range resources {
|
|
if i >= r.MaxResources {
|
|
resources[i] = excluded(resources[i],
|
|
fmt.Sprintf("dropped to fit %d-resource snapshot count cap", r.MaxResources))
|
|
continue
|
|
}
|
|
if resources[i].Status != StatusOK {
|
|
continue
|
|
}
|
|
size := uint64(len(resources[i].Payload))
|
|
if total+size > r.MaxSnapshotBytes {
|
|
resources[i] = excluded(resources[i],
|
|
fmt.Sprintf("dropped to fit %d-byte aggregate cap", r.MaxSnapshotBytes))
|
|
continue
|
|
}
|
|
total += size
|
|
}
|
|
return resources, total
|
|
}
|
|
|
|
// 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.
|
|
// priorBytes is the sum of payload bytes already committed by
|
|
// the filesystem pass; MCP resources whose payloads would push
|
|
// the running total past MaxSnapshotBytes are stamped
|
|
// StatusExcluded. Without this guard a provider returning one
|
|
// large KindMCPServer payload would exceed the aggregate cap
|
|
// with StatusOK, breaking the contract in
|
|
// DefaultMaxSnapshotBytes.
|
|
func (r *Resolver) applyMCPCaps(resources []Resource, startIdx int, priorBytes uint64, snapErrs []string) ([]Resource, []string) {
|
|
total := priorBytes
|
|
countCapHit := false
|
|
byteCapHit := false
|
|
for i := startIdx; i < len(resources); i++ {
|
|
if i >= r.MaxResources {
|
|
resources[i] = excluded(resources[i],
|
|
fmt.Sprintf("dropped to fit %d-resource snapshot count cap", r.MaxResources))
|
|
countCapHit = true
|
|
continue
|
|
}
|
|
if resources[i].Status != StatusOK {
|
|
continue
|
|
}
|
|
size := uint64(len(resources[i].Payload))
|
|
if total+size > r.MaxSnapshotBytes {
|
|
resources[i] = excluded(resources[i],
|
|
fmt.Sprintf("dropped to fit %d-byte aggregate cap", r.MaxSnapshotBytes))
|
|
byteCapHit = true
|
|
continue
|
|
}
|
|
total += size
|
|
}
|
|
if countCapHit {
|
|
snapErrs = append(snapErrs, fmt.Sprintf("snapshot exceeds %d-resource count cap", r.MaxResources))
|
|
}
|
|
if byteCapHit {
|
|
snapErrs = append(snapErrs, fmt.Sprintf("snapshot exceeds %d-byte aggregate cap", r.MaxSnapshotBytes))
|
|
}
|
|
return resources, snapErrs
|
|
}
|
|
|
|
// excluded mutates and returns the supplied resource with the
|
|
// StatusExcluded outcome.
|
|
func excluded(r Resource, reason string) Resource {
|
|
r.Status = StatusExcluded
|
|
r.Error = reason
|
|
r.Payload = nil
|
|
return r
|
|
}
|
|
|
|
// isSkillsContainer reports whether dir is a recognized skills
|
|
// container directory whose immediate children carry SKILL.md
|
|
// files. Both bare "skills" and nested "<parent>/skills"
|
|
// directories qualify (e.g. ".agents/skills",
|
|
// "plugins/foo/skills").
|
|
func isSkillsContainer(dir string) bool {
|
|
return filepath.Base(dir) == "skills"
|
|
}
|
|
|
|
// 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.
|
|
func resourceID(kind ResourceKind, source string) string {
|
|
return kind.String() + ":" + source
|
|
}
|
|
|
|
// readFileCapped reads up to maxBytes from path. It returns the
|
|
// truncated payload on success.
|
|
func readFileCapped(path string, maxBytes int64) ([]byte, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return io.ReadAll(io.LimitReader(f, maxBytes))
|
|
}
|
|
|
|
// firstLine returns the first non-empty trimmed line of s, used
|
|
// as a short description fallback.
|
|
func firstLine(s string) string {
|
|
for line := range strings.SplitSeq(s, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Strip leading markdown heading markers for prettier
|
|
// descriptions.
|
|
return strings.TrimSpace(headingPrefixRegex.ReplaceAllString(line, ""))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var headingPrefixRegex = regexp.MustCompile(`^#+\s*`)
|
|
|
|
// safeUint64 converts a non-negative int64 to uint64. Negative
|
|
// inputs are clamped to 0, which is safe for the size-tracking
|
|
// fields that use it; a negative os.FileInfo size is pathological
|
|
// and never indicates real content.
|
|
func safeUint64(n int64) uint64 {
|
|
if n < 0 {
|
|
return 0
|
|
}
|
|
return uint64(n)
|
|
}
|
|
|
|
// safeInt64 converts a uint64 to int64, clamping to math.MaxInt64
|
|
// when the input would overflow. The caps configured on the
|
|
// resolver never approach 2^63 bytes, so the clamp only guards
|
|
// against pathological caller input.
|
|
func safeInt64(n uint64) int64 {
|
|
if n > math.MaxInt64 {
|
|
return math.MaxInt64
|
|
}
|
|
return int64(n)
|
|
}
|
|
|
|
// 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.
|
|
type ResourceKind int
|
|
|
|
const (
|
|
KindUnspecified ResourceKind = iota
|
|
// KindInstructionFile covers AGENTS.md, CLAUDE.md,
|
|
// .cursorrules, and similar plain-text rule files that
|
|
// inject content into the model prompt.
|
|
KindInstructionFile
|
|
// KindSkill is a directory containing SKILL.md and any
|
|
// supporting files. Only the meta file is read at
|
|
// resolve time; bodies are fetched on demand.
|
|
KindSkill
|
|
// KindMCPConfig is a .mcp.json fragment declaring one or
|
|
// more MCP servers.
|
|
KindMCPConfig
|
|
// KindMCPServer is a live MCP server's resolved tool list,
|
|
// populated from the MCP runner's snapshot after the server
|
|
// has been connected.
|
|
KindMCPServer
|
|
// KindPlugin is reserved for Claude Code plugin manifests.
|
|
// Not emitted by v1.
|
|
KindPlugin
|
|
// KindHook is reserved for plugin hooks. Not emitted by v1.
|
|
KindHook
|
|
// KindSubagent is reserved for plugin-declared subagents.
|
|
// Not emitted by v1.
|
|
KindSubagent
|
|
// KindCommand is reserved for plugin slash commands.
|
|
// Not emitted by v1.
|
|
KindCommand
|
|
)
|
|
|
|
// String returns the lower-snake-case name used in IDs and
|
|
// metrics. Unknown values stringify to "unknown".
|
|
func (k ResourceKind) String() string {
|
|
switch k {
|
|
case KindInstructionFile:
|
|
return "instruction_file"
|
|
case KindSkill:
|
|
return "skill"
|
|
case KindMCPConfig:
|
|
return "mcp_config"
|
|
case KindMCPServer:
|
|
return "mcp_server"
|
|
case KindPlugin:
|
|
return "plugin"
|
|
case KindHook:
|
|
return "hook"
|
|
case KindSubagent:
|
|
return "subagent"
|
|
case KindCommand:
|
|
return "command"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// ResourceStatus describes whether a resource was successfully
|
|
// read and whether its payload survived the per-resource and
|
|
// aggregate caps.
|
|
//
|
|
// Note: these iota ordinals do NOT match the proto
|
|
// ContextResource.Status ordinals one-to-one. The proto enum
|
|
// reserves 0 for STATUS_UNSPECIFIED and shifts every value by
|
|
// one, so the conversion in resourceStatusToProto cannot be
|
|
// replaced with a direct int cast. ResourceKind, by contrast,
|
|
// does align with its proto counterpart.
|
|
type ResourceStatus int
|
|
|
|
const (
|
|
// StatusOK indicates the payload was populated.
|
|
StatusOK ResourceStatus = iota
|
|
// StatusOversize indicates the resource exceeded the
|
|
// per-resource size cap; payload is omitted.
|
|
StatusOversize
|
|
// StatusUnreadable indicates an IO error reading the
|
|
// resource (permission denied, broken symlink, etc.).
|
|
StatusUnreadable
|
|
// StatusInvalid indicates the resource was structurally
|
|
// malformed (bad JSON, missing front-matter, etc.).
|
|
StatusInvalid
|
|
// StatusExcluded indicates the resource was dropped to fit
|
|
// the aggregate snapshot or count cap.
|
|
StatusExcluded
|
|
)
|
|
|
|
// String returns the lower-snake-case name used in IDs and
|
|
// metrics. Unknown values stringify to "unknown".
|
|
func (s ResourceStatus) String() string {
|
|
switch s {
|
|
case StatusOK:
|
|
return "ok"
|
|
case StatusOversize:
|
|
return "oversize"
|
|
case StatusUnreadable:
|
|
return "unreadable"
|
|
case StatusInvalid:
|
|
return "invalid"
|
|
case StatusExcluded:
|
|
return "excluded"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// Resource is what the resolver emits for each recognized file
|
|
// or live server it discovers under a scan root. The struct is
|
|
// intentionally flat; the typed wire mapping happens in
|
|
// drpc.go where Kind selects the proto oneof variant.
|
|
type Resource struct {
|
|
// ID is stable across pushes for the same logical
|
|
// resource. The current scheme is "<kind>:<source>". It is
|
|
// used for in-snapshot dedup and as part of the aggregate
|
|
// hash; it is not transmitted on the wire.
|
|
ID string
|
|
// Kind classifies the resource. Drives which proto oneof
|
|
// variant the DRPC adapter sets.
|
|
Kind ResourceKind
|
|
// Source is the file path or MCP server name.
|
|
Source string
|
|
// ContentHash is sha256 over the resource's original
|
|
// bytes (or transport-encoded server tool list).
|
|
ContentHash [32]byte
|
|
// Payload is the full bytes when Status == StatusOK; the
|
|
// per-resource and aggregate caps may leave it empty.
|
|
// Unused for KindMCPServer (Tools is used instead).
|
|
Payload []byte
|
|
// SizeBytes is the original payload size, populated
|
|
// regardless of Status.
|
|
SizeBytes uint64
|
|
// Status records OK or a reason the payload is absent.
|
|
Status ResourceStatus
|
|
// Error is populated whenever Status != StatusOK; may
|
|
// 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.
|
|
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.
|
|
Description string
|
|
// SourcePath is the user-declared source that contributed
|
|
// the resource; empty for built-in scan roots.
|
|
SourcePath string
|
|
// Tools is populated for KindMCPServer with the live
|
|
// server's tool list; empty otherwise.
|
|
Tools []MCPTool
|
|
}
|
|
|
|
// MCPTool mirrors the wire MCPTool message. InputSchema is the
|
|
// JSON-Schema-shaped object the MCP server reported for the
|
|
// tool's arguments.
|
|
type MCPTool struct {
|
|
Name string
|
|
Description string
|
|
InputSchema map[string]any
|
|
}
|
|
|
|
// Snapshot is the immutable bundle of resources produced by a
|
|
// single resolver pass.
|
|
type Snapshot struct {
|
|
// Version is monotonically increasing per Manager
|
|
// instance; resets when the agent process restarts.
|
|
Version uint64
|
|
// AggregateHash is sha256 over a canonical encoding of
|
|
// (ID, Kind, Source, ContentHash, Status) for every
|
|
// drift-relevant resource. MCP resources (KindMCPConfig and
|
|
// KindMCPServer) are excluded because they describe live,
|
|
// agent-global runtime capabilities discovered at turn time,
|
|
// not pinned prompt content; see driftResources. Identical
|
|
// inputs always produce identical hashes; see
|
|
// ComputeAggregateHash.
|
|
AggregateHash [32]byte
|
|
// Resources is sorted by ID for deterministic encoding.
|
|
Resources []Resource
|
|
// PayloadBytes is the sum of len(Resource.Payload) across
|
|
// emitted resources after caps were applied.
|
|
PayloadBytes uint64
|
|
// SnapshotError carries a single snapshot-level error
|
|
// string when present (count cap exceeded, watcher
|
|
// degraded, ENOSPC, etc.). Empty when healthy.
|
|
SnapshotError string
|
|
}
|
|
|
|
// driftResources returns the subset of resources that participate in
|
|
// chat-context drift detection. MCP resources (the .mcp.json config and
|
|
// connected MCP servers) are deliberately excluded: an agent connects to
|
|
// its MCP servers asynchronously after startup, and the chat model
|
|
// discovers their tools live at turn time, not from pinned prompt
|
|
// content. Hashing them would dirty an already-hydrated chat the moment
|
|
// a server finished connecting, even though nothing the user pinned
|
|
// changed. Instruction files and skills, whose content is pinned into
|
|
// the chat, stay drift-relevant.
|
|
func driftResources(resources []Resource) []Resource {
|
|
out := make([]Resource, 0, len(resources))
|
|
for _, r := range resources {
|
|
switch r.Kind {
|
|
case KindMCPConfig, KindMCPServer:
|
|
continue
|
|
default:
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ComputeAggregateHash produces the deterministic snapshot
|
|
// aggregate hash for the supplied resources. The caller does
|
|
// not need to pre-sort; the function sorts a copy of the slice
|
|
// to keep its inputs side-effect free.
|
|
//
|
|
// The encoding is a Netstring-style stream. Each string field
|
|
// is written as the decimal-ASCII length, the literal ':', and
|
|
// the raw UTF-8 bytes. ContentHash is written as 32 raw bytes
|
|
// without a length prefix because it is a fixed-size SHA-256
|
|
// digest. Resources are separated by a single NUL byte. The
|
|
// scheme is internal to the agent and coderd, but it is stable
|
|
// across platforms because every field has an unambiguous
|
|
// length.
|
|
func ComputeAggregateHash(resources []Resource) [32]byte {
|
|
indexed := make([]Resource, len(resources))
|
|
copy(indexed, resources)
|
|
slices.SortFunc(indexed, func(a, b Resource) int {
|
|
return strings.Compare(a.ID, b.ID)
|
|
})
|
|
|
|
h := sha256.New()
|
|
for _, r := range indexed {
|
|
writeLengthPrefixed(h, r.ID)
|
|
writeLengthPrefixed(h, r.Kind.String())
|
|
writeLengthPrefixed(h, r.Source)
|
|
_, _ = h.Write(r.ContentHash[:])
|
|
writeLengthPrefixed(h, r.Status.String())
|
|
_, _ = h.Write([]byte{0})
|
|
}
|
|
var out [32]byte
|
|
copy(out[:], h.Sum(nil))
|
|
return out
|
|
}
|
|
|
|
// writeLengthPrefixed writes a decimal-ASCII length prefix, a
|
|
// literal ':' separator, and the raw bytes of s. This matches
|
|
// the Netstring framing used by ComputeAggregateHash.
|
|
func writeLengthPrefixed(h interface{ Write([]byte) (int, error) }, s string) {
|
|
_, _ = h.Write([]byte(strconv.Itoa(len(s))))
|
|
_, _ = h.Write([]byte{':'})
|
|
_, _ = h.Write([]byte(s))
|
|
}
|