- admin-protected client config (flag > AGENT_LIB_CONFIG > platform default), token used for basic auth only, redacted from all errors and files - work-repo pull via go-git (bare cache clone + fetch, remote re-pointed on config change), clean failure keeps previous state - inventory: own items + external areas from lockfile; MCP inventoried but never deployed - manifest v1 records name/type/origin/revision/content-hash per item, deterministic bytes; folder hashes over sorted relpath+filehash lines so tree and disk hashes agree - shared deploy package (atomic temp+rename, exec-bit preserving) now backs both curator materialization and client deployment - sync validates and hashes everything before the first mutation; second run is a byte-level no-op
280 lines
8.3 KiB
Go
280 lines
8.3 KiB
Go
package e2e
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func agentLibCmd(args ...string) *exec.Cmd {
|
|
return exec.Command(binPath, args...)
|
|
}
|
|
|
|
type syncEnv struct {
|
|
home string
|
|
stateDir string
|
|
configDir string
|
|
config string
|
|
workRepo string
|
|
}
|
|
|
|
func newSyncEnv(t *testing.T, h *harness) *syncEnv {
|
|
t.Helper()
|
|
base := t.TempDir()
|
|
env := &syncEnv{
|
|
home: filepath.Join(base, "home"),
|
|
stateDir: filepath.Join(base, "state"),
|
|
configDir: filepath.Join(base, "config"),
|
|
workRepo: filepath.Join(base, "work-repo"),
|
|
}
|
|
for _, d := range []string{env.home, env.stateDir, env.configDir, env.workRepo} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
func (e *syncEnv) writeConfig(t *testing.T, repoURL, token string) {
|
|
t.Helper()
|
|
cfg := map[string]string{"repo_url": repoURL, "ref": "main"}
|
|
if token != "" {
|
|
cfg["token"] = token
|
|
}
|
|
data, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.config = filepath.Join(e.configDir, "config.json")
|
|
os.WriteFile(e.config, data, 0o600)
|
|
}
|
|
|
|
func (e *syncEnv) run(t *testing.T, args ...string) (string, error) {
|
|
t.Helper()
|
|
cmd := agentLibCmd(append([]string{"sync", "--config", e.config}, args...)...)
|
|
cmd.Env = append(os.Environ(),
|
|
"HOME="+e.home, "USERPROFILE="+e.home,
|
|
"AGENT_LIB_STATE_DIR="+e.stateDir,
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
func buildWorkRepo(t *testing.T, h *harness, dir string) {
|
|
t.Helper()
|
|
h.git(t, dir, "init", "-b", "main")
|
|
write := func(rel, content string) {
|
|
p := filepath.Join(dir, rel)
|
|
os.MkdirAll(filepath.Dir(p), 0o755)
|
|
os.WriteFile(p, []byte(content), 0o644)
|
|
}
|
|
write("skills/own-skill/SKILL.md", "---\nname: Own Skill\n---\n# Own\n")
|
|
write("skills/own-skill/run.sh", "#!/bin/sh\necho hi\n")
|
|
os.Chmod(filepath.Join(dir, "skills/own-skill/run.sh"), 0o755)
|
|
write("commands/deploy.md", "---\nname: Deploy\n---\nbody\n")
|
|
write("agents/helper.md", "---\nname: Helper\n---\nbody\n")
|
|
write("mcp/internal.yaml", "servers: {}\n")
|
|
h.git(t, dir, "add", "-A")
|
|
h.git(t, dir, "commit", "-m", "work repo")
|
|
}
|
|
|
|
func TestSyncHappyPathAndManifest(t *testing.T) {
|
|
h := newHarness(t)
|
|
env := newSyncEnv(t, h)
|
|
buildWorkRepo(t, h, env.workRepo)
|
|
env.writeConfig(t, env.workRepo, "")
|
|
|
|
out, err := env.run(t)
|
|
if err != nil {
|
|
t.Fatalf("sync failed: %v\n%s", err, out)
|
|
}
|
|
|
|
skillMd := filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")
|
|
if _, err := os.Stat(skillMd); err != nil {
|
|
t.Error("skill must deploy to ~/.agents/skills/<id>/")
|
|
}
|
|
runSh := filepath.Join(env.home, ".agents/skills/own-skill/run.sh")
|
|
info, err := os.Stat(runSh)
|
|
if err != nil {
|
|
t.Fatal("skill folder artifacts must be preserved")
|
|
}
|
|
if info.Mode()&0o111 == 0 {
|
|
t.Error("executable bit must survive deployment")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.home, ".config/opencode/commands/deploy.md")); err != nil {
|
|
t.Error("command must deploy to ~/.config/opencode/commands/<id>.md")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.home, ".config/opencode/agents/helper.md")); err != nil {
|
|
t.Error("agent must deploy to ~/.config/opencode/agents/<id>.md")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.home, ".config/opencode/mcp")); err == nil {
|
|
t.Error("MCP must never be deployed")
|
|
}
|
|
mcpEverywhere := false
|
|
filepath.WalkDir(env.home, func(p string, d os.DirEntry, err error) error {
|
|
if d != nil && !d.IsDir() && strings.HasSuffix(p, ".yaml") {
|
|
mcpEverywhere = true
|
|
}
|
|
return nil
|
|
})
|
|
if mcpEverywhere {
|
|
t.Error("no yaml fragment may land anywhere in HOME")
|
|
}
|
|
|
|
manifestData, err := os.ReadFile(filepath.Join(env.stateDir, "manifest.json"))
|
|
if err != nil {
|
|
t.Fatal("manifest must exist in the state dir")
|
|
}
|
|
var manifest struct {
|
|
Version int `json:"version"`
|
|
Items map[string]struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Origin string `json:"origin"`
|
|
Revision string `json:"revision"`
|
|
Hash string `json:"hash"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
|
t.Fatalf("manifest not parseable: %v", err)
|
|
}
|
|
skill := manifest.Items["skills/own-skill"]
|
|
if skill.Origin != "own" || skill.Hash == "" || skill.Revision == "" {
|
|
t.Errorf("manifest record incomplete: %+v", skill)
|
|
}
|
|
}
|
|
|
|
func TestSyncIdempotentSecondRun(t *testing.T) {
|
|
h := newHarness(t)
|
|
env := newSyncEnv(t, h)
|
|
buildWorkRepo(t, h, env.workRepo)
|
|
env.writeConfig(t, env.workRepo, "")
|
|
|
|
if _, err := env.run(t); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
skillMd := filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")
|
|
firstInfo, _ := os.Stat(skillMd)
|
|
manifestBefore := readFile(t, filepath.Join(env.stateDir, "manifest.json"))
|
|
|
|
out, err := env.run(t)
|
|
if err != nil {
|
|
t.Fatalf("second sync failed: %v\n%s", err, out)
|
|
}
|
|
if !contains(out, "kept: 3") {
|
|
t.Errorf("second run must keep everything:\n%s", out)
|
|
}
|
|
|
|
secondInfo, _ := os.Stat(skillMd)
|
|
if !firstInfo.ModTime().Equal(secondInfo.ModTime()) {
|
|
t.Error("unchanged items must not be rewritten (byte-level no-op)")
|
|
}
|
|
if got := readFile(t, filepath.Join(env.stateDir, "manifest.json")); got != manifestBefore {
|
|
t.Error("manifest bytes must not change on a no-op sync")
|
|
}
|
|
}
|
|
|
|
func TestSyncUnreachableRepoKeepsState(t *testing.T) {
|
|
h := newHarness(t)
|
|
env := newSyncEnv(t, h)
|
|
buildWorkRepo(t, h, env.workRepo)
|
|
env.writeConfig(t, env.workRepo, "")
|
|
|
|
if _, err := env.run(t); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
skillBefore := readFile(t, filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md"))
|
|
|
|
env.writeConfig(t, filepath.Join(t.TempDir(), "does-not-exist"), "")
|
|
out, err := env.run(t)
|
|
if err == nil {
|
|
t.Fatalf("unreachable repo must fail:\n%s", out)
|
|
}
|
|
if !contains(out, "error") {
|
|
t.Errorf("failure must be a clean error:\n%s", out)
|
|
}
|
|
if got := readFile(t, filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")); got != skillBefore {
|
|
t.Error("previous state must survive a failed sync")
|
|
}
|
|
}
|
|
|
|
func TestSyncPicksUpChanges(t *testing.T) {
|
|
h := newHarness(t)
|
|
env := newSyncEnv(t, h)
|
|
buildWorkRepo(t, h, env.workRepo)
|
|
env.writeConfig(t, env.workRepo, "")
|
|
env.run(t)
|
|
|
|
p := filepath.Join(env.workRepo, "commands/deploy.md")
|
|
os.WriteFile(p, []byte("---\nname: Deploy\ndescription: updated\n---\nnew body\n"), 0o644)
|
|
h.git(t, env.workRepo, "add", "-A")
|
|
h.git(t, env.workRepo, "commit", "-m", "update command")
|
|
|
|
out, err := env.run(t)
|
|
if err != nil {
|
|
t.Fatalf("sync after upstream change failed: %v\n%s", err, out)
|
|
}
|
|
if !contains(out, "updated: 1") {
|
|
t.Errorf("changed item must be updated:\n%s", out)
|
|
}
|
|
deployed := readFile(t, filepath.Join(env.home, ".config/opencode/commands/deploy.md"))
|
|
if !contains(deployed, "new body") {
|
|
t.Error("updated content must land on disk")
|
|
}
|
|
}
|
|
|
|
func TestSyncVendoredContentDeploys(t *testing.T) {
|
|
h := newHarness(t)
|
|
standardFixture(h, t)
|
|
env := newSyncEnv(t, h)
|
|
buildWorkRepo(t, h, env.workRepo)
|
|
|
|
upstreamWork := env.workRepo
|
|
cmd := agentLibCmd("vendor", "add", "superpowers", h.upstreamDir)
|
|
cmd.Dir = upstreamWork
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("vendor add into work repo: %v\n%s", err, out)
|
|
}
|
|
h.git(t, upstreamWork, "add", "-A")
|
|
h.git(t, upstreamWork, "commit", "-m", "vendor superpowers")
|
|
|
|
env.writeConfig(t, upstreamWork, "")
|
|
out, err := env.run(t)
|
|
if err != nil {
|
|
t.Fatalf("sync with vendored content failed: %v\n%s", err, out)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.home, ".agents/skills/good-skill/SKILL.md")); err != nil {
|
|
t.Error("vendored skill must deploy under its deployed name")
|
|
}
|
|
manifestData := []byte(readFile(t, filepath.Join(env.stateDir, "manifest.json")))
|
|
if !contains(string(manifestData), `"origin": "superpowers"`) {
|
|
t.Errorf("manifest must record the source origin:\n%s", manifestData)
|
|
}
|
|
}
|
|
|
|
func TestSyncTokenNeverLeaked(t *testing.T) {
|
|
h := newHarness(t)
|
|
env := newSyncEnv(t, h)
|
|
buildWorkRepo(t, h, env.workRepo)
|
|
env.writeConfig(t, env.workRepo, "super-secret-token")
|
|
|
|
out, err := env.run(t)
|
|
if err != nil {
|
|
t.Fatalf("sync with token failed: %v\n%s", err, out)
|
|
}
|
|
if contains(out, "super-secret-token") {
|
|
t.Errorf("token must never appear in output:\n%s", out)
|
|
}
|
|
for _, path := range []string{
|
|
filepath.Join(env.stateDir, "manifest.json"),
|
|
filepath.Join(env.workRepo, "agent-lib.lock.json"),
|
|
} {
|
|
if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), "super-secret-token") {
|
|
t.Errorf("token leaked into %s", path)
|
|
}
|
|
}
|
|
}
|