mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
These symlink resolver tests build expected paths from `t.TempDir()`, but the resolver canonicalizes symlink targets with `filepath.EvalSymlinks`. On macOS, `/var` resolves to `/private/var`, so the raw temp dir path can disagree with the resolver output and flake. Use `testutil.TempDirResolved` in the symlink-sensitive tests so the expectations are canonicalized the same way. Closes https://github.com/coder/internal/issues/1574 Closes ENG-2849
669 lines
23 KiB
Go
669 lines
23 KiB
Go
package agentcontext_test
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/agent/agentcontext"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func mustWriteFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
}
|
|
|
|
func mustWriteSkill(t *testing.T, dir, name, description string) {
|
|
t.Helper()
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o755))
|
|
mustWriteFile(t, filepath.Join(dir, name, "SKILL.md"),
|
|
"---\nname: "+name+"\ndescription: "+description+"\n---\nSkill body for "+name)
|
|
}
|
|
|
|
func findResource(t *testing.T, resources []agentcontext.Resource, kind agentcontext.ResourceKind, source string) agentcontext.Resource {
|
|
t.Helper()
|
|
for _, r := range resources {
|
|
if r.Kind == kind && r.Source == source {
|
|
return r
|
|
}
|
|
}
|
|
t.Fatalf("resource not found: kind=%s source=%s", kind, source)
|
|
return agentcontext.Resource{}
|
|
}
|
|
|
|
func TestResolver_ProjectAGENTSFile(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "# Project rules\n\nDo the thing.")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.KindInstructionFile, got.Kind)
|
|
require.Equal(t, agentcontext.StatusOK, got.Status)
|
|
require.Equal(t, filepath.Join(dir, "AGENTS.md"), got.Source)
|
|
require.Contains(t, string(got.Payload), "Do the thing.")
|
|
require.Equal(t, "Project rules", got.Description)
|
|
require.NotEqual(t, [32]byte{}, got.ContentHash)
|
|
}
|
|
|
|
// TestResolver_InstructionNamesAreCaseSensitive verifies the
|
|
// resolver matches instruction filenames exactly, mirroring
|
|
// codex. A lower-case agents.md (for example a generated API
|
|
// reference doc) must not be mistaken for an instruction file.
|
|
func TestResolver_InstructionNamesAreCaseSensitive(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, "agents.md"), "lower\n")
|
|
mustWriteFile(t, filepath.Join(dir, "CLAUDE.md"), "claude\n")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, filepath.Join(dir, "CLAUDE.md"), snap.Resources[0].Source)
|
|
}
|
|
|
|
func TestResolver_SkillsContainerEmitsEachSubdir(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteSkill(t, filepath.Join(dir, ".agents", "skills"), "make-coffee", "Coffee skill")
|
|
mustWriteSkill(t, filepath.Join(dir, ".agents", "skills"), "fold-laundry", "Laundry skill")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
var kinds []string
|
|
for _, res := range snap.Resources {
|
|
kinds = append(kinds, res.Kind.String()+":"+filepath.Base(res.Source))
|
|
}
|
|
require.ElementsMatch(t, []string{
|
|
"skill:make-coffee",
|
|
"skill:fold-laundry",
|
|
}, kinds)
|
|
}
|
|
|
|
func TestResolver_SkillNameMismatchInvalid(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
skillsDir := filepath.Join(dir, ".agents", "skills", "make-coffee")
|
|
require.NoError(t, os.MkdirAll(skillsDir, 0o755))
|
|
mustWriteFile(t, filepath.Join(skillsDir, "SKILL.md"),
|
|
"---\nname: drink-tea\ndescription: oops\n---\nBody")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.KindSkill, got.Kind)
|
|
require.Equal(t, agentcontext.StatusInvalid, got.Status)
|
|
require.Contains(t, got.Error, "does not match directory")
|
|
}
|
|
|
|
// TestResolver_SkillNameNonKebabInvalid exercises the kebab-case
|
|
// validation branch in readSkillMeta. The skill name matches the
|
|
// parent directory (so the mismatch check passes) but contains
|
|
// characters that SkillNamePattern rejects. Without this test
|
|
// the kebab branch could be deleted and the suite would still
|
|
// pass.
|
|
func TestResolver_SkillNameNonKebabInvalid(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
skillDir := filepath.Join(dir, ".agents", "skills", "Make_Coffee")
|
|
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
|
mustWriteFile(t, filepath.Join(skillDir, "SKILL.md"),
|
|
"---\nname: Make_Coffee\ndescription: oops\n---\nBody")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.KindSkill, got.Kind)
|
|
require.Equal(t, agentcontext.StatusInvalid, got.Status)
|
|
require.Contains(t, got.Error, "kebab-case")
|
|
}
|
|
|
|
func TestResolver_MCPConfigEmitted(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
contents := `{"mcpServers": {"github": {"env": {"GITHUB_TOKEN": "secret-token"}}}}`
|
|
mustWriteFile(t, filepath.Join(dir, ".mcp.json"), contents)
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.KindMCPConfig, got.Kind)
|
|
require.Equal(t, agentcontext.StatusOK, got.Status)
|
|
// The .mcp.json payload is intentionally not shipped:
|
|
// the file can contain secret-bearing Env/Headers values.
|
|
// Only the path + ContentHash are exposed, so consumers
|
|
// can detect changes without ever seeing the bytes.
|
|
require.Empty(t, got.Payload, "readMCPConfig must not include the file payload")
|
|
require.NotEqual(t, [32]byte{}, got.ContentHash, "readMCPConfig must populate ContentHash for change detection")
|
|
require.Equal(t, uint64(len(contents)), got.SizeBytes)
|
|
}
|
|
|
|
// TestResolver_SymlinkInsideScanRootAllowed exercises the
|
|
// monorepo case where a top-level AGENTS.md is symlinked to
|
|
// shared content elsewhere inside the same workspace tree. The
|
|
// target lives under the scan root, so the resolver follows the
|
|
// symlink, emits the target bytes, and attributes the resource
|
|
// to the resolved target path.
|
|
func TestResolver_SymlinkInsideScanRootAllowed(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require admin privileges on Windows runners")
|
|
}
|
|
t.Parallel()
|
|
dir := testutil.TempDirResolved(t)
|
|
target := filepath.Join(dir, "docs", "AGENTS.md")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
|
|
mustWriteFile(t, target, "shared monorepo guidance")
|
|
link := filepath.Join(dir, "AGENTS.md")
|
|
require.NoError(t, os.Symlink(target, link))
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
// The nested target is not independently recognized (only the
|
|
// top-level symlink is), so exactly one resource is emitted,
|
|
// carrying the target bytes and attributed to the target.
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.StatusOK, got.Status)
|
|
require.Equal(t, target, got.Source)
|
|
require.Equal(t, "shared monorepo guidance", string(got.Payload))
|
|
}
|
|
|
|
// TestResolver_SymlinkedInstructionFilesDeduplicated reproduces
|
|
// the common repo layout where CLAUDE.md and .cursorrules are
|
|
// symlinks to a single AGENTS.md. All three resolve to the same
|
|
// file, so the resolver must emit one instruction resource
|
|
// attributed to the real AGENTS.md rather than three copies of
|
|
// identical content.
|
|
func TestResolver_SymlinkedInstructionFilesDeduplicated(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require admin privileges on Windows runners")
|
|
}
|
|
t.Parallel()
|
|
dir := testutil.TempDirResolved(t)
|
|
agents := filepath.Join(dir, "AGENTS.md")
|
|
mustWriteFile(t, agents, "the one true guidance")
|
|
require.NoError(t, os.Symlink(agents, filepath.Join(dir, "CLAUDE.md")))
|
|
require.NoError(t, os.Symlink(agents, filepath.Join(dir, ".cursorrules")))
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.KindInstructionFile, got.Kind)
|
|
require.Equal(t, agents, got.Source)
|
|
require.Equal(t, "the one true guidance", string(got.Payload))
|
|
}
|
|
|
|
// TestResolver_InstructionFilesOnlyAtScanRoot verifies the
|
|
// resolver does not descend into subdirectories to collect
|
|
// nested instruction files, mirroring codex. A nested
|
|
// site/AGENTS.md is ignored while the top-level one is kept.
|
|
func TestResolver_InstructionFilesOnlyAtScanRoot(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "root")
|
|
mustWriteFile(t, filepath.Join(dir, "site", "AGENTS.md"), "nested")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, filepath.Join(dir, "AGENTS.md"), snap.Resources[0].Source)
|
|
require.Equal(t, "root", string(snap.Resources[0].Payload))
|
|
}
|
|
|
|
// TestResolver_SymlinkOutsideScanRootRejected guards the
|
|
// security boundary. A malicious workspace cannot ship a
|
|
// snapshot containing ~/.ssh/id_rsa or /etc/passwd by placing a
|
|
// symlink with that target at AGENTS.md, .mcp.json, or
|
|
// SKILL.md inside the scan root.
|
|
func TestResolver_SymlinkOutsideScanRootRejected(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require admin privileges on Windows runners")
|
|
}
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
secretDir := t.TempDir()
|
|
secret := filepath.Join(secretDir, "id_rsa")
|
|
mustWriteFile(t, secret, "-----BEGIN OPENSSH PRIVATE KEY-----")
|
|
link := filepath.Join(dir, "AGENTS.md")
|
|
require.NoError(t, os.Symlink(secret, link))
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.StatusInvalid, got.Status)
|
|
require.Empty(t, got.Payload, "escaping symlink target must not be shipped")
|
|
require.Contains(t, got.Error, "escapes scan root")
|
|
}
|
|
|
|
// TestResolver_BrokenSymlink emits Unreadable for a dangling
|
|
// link rather than crashing the walk.
|
|
func TestResolver_BrokenSymlink(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require admin privileges on Windows runners")
|
|
}
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
link := filepath.Join(dir, "AGENTS.md")
|
|
require.NoError(t, os.Symlink(filepath.Join(dir, "does-not-exist"), link))
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, agentcontext.StatusUnreadable, snap.Resources[0].Status)
|
|
}
|
|
|
|
func TestResolver_OversizeInstructionFile(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
// Write a file larger than the per-resource cap.
|
|
big := make([]byte, 200)
|
|
for i := range big {
|
|
big[i] = 'a'
|
|
}
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), string(big))
|
|
|
|
r := &agentcontext.Resolver{MaxResourceBytes: 100}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.StatusOversize, got.Status)
|
|
require.Empty(t, got.Payload)
|
|
require.Equal(t, uint64(200), got.SizeBytes)
|
|
// Hash over capped slice is still populated so callers
|
|
// can detect "still oversize but content changed".
|
|
require.NotEqual(t, [32]byte{}, got.ContentHash)
|
|
}
|
|
|
|
func TestResolver_AggregateCapExcludes(t *testing.T) {
|
|
t.Parallel()
|
|
// Instruction files are only read at a scan root's top level,
|
|
// so each contributing file lives at its own scan root.
|
|
dirRoot := t.TempDir()
|
|
dirA := t.TempDir()
|
|
dirB := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dirRoot, "AGENTS.md"), "small")
|
|
mustWriteFile(t, filepath.Join(dirA, "AGENTS.md"), "AAAA")
|
|
mustWriteFile(t, filepath.Join(dirB, "AGENTS.md"), "BBBB")
|
|
|
|
// Aggregate cap of 9 bytes lets two of the three (5+4) bytes
|
|
// through but excludes the third regardless of order.
|
|
r := &agentcontext.Resolver{MaxSnapshotBytes: 9}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{
|
|
{Path: dirRoot},
|
|
{Path: dirA},
|
|
{Path: dirB},
|
|
})
|
|
|
|
var excluded int
|
|
for _, res := range snap.Resources {
|
|
if res.Status == agentcontext.StatusExcluded {
|
|
excluded++
|
|
}
|
|
}
|
|
require.Equal(t, 1, excluded)
|
|
}
|
|
|
|
func TestResolver_CountCapExcludes(t *testing.T) {
|
|
t.Parallel()
|
|
// Instruction files are only read at a scan root's top level,
|
|
// so spread the five files across five scan roots.
|
|
roots := make([]agentcontext.ScanRoot, 0, 5)
|
|
for i := 0; i < 5; i++ {
|
|
d := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(d, "AGENTS.md"), "x")
|
|
roots = append(roots, agentcontext.ScanRoot{Path: d})
|
|
}
|
|
|
|
r := &agentcontext.Resolver{MaxResources: 3}
|
|
snap := r.Resolve(roots)
|
|
|
|
require.Len(t, snap.Resources, 5)
|
|
var excluded int
|
|
for _, res := range snap.Resources {
|
|
if res.Status == agentcontext.StatusExcluded {
|
|
excluded++
|
|
}
|
|
}
|
|
require.Equal(t, 2, excluded)
|
|
}
|
|
|
|
// TestResolver_MCPConfigOnlyAtScanRoot verifies that .mcp.json is
|
|
// recognized only at a scan root's top level. A nested config is
|
|
// ignored because the resolver no longer walks the tree.
|
|
func TestResolver_MCPConfigOnlyAtScanRoot(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, ".mcp.json"), `{"mcpServers": {}}`)
|
|
mustWriteFile(t, filepath.Join(dir, "sub", ".mcp.json"), `{"mcpServers": {}}`)
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, agentcontext.KindMCPConfig, snap.Resources[0].Kind)
|
|
require.Equal(t, filepath.Join(dir, ".mcp.json"), snap.Resources[0].Source)
|
|
}
|
|
|
|
// TestResolver_SkillsOnlyFromFixedContainers verifies skills are
|
|
// discovered from the fixed container locations (skills,
|
|
// .agents/skills, .claude/skills, .codex/skills) and never from an
|
|
// arbitrary skills/ directory nested elsewhere in the tree.
|
|
func TestResolver_SkillsOnlyFromFixedContainers(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteSkill(t, filepath.Join(dir, "skills"), "water-plants", "p")
|
|
mustWriteSkill(t, filepath.Join(dir, ".agents", "skills"), "make-coffee", "c")
|
|
mustWriteSkill(t, filepath.Join(dir, ".claude", "skills"), "fold-laundry", "l")
|
|
mustWriteSkill(t, filepath.Join(dir, ".codex", "skills"), "walk-dog", "d")
|
|
// A skills/ directory buried under an arbitrary path is not a
|
|
// fixed container location and must be ignored.
|
|
mustWriteSkill(t, filepath.Join(dir, "pkg", "skills"), "buried", "b")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
var names []string
|
|
for _, res := range snap.Resources {
|
|
require.Equal(t, agentcontext.KindSkill, res.Kind)
|
|
names = append(names, filepath.Base(res.Source))
|
|
}
|
|
require.ElementsMatch(t,
|
|
[]string{"water-plants", "make-coffee", "fold-laundry", "walk-dog"}, names)
|
|
}
|
|
|
|
func TestResolver_UserSourceAttribution(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "user-added")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir, UserSource: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, dir, snap.Resources[0].SourcePath)
|
|
}
|
|
|
|
func TestResolver_MissingRootSilentlyIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: "/nonexistent/path"}})
|
|
require.Empty(t, snap.Resources)
|
|
require.Empty(t, snap.SnapshotError)
|
|
}
|
|
|
|
func TestResolver_SingleFileRootClassified(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "AGENTS.md")
|
|
mustWriteFile(t, path, "x")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: path}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
require.Equal(t, agentcontext.KindInstructionFile, snap.Resources[0].Kind)
|
|
}
|
|
|
|
func TestResolver_DuplicateRootsDeduplicated(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "x")
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{
|
|
{Path: dir},
|
|
{Path: dir},
|
|
{Path: dir},
|
|
})
|
|
require.Len(t, snap.Resources, 1)
|
|
}
|
|
|
|
func TestResolver_MCPResources(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
mcpRes := agentcontext.Resource{
|
|
ID: "mcp_server:github",
|
|
Kind: agentcontext.KindMCPServer,
|
|
Source: "github",
|
|
Status: agentcontext.StatusOK,
|
|
Payload: []byte("tool-list-json"),
|
|
ContentHash: sha256.Sum256([]byte("tool-list-json")),
|
|
Description: "GitHub MCP server",
|
|
}
|
|
r := &agentcontext.Resolver{
|
|
MCPResources: func() []agentcontext.Resource { return []agentcontext.Resource{mcpRes} },
|
|
}
|
|
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
got := findResource(t, snap.Resources, agentcontext.KindMCPServer, "github")
|
|
require.Equal(t, agentcontext.StatusOK, got.Status)
|
|
require.Equal(t, "GitHub MCP server", got.Description)
|
|
}
|
|
|
|
// TestResolver_MCPResourcesRespectAggregateByteCap guards the
|
|
// contract that a single oversized MCP payload cannot blow past
|
|
// MaxSnapshotBytes with StatusOK.
|
|
func TestResolver_MCPResourcesRespectAggregateByteCap(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
big := make([]byte, 1024)
|
|
for i := range big {
|
|
big[i] = 'x'
|
|
}
|
|
mcpRes := agentcontext.Resource{
|
|
ID: "mcp_server:big",
|
|
Kind: agentcontext.KindMCPServer,
|
|
Source: "big",
|
|
Status: agentcontext.StatusOK,
|
|
Payload: big,
|
|
ContentHash: sha256.Sum256(big),
|
|
}
|
|
r := &agentcontext.Resolver{
|
|
MaxSnapshotBytes: 512,
|
|
MCPResources: func() []agentcontext.Resource { return []agentcontext.Resource{mcpRes} },
|
|
}
|
|
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
got := findResource(t, snap.Resources, agentcontext.KindMCPServer, "big")
|
|
require.Equal(t, agentcontext.StatusExcluded, got.Status,
|
|
"MCP payload exceeding MaxSnapshotBytes must be excluded")
|
|
require.Empty(t, got.Payload)
|
|
require.NotEmpty(t, snap.SnapshotError, "snapshot must surface the cap breach")
|
|
}
|
|
|
|
// TestResolver_MCPExcludedFromAggregateHash verifies that MCP resources
|
|
// (config and live servers) are carried in the snapshot but excluded
|
|
// from the drift/aggregate hash, so an MCP server connecting (or its
|
|
// tools changing) does not flip already-hydrated chats to dirty.
|
|
func TestResolver_MCPExcludedFromAggregateHash(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
// An instruction file provides drift-relevant pinned content.
|
|
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "workspace rules")
|
|
|
|
base := (&agentcontext.Resolver{}).Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
mcpRes := agentcontext.Resource{
|
|
ID: "mcp_server:github",
|
|
Kind: agentcontext.KindMCPServer,
|
|
Source: "github",
|
|
Name: "github",
|
|
Status: agentcontext.StatusOK,
|
|
ContentHash: sha256.Sum256([]byte("tool-list")),
|
|
Tools: []agentcontext.MCPTool{{Name: "search"}},
|
|
}
|
|
withMCP := (&agentcontext.Resolver{
|
|
MCPResources: func() []agentcontext.Resource { return []agentcontext.Resource{mcpRes} },
|
|
}).Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
// The MCP server resource is present in the snapshot...
|
|
got := findResource(t, withMCP.Resources, agentcontext.KindMCPServer, "github")
|
|
require.Len(t, got.Tools, 1)
|
|
// ...but does not change the drift/aggregate hash.
|
|
require.Equal(t, base.AggregateHash, withMCP.AggregateHash,
|
|
"MCP resources must not participate in the drift hash")
|
|
}
|
|
|
|
// TestResolver_UnreadableInstructionFile verifies the
|
|
// permission-denied walk path produces a StatusUnreadable
|
|
// resource classified with the correct kind, matching the
|
|
// classification the resolver would emit on a successful read.
|
|
func TestResolver_UnreadableInstructionFile(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("file mode 0o000 does not deny reads on Windows")
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root bypasses file mode permissions")
|
|
}
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "AGENTS.md")
|
|
mustWriteFile(t, path, "hello")
|
|
require.NoError(t, os.Chmod(path, 0o000))
|
|
t.Cleanup(func() { _ = os.Chmod(path, 0o600) })
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.KindInstructionFile, got.Kind)
|
|
require.Equal(t, agentcontext.StatusUnreadable, got.Status)
|
|
require.NotEmpty(t, got.Error)
|
|
}
|
|
|
|
// TestResolver_UnreadableMCPConfig confirms the walk-error path
|
|
// uses the file's real kind, not a hardcoded fallback. Without
|
|
// this, a permission flip on .mcp.json would produce a phantom
|
|
// resource ID swap when the permission is later restored.
|
|
func TestResolver_UnreadableMCPConfig(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("file mode 0o000 does not deny reads on Windows")
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root bypasses file mode permissions")
|
|
}
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, ".mcp.json")
|
|
mustWriteFile(t, path, `{"mcpServers": {}}`)
|
|
require.NoError(t, os.Chmod(path, 0o000))
|
|
t.Cleanup(func() { _ = os.Chmod(path, 0o600) })
|
|
|
|
r := &agentcontext.Resolver{}
|
|
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
|
|
|
|
require.Len(t, snap.Resources, 1)
|
|
got := snap.Resources[0]
|
|
require.Equal(t, agentcontext.KindMCPConfig, got.Kind)
|
|
require.Equal(t, agentcontext.StatusUnreadable, got.Status)
|
|
require.NotEmpty(t, got.Error)
|
|
}
|
|
|
|
func TestResourceKindString(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
kind agentcontext.ResourceKind
|
|
want string
|
|
}{
|
|
{agentcontext.KindUnspecified, "unknown"},
|
|
{agentcontext.KindInstructionFile, "instruction_file"},
|
|
{agentcontext.KindSkill, "skill"},
|
|
{agentcontext.KindMCPConfig, "mcp_config"},
|
|
{agentcontext.KindMCPServer, "mcp_server"},
|
|
{agentcontext.KindPlugin, "plugin"},
|
|
{agentcontext.KindHook, "hook"},
|
|
{agentcontext.KindSubagent, "subagent"},
|
|
{agentcontext.KindCommand, "command"},
|
|
{agentcontext.ResourceKind(999), "unknown"},
|
|
}
|
|
for _, tt := range tests {
|
|
require.Equal(t, tt.want, tt.kind.String())
|
|
}
|
|
}
|
|
|
|
func TestResourceStatusString(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
status agentcontext.ResourceStatus
|
|
want string
|
|
}{
|
|
{agentcontext.StatusOK, "ok"},
|
|
{agentcontext.StatusOversize, "oversize"},
|
|
{agentcontext.StatusUnreadable, "unreadable"},
|
|
{agentcontext.StatusInvalid, "invalid"},
|
|
{agentcontext.StatusExcluded, "excluded"},
|
|
{agentcontext.ResourceStatus(999), "unknown"},
|
|
}
|
|
for _, tt := range tests {
|
|
require.Equal(t, tt.want, tt.status.String())
|
|
}
|
|
}
|
|
|
|
func TestComputeAggregateHash_DeterministicAcrossOrder(t *testing.T) {
|
|
t.Parallel()
|
|
a := agentcontext.Resource{
|
|
ID: "instruction_file:/a/AGENTS.md",
|
|
Kind: agentcontext.KindInstructionFile,
|
|
Source: "/a/AGENTS.md",
|
|
Status: agentcontext.StatusOK,
|
|
}
|
|
b := agentcontext.Resource{
|
|
ID: "instruction_file:/b/AGENTS.md",
|
|
Kind: agentcontext.KindInstructionFile,
|
|
Source: "/b/AGENTS.md",
|
|
Status: agentcontext.StatusOK,
|
|
}
|
|
got1 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{a, b})
|
|
got2 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{b, a})
|
|
require.Equal(t, got1, got2)
|
|
}
|
|
|
|
func TestComputeAggregateHash_ChangesOnContent(t *testing.T) {
|
|
t.Parallel()
|
|
base := agentcontext.Resource{
|
|
ID: "instruction_file:/a/AGENTS.md",
|
|
Kind: agentcontext.KindInstructionFile,
|
|
Source: "/a/AGENTS.md",
|
|
Status: agentcontext.StatusOK,
|
|
}
|
|
hash1 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{base})
|
|
|
|
withContent := base
|
|
withContent.ContentHash = [32]byte{0x01}
|
|
hash2 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{withContent})
|
|
require.NotEqual(t, hash1, hash2)
|
|
|
|
withStatus := base
|
|
withStatus.Status = agentcontext.StatusOversize
|
|
hash3 := agentcontext.ComputeAggregateHash([]agentcontext.Resource{withStatus})
|
|
require.NotEqual(t, hash1, hash3)
|
|
}
|