Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
251 lines
7.3 KiB
Go
251 lines
7.3 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func writePlugin(t *testing.T, base, name string, files map[string]string) string {
|
|
t.Helper()
|
|
dir := filepath.Join(base, name)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for path, content := range files {
|
|
full := filepath.Join(dir, path)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestLocalResolver_Scheme(t *testing.T) {
|
|
if (&LocalResolver{}).Scheme() != "local" {
|
|
t.Error("scheme must be 'local'")
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_CopiesPluginTree(t *testing.T) {
|
|
base := t.TempDir()
|
|
writePlugin(t, base, "demo", map[string]string{
|
|
"plugin.yaml": "name: demo\n",
|
|
"rules/one.md": "- rule",
|
|
"skills/hello/SKILL.md": "---\nname: hello\ndescription: d\n---\nbody",
|
|
"skills/hello/scripts/t.py": "# tool",
|
|
})
|
|
|
|
dst := t.TempDir()
|
|
r := NewLocalResolver(base)
|
|
name, err := r.Fetch(context.Background(), "demo", dst)
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if name != "demo" {
|
|
t.Errorf("got name %q", name)
|
|
}
|
|
// Verify every file made it across.
|
|
for _, want := range []string{
|
|
"plugin.yaml",
|
|
"rules/one.md",
|
|
"skills/hello/SKILL.md",
|
|
"skills/hello/scripts/t.py",
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(dst, want)); err != nil {
|
|
t.Errorf("missing %q in dst: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_RejectsPathTraversal(t *testing.T) {
|
|
r := NewLocalResolver(t.TempDir())
|
|
cases := []string{
|
|
"",
|
|
" ",
|
|
"../evil",
|
|
"foo/bar",
|
|
"..",
|
|
"./hidden",
|
|
"foo\\bar",
|
|
}
|
|
for _, name := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := r.Fetch(context.Background(), name, t.TempDir())
|
|
if err == nil {
|
|
t.Errorf("Fetch(%q) should have failed", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_MissingPluginReturnsError(t *testing.T) {
|
|
r := NewLocalResolver(t.TempDir())
|
|
_, err := r.Fetch(context.Background(), "not-here", t.TempDir())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "not found") {
|
|
t.Errorf("error should mention 'not found': %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_RejectsNonDirectoryTarget(t *testing.T) {
|
|
base := t.TempDir()
|
|
// "demo" exists but is a file, not a dir.
|
|
if err := os.WriteFile(filepath.Join(base, "demo"), []byte("hi"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := NewLocalResolver(base)
|
|
_, err := r.Fetch(context.Background(), "demo", t.TempDir())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "not a directory") {
|
|
t.Errorf("error should mention 'not a directory': %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_HonoursContextCancellation(t *testing.T) {
|
|
// Make a plugin with enough files to take a moment.
|
|
base := t.TempDir()
|
|
files := map[string]string{}
|
|
for i := 0; i < 20; i++ {
|
|
files[filepath.Join("data", "f"+string(rune('a'+i))+".txt")] = strings.Repeat("x", 1024)
|
|
}
|
|
writePlugin(t, base, "big", files)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel before starting — walk should abort fast
|
|
|
|
r := NewLocalResolver(base)
|
|
_, err := r.Fetch(ctx, "big", t.TempDir())
|
|
if err == nil {
|
|
t.Error("expected cancellation error")
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_BubblesUpCopyFailure(t *testing.T) {
|
|
// Source file the copyTree walk would read; make dst unwritable so
|
|
// the copyFile step fails.
|
|
base := t.TempDir()
|
|
writePlugin(t, base, "demo", map[string]string{
|
|
"plugin.yaml": "name: demo\n",
|
|
})
|
|
dst := t.TempDir()
|
|
// Make dst read-only so creating files inside it fails.
|
|
if err := os.Chmod(dst, 0o555); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(dst, 0o755) })
|
|
|
|
r := NewLocalResolver(base)
|
|
_, err := r.Fetch(context.Background(), "demo", dst)
|
|
if err == nil {
|
|
t.Error("expected copy failure when dst is read-only")
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_CopyFileSourceUnreadable(t *testing.T) {
|
|
base := t.TempDir()
|
|
pluginDir := writePlugin(t, base, "demo", map[string]string{
|
|
"plugin.yaml": "name: demo\n",
|
|
})
|
|
// Make the source file unreadable — copyFile should error.
|
|
if err := os.Chmod(filepath.Join(pluginDir, "plugin.yaml"), 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(filepath.Join(pluginDir, "plugin.yaml"), 0o644) })
|
|
|
|
r := NewLocalResolver(base)
|
|
_, err := r.Fetch(context.Background(), "demo", t.TempDir())
|
|
// Root can read any file, so this test only asserts on non-root hosts.
|
|
if os.Getuid() == 0 {
|
|
t.Skip("running as root — cannot exercise unreadable-file branch")
|
|
}
|
|
if err == nil {
|
|
t.Error("expected error when source file is unreadable")
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_WalkErrorPropagates(t *testing.T) {
|
|
// Put a plugin dir in place, then replace its subdirectory with an
|
|
// unreadable one so filepath.Walk surfaces walkErr to our callback.
|
|
if os.Getuid() == 0 {
|
|
t.Skip("running as root — cannot exercise Walk error branch")
|
|
}
|
|
base := t.TempDir()
|
|
pluginDir := writePlugin(t, base, "demo", map[string]string{
|
|
"sub/file.txt": "x",
|
|
})
|
|
// Make the subdir unreadable so Walk's readdir fails.
|
|
if err := os.Chmod(filepath.Join(pluginDir, "sub"), 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(filepath.Join(pluginDir, "sub"), 0o755) })
|
|
|
|
r := NewLocalResolver(base)
|
|
_, err := r.Fetch(context.Background(), "demo", t.TempDir())
|
|
if err == nil {
|
|
t.Error("expected Walk error to propagate")
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_MissingReturnsErrPluginNotFound(t *testing.T) {
|
|
r := NewLocalResolver(t.TempDir())
|
|
_, err := r.Fetch(context.Background(), "absent", t.TempDir())
|
|
if !errors.Is(err, ErrPluginNotFound) {
|
|
t.Errorf("expected ErrPluginNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_NonNotExistStatErrorIsNotErrPluginNotFound(t *testing.T) {
|
|
// stat failing for reasons other than "doesn't exist" should NOT
|
|
// surface as ErrPluginNotFound. Hard to reliably trigger cross-
|
|
// platform, but we can at least prove the wrapping on a read-only
|
|
// permission-denied BaseDir.
|
|
if os.Getuid() == 0 {
|
|
t.Skip("running as root — cannot exercise permission branch")
|
|
}
|
|
base := t.TempDir()
|
|
locked := base + "/locked"
|
|
if err := os.Mkdir(locked, 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
r := NewLocalResolver(locked)
|
|
_, err := r.Fetch(context.Background(), "anything", t.TempDir())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if errors.Is(err, ErrPluginNotFound) {
|
|
t.Errorf("permission-denied stat must not be surfaced as ErrPluginNotFound: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLocalResolver_RejectsOverlongName(t *testing.T) {
|
|
// 129-char name (1 + 128 tail); max is 1 + 127 = 128.
|
|
long := "a" + strings.Repeat("b", 128)
|
|
r := NewLocalResolver(t.TempDir())
|
|
_, err := r.Fetch(context.Background(), long, t.TempDir())
|
|
if err == nil {
|
|
t.Error("overlong name should be rejected")
|
|
}
|
|
// Exactly at limit should still work (just the regex check; will
|
|
// still fail at the stat step since the dir doesn't exist, but with
|
|
// ErrPluginNotFound, not a format error).
|
|
atLimit := "a" + strings.Repeat("b", 127)
|
|
_, err = r.Fetch(context.Background(), atLimit, t.TempDir())
|
|
if err == nil {
|
|
t.Error("at-limit name with missing dir should still err")
|
|
} else if !errors.Is(err, ErrPluginNotFound) {
|
|
t.Errorf("at-limit name should err with ErrPluginNotFound (format is fine), got %v", err)
|
|
}
|
|
}
|