mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 12:05:06 +02:00
* fix(daemon): cleanup .agent_context / .multica / provider skill sidecars after local_directory tasks (MUL-2784) PR #3438 (MUL-2753) only restored CLAUDE.md / AGENTS.md / GEMINI.md to their pre-task bytes; the sidecar tree writeContextFiles seeds (.agent_context/, .multica/, .claude/skills/, .github/skills/, .opencode/skills/, skills/, .pi/skills/, .cursor/skills/, .kimi/skills/, .kiro/skills/, .agents/skills/, fallback .agent_context/skills/) was explicitly deferred to this follow-up. In local_directory mode the agent's workdir is the user's repo, so each task accumulates one more layer of those directories in the user's tree. Plan A: track every file/dir Prepare creates inside workDir in a sidecarManifest written to envRoot/.multica_sidecar_manifest.json (daemon scratch — never in the user's workdir). On local_directory teardown CleanupSidecars walks the manifest, removes the recorded files, then rmdir-iterates the recorded directories in reverse. Pre-existing files and directories are deliberately NOT recorded, so a user-installed .claude/skills/my-own-skill/ sibling — or any unrelated file the user keeps under .claude/, .github/, etc. — is preserved bit-for-bit. Non-empty rmdir fails ENOTEMPTY and is silently skipped, which is the signal that the user owns the directory. Daemon wiring lives next to the existing CleanupRuntimeConfig defer in runTask: runtime brief first, sidecars second. Cloud-mode runs still write a manifest for symmetry but never trigger the cleanup (the GC loop wipes envRoot wholesale). Tests (sidecar_manifest_test.go) cover the round-trip invariant per the issue's acceptance criteria: - empty workdir → Prepare → Cleanup → empty workdir, byte-exact, for every file-based provider (claude, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, gemini), - user's .claude/skills/my-own-skill/ (and equivalents per provider) survives Cleanup intact, - unrelated user files under .claude/, .github/, etc. survive, - three repeated cycles do not accumulate any orphan state, - project_resources branch (.multica/project/resources.json) is also reversible, - recordWriteFile refuses to record pre-existing files, - recordMkdirAll refuses to record pre-existing dirs, - Cleanup is a no-op when the manifest file is missing. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): refuse to overwrite pre-existing sidecar paths; pick collision-free skill slugs (MUL-2784 review) Addresses PR #3444 review (Elon): **Must-fix #1**: recordWriteFile used to overwrite pre-existing target files unconditionally and only skip the manifest record. That destroys user bytes at write time AND leaves the corrupted contents in place at cleanup time — the byte-exact contract the issue requires is violated on both halves. Fixed by making recordWriteFile detect any pre-existing entry (regular file, symlink, directory) via Lstat and return a sentinel errPathPreExists without touching the path. The user's bytes are preserved verbatim. For per-skill collisions (user's .claude/skills/issue-review/ vs Multica's "Issue Review"), writeSkillFiles now allocates a collision-free sibling slug via allocateCollisionFreeSkillDir: first attempt is the natural slug, then `<base>-multica`, `<base>-multica-2`, …, bounded at 64 attempts. Provider-native discovery still picks the skill up (every subdir under skillsParent is a distinct skill) and the user's path stays bit-for-bit intact. For Multica-only namespace files (.agent_context/issue_context.md, .multica/project/resources.json), the writer swallows errPathPreExists and continues — the runtime brief already carries every fact those files would, so a collision degrades to brief-only mode rather than destroying user content. **Must-fix #2**: Added byte-exact collision matrix tests covering every file-based provider (claude / codex / copilot / opencode / openclaw / hermes / pi / cursor / kimi / kiro / antigravity / gemini): - TestPrepareThenCleanupSidecarsSameSlugCollisionPerProvider: seeds user's `<provider>/skills/issue-review/SKILL.md` plus a private notes.md sibling, runs Prepare → Inject → Cleanup, asserts workdir snapshot is byte-identical to seed. - TestPrepareThenCleanupSidecarsIssueContextCollisionPerProvider: seeds user's `.agent_context/issue_context.md`, asserts round-trip preserves it. - TestPrepareThenCleanupSidecarsProjectResourcesCollisionPerProvider: same for `.multica/project/resources.json`. - TestPrepareThenCleanupSidecarsMultiSkillCollisionFreeAllocation: end-to-end check that the Multica skill lands at the collision-free sibling and Cleanup removes only the Multica side. - TestAllocateCollisionFreeSkillDir: directed unit test pinning the slug-bumping sequence. - TestRecordWriteFileRefusesToOverwritePreExistingFile (was TestRecordWriteFileSkipsPreExistingFile): flipped to assert the user's bytes survive and errPathPreExists is returned. - TestRecordWriteFileRefusesToOverwriteSymlinkOrDir: covers the Lstat path for non-file entries. **Should-fix**: CleanupSidecars used to swallow ANY non-ENOENT rmdir error as "user content present," silently dropping real I/O failures (EACCES, EPERM, EBUSY). Now it re-reads the directory after a failed rmdir via the new dirHasEntries helper — non-empty → silently skip (ENOTEMPTY, the intended branch); empty → genuine error, captured into firstErr and surfaced. Plus directed tests: - TestCleanupSidecarsSurfacesRealRmdirErrors - TestDirHasEntries Local verification: - go test ./internal/daemon/execenv/... — all green - go test ./internal/daemon/... — all green - go vet ./... — clean Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): surface original rmdir error when post-rmdir ReadDir also fails (MUL-2784 review) Addresses remaining PR #3444 review blocker (Elon): dirHasEntries used to return true when ReadDir failed with anything other than ENOENT, which made CleanupSidecars treat every locked / faulted directory as ENOTEMPTY and silently drop the original rmdir error. The v1 fix from the previous round closed the EACCES-on-empty-dir branch but missed the case where the chmod also blocks ReadDir — exactly the failure mode the review called out. Helper change: dirHasEntries now returns (hasEntries, ok bool): - (false, true) — dir exists and is empty (or missing, race-safe) - (true, true) — dir has user content (the ENOTEMPTY branch) - (_, false) — ReadDir failed (EACCES, ENOTDIR, EIO, …); the caller cannot tell ENOTEMPTY from a real error and MUST surface the original rmdir error CleanupSidecars switches on (ok, hasEntries): - !ok → surface the ORIGINAL rmdir error (not the ReadDir failure — that's diagnostic plumbing and would distract from the root cause) - ok && hasEntries → swallow silently (intended ENOTEMPTY branch; preserve user content) - ok && !hasEntries → surface the rmdir error (empty dir + EACCES / EPERM / EBUSY → genuine cleanup failure) Tests: - TestDirHasEntries: extended with a regular-file sub-case (ReadDir returns ENOTDIR) asserting (false, false). The v1 helper returned (true) here, hiding the bug. - TestCleanupSidecarsSwallowsMissingAndNonEmptyDirs: renamed from TestCleanupSidecarsSurfacesRealRmdirErrors. The old name claimed to test the surfacing path but never actually exercised it. - TestCleanupSidecarsSurfacesEACCESOnEmptyRecordedDir: chmod parent to 0o555 so rmdir(recorded) fails EACCES while ReadDir(recorded) still succeeds (empty). Asserts firstErr is non-nil and references both the recorded path and the rmdir branch. Skipped when running as root (chmod is bypassed for uid 0). - TestCleanupSidecarsSurfacesEACCESWhenReadDirFailsToo: the must-fix case — chmod parent 0o555 AND chmod recorded 0o000 so BOTH rmdir and ReadDir fail. The surfaced error must be the ORIGINAL rmdir failure, not the ReadDir one. Skipped on uid 0. Local verification: - go test ./internal/daemon/execenv/... — all green - go test ./internal/daemon/... — all green - go vet ./... — clean Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
1127 lines
41 KiB
Go
1127 lines
41 KiB
Go
package execenv
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// walkRelative returns every relative path inside root (files and directories),
|
|
// sorted, with a `dir/` suffix on directories so dir-vs-file mismatches show up
|
|
// in the diff. The root itself is reported as "." so a fully-empty directory
|
|
// still surfaces a non-empty fingerprint and an empty-vs-missing comparison
|
|
// fails loudly instead of looking identical.
|
|
func walkRelative(t *testing.T, root string) []string {
|
|
t.Helper()
|
|
var entries []string
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, relErr := filepath.Rel(root, path)
|
|
if relErr != nil {
|
|
return relErr
|
|
}
|
|
if rel == "." {
|
|
entries = append(entries, ".")
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
entries = append(entries, rel+string(os.PathSeparator))
|
|
return nil
|
|
}
|
|
entries = append(entries, rel)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk %s: %v", root, err)
|
|
}
|
|
sort.Strings(entries)
|
|
return entries
|
|
}
|
|
|
|
// snapshotWorkdir captures both the directory listing and the content of every
|
|
// regular file inside root, so a round-trip assertion can compare "exactly the
|
|
// same bytes everywhere" — not just "no orphan files survived". An empty map
|
|
// represents an empty workdir.
|
|
type workdirSnapshot struct {
|
|
entries []string
|
|
files map[string]string
|
|
}
|
|
|
|
func snapshot(t *testing.T, root string) workdirSnapshot {
|
|
t.Helper()
|
|
snap := workdirSnapshot{files: map[string]string{}}
|
|
snap.entries = walkRelative(t, root)
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
rel, relErr := filepath.Rel(root, path)
|
|
if relErr != nil {
|
|
return relErr
|
|
}
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
snap.files[rel] = string(data)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk-for-content %s: %v", root, err)
|
|
}
|
|
return snap
|
|
}
|
|
|
|
func assertSnapshotEqual(t *testing.T, label string, want, got workdirSnapshot) {
|
|
t.Helper()
|
|
if !reflect.DeepEqual(want.entries, got.entries) {
|
|
t.Errorf("[%s] directory listing differs\n want: %v\n got: %v", label, want.entries, got.entries)
|
|
}
|
|
if !reflect.DeepEqual(want.files, got.files) {
|
|
// Find a small diff first so the failure is actionable.
|
|
for k, wv := range want.files {
|
|
gv, ok := got.files[k]
|
|
if !ok {
|
|
t.Errorf("[%s] missing file %s after round-trip", label, k)
|
|
continue
|
|
}
|
|
if wv != gv {
|
|
t.Errorf("[%s] file %s differs\n want: %q\n got: %q", label, k, wv, gv)
|
|
}
|
|
}
|
|
for k := range got.files {
|
|
if _, ok := want.files[k]; !ok {
|
|
t.Errorf("[%s] orphan file %s after round-trip", label, k)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// runPrepareLikeCycle replays the daemon's local_directory path against the
|
|
// supplied workDir and envRoot: writes context files (with manifest tracking),
|
|
// injects the runtime brief, then runs the matching cleanups. Tests use this
|
|
// to assert byte-exact reversibility without booting the full Prepare/Reuse
|
|
// pipeline (which would need a WorkspacesRoot, GC plumbing, etc.).
|
|
func runPrepareLikeCycle(t *testing.T, workDir, envRoot, provider string, ctx TaskContextForEnv) {
|
|
t.Helper()
|
|
manifest := &sidecarManifest{}
|
|
if err := writeContextFiles(workDir, provider, ctx, manifest); err != nil {
|
|
t.Fatalf("writeContextFiles(%s): %v", provider, err)
|
|
}
|
|
if err := writeSidecarManifest(envRoot, manifest); err != nil {
|
|
t.Fatalf("writeSidecarManifest(%s): %v", provider, err)
|
|
}
|
|
if _, err := InjectRuntimeConfig(workDir, provider, ctx); err != nil {
|
|
t.Fatalf("InjectRuntimeConfig(%s): %v", provider, err)
|
|
}
|
|
// Mirror daemon.go ordering: runtime config first, sidecars second. The
|
|
// order is incidental — neither cleanup touches the other's paths — but
|
|
// pinning the same order in tests catches an accidental coupling.
|
|
if err := CleanupRuntimeConfig(workDir, provider); err != nil {
|
|
t.Fatalf("CleanupRuntimeConfig(%s): %v", provider, err)
|
|
}
|
|
if err := CleanupSidecars(envRoot); err != nil {
|
|
t.Fatalf("CleanupSidecars(%s): %v", provider, err)
|
|
}
|
|
}
|
|
|
|
// allFileBasedProviders lists every provider whose `writeContextFiles` /
|
|
// `InjectRuntimeConfig` writes into the user's workDir. Codex is included
|
|
// because it still writes AGENTS.md + .agent_context/ into the workdir (its
|
|
// skills live in codex-home, but that's not in workdir and is out of scope
|
|
// for this manifest). Adding a new provider that writes into workDir must
|
|
// also add it here so the round-trip invariant is enforced for it on day
|
|
// one — review the test diff before merging.
|
|
var allFileBasedProviders = []string{
|
|
"claude",
|
|
"codex",
|
|
"copilot",
|
|
"opencode",
|
|
"openclaw",
|
|
"hermes",
|
|
"pi",
|
|
"cursor",
|
|
"kimi",
|
|
"kiro",
|
|
"antigravity",
|
|
"gemini",
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsRoundTripEmptyWorkdir is the headline
|
|
// invariant the issue (MUL-2784) calls out: a user repo that contained
|
|
// nothing related to Multica before a task ran must contain nothing
|
|
// related to Multica after the task finishes — no .agent_context/,
|
|
// no .claude/skills/, no .multica/, no stub directories. The test
|
|
// runs the full Prepare → Inject → Cleanup cycle for every file-based
|
|
// provider against a fresh empty workdir and asserts the directory is
|
|
// byte-exactly empty again.
|
|
func TestPrepareThenCleanupSidecarsRoundTripEmptyWorkdir(t *testing.T) {
|
|
t.Parallel()
|
|
for _, provider := range allFileBasedProviders {
|
|
provider := provider
|
|
t.Run(provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{
|
|
Name: "Issue Review",
|
|
Description: "Review GH issues",
|
|
Content: "Steps to review",
|
|
Files: []SkillFileContextForEnv{
|
|
{Path: "templates/checklist.md", Content: "- [ ] check"},
|
|
},
|
|
},
|
|
{
|
|
Name: "PR Review",
|
|
Content: "Review PR diffs",
|
|
},
|
|
},
|
|
ProjectID: "proj-1",
|
|
ProjectTitle: "Demo",
|
|
}
|
|
|
|
runPrepareLikeCycle(t, workDir, envRoot, provider, ctx)
|
|
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, provider, before, after)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsPreservesUserSkillSibling pins the
|
|
// non-destructive contract from the issue: if the user already keeps a
|
|
// hand-authored skill under the same parent directory we use, Cleanup
|
|
// must leave it bit-for-bit intact. The user-skill payload is laid down
|
|
// BEFORE Prepare runs and snapshotted; after Cleanup the user's skill
|
|
// must still exist and the Multica-written sibling must be gone.
|
|
func TestPrepareThenCleanupSidecarsPreservesUserSkillSibling(t *testing.T) {
|
|
t.Parallel()
|
|
// One representative case per provider that writes into a
|
|
// provider-native skill directory. Gemini and Hermes don't have a
|
|
// native discovery path; they fall back to .agent_context/skills/,
|
|
// which is also covered (a user-created sibling under there should
|
|
// also survive). Codex is intentionally excluded — its workspace
|
|
// skills don't live in workdir, so the "user skill sibling"
|
|
// scenario doesn't apply.
|
|
cases := []struct {
|
|
provider string
|
|
userSkillRel string // path under workDir
|
|
userSkillFile string // path under userSkillRel
|
|
}{
|
|
{"claude", filepath.Join(".claude", "skills", "my-own"), "SKILL.md"},
|
|
{"copilot", filepath.Join(".github", "skills", "my-own"), "SKILL.md"},
|
|
{"opencode", filepath.Join(".opencode", "skills", "my-own"), "SKILL.md"},
|
|
{"openclaw", filepath.Join("skills", "my-own"), "SKILL.md"},
|
|
{"pi", filepath.Join(".pi", "skills", "my-own"), "SKILL.md"},
|
|
{"cursor", filepath.Join(".cursor", "skills", "my-own"), "SKILL.md"},
|
|
{"kimi", filepath.Join(".kimi", "skills", "my-own"), "SKILL.md"},
|
|
{"kiro", filepath.Join(".kiro", "skills", "my-own"), "SKILL.md"},
|
|
{"antigravity", filepath.Join(".agents", "skills", "my-own"), "SKILL.md"},
|
|
{"hermes", filepath.Join(".agent_context", "skills", "my-own"), "SKILL.md"},
|
|
{"gemini", filepath.Join(".agent_context", "skills", "my-own"), "SKILL.md"},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
userDir := filepath.Join(workDir, tc.userSkillRel)
|
|
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
|
t.Fatalf("seed user skill dir: %v", err)
|
|
}
|
|
userBody := "---\nname: my-own\n---\n\nUser-authored.\n"
|
|
if err := os.WriteFile(filepath.Join(userDir, tc.userSkillFile), []byte(userBody), 0o644); err != nil {
|
|
t.Fatalf("seed user skill file: %v", err)
|
|
}
|
|
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{Name: "Issue Review", Content: "ours"},
|
|
},
|
|
}
|
|
runPrepareLikeCycle(t, workDir, envRoot, tc.provider, ctx)
|
|
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, tc.provider, before, after)
|
|
|
|
// Defensive: independently re-read the user skill to make
|
|
// sure no clever cleanup heuristic stripped its content.
|
|
got, err := os.ReadFile(filepath.Join(userDir, tc.userSkillFile))
|
|
if err != nil {
|
|
t.Fatalf("user skill went missing after round-trip: %v", err)
|
|
}
|
|
if string(got) != userBody {
|
|
t.Errorf("user skill content changed\n want: %q\n got: %q", userBody, string(got))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsPreservesUnrelatedUserFiles covers the
|
|
// case where the user keeps a non-skill file under a parent we end up
|
|
// using — for example `.claude/config.json` next to where we drop
|
|
// `.claude/skills/`. Cleanup must rmdir only the directories it created;
|
|
// pre-existing siblings (and their parents) must survive.
|
|
func TestPrepareThenCleanupSidecarsPreservesUnrelatedUserFiles(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
provider string
|
|
userFile string // path under workDir
|
|
}{
|
|
{"claude", filepath.Join(".claude", "settings.json")},
|
|
{"copilot", filepath.Join(".github", "CODEOWNERS")},
|
|
{"opencode", filepath.Join(".opencode", "config.json")},
|
|
{"pi", filepath.Join(".pi", "config.toml")},
|
|
{"cursor", filepath.Join(".cursor", "settings.json")},
|
|
{"kimi", filepath.Join(".kimi", "config.json")},
|
|
{"kiro", filepath.Join(".kiro", "config.json")},
|
|
{"antigravity", filepath.Join(".agents", "config.json")},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
userPath := filepath.Join(workDir, tc.userFile)
|
|
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
|
t.Fatalf("seed user dir: %v", err)
|
|
}
|
|
userBody := "user content " + tc.provider
|
|
if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
|
|
t.Fatalf("seed user file: %v", err)
|
|
}
|
|
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{Name: "Issue Review", Content: "ours"},
|
|
},
|
|
}
|
|
runPrepareLikeCycle(t, workDir, envRoot, tc.provider, ctx)
|
|
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, tc.provider, before, after)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsRepeatedCycles guards against the
|
|
// failure mode the issue describes most explicitly — every run
|
|
// accumulates one more directory layer than the last. Running the cycle
|
|
// twice in a row must leave the workdir in the same state as running it
|
|
// once (which is the seed state), with the manifest correctly
|
|
// regenerated each cycle.
|
|
func TestPrepareThenCleanupSidecarsRepeatedCycles(t *testing.T) {
|
|
t.Parallel()
|
|
for _, provider := range allFileBasedProviders {
|
|
provider := provider
|
|
t.Run(provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{Name: "Issue Review", Content: "ours"},
|
|
},
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
runPrepareLikeCycle(t, workDir, envRoot, provider, ctx)
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, provider, before, after)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsWithProjectResources extends the
|
|
// round-trip to the .multica/project/resources.json branch — a separate
|
|
// sidecar write that creates its own intermediate directory tree.
|
|
func TestPrepareThenCleanupSidecarsWithProjectResources(t *testing.T) {
|
|
t.Parallel()
|
|
for _, provider := range allFileBasedProviders {
|
|
provider := provider
|
|
t.Run(provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
ProjectID: "proj-1",
|
|
ProjectTitle: "Demo project",
|
|
ProjectResources: []ProjectResourceForEnv{
|
|
{
|
|
ID: "res-1",
|
|
ResourceType: "github_repo",
|
|
ResourceRef: []byte(`{"url":"https://github.com/example/repo"}`),
|
|
},
|
|
},
|
|
}
|
|
runPrepareLikeCycle(t, workDir, envRoot, provider, ctx)
|
|
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, provider, before, after)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCleanupSidecarsNoOpWhenManifestMissing pins backward compatibility
|
|
// with envRoots that predate the manifest mechanism (older daemons, GC'd
|
|
// scratch dirs, fresh tempdirs). Cleanup must be a silent no-op rather
|
|
// than an error when there's nothing to clean.
|
|
func TestCleanupSidecarsNoOpWhenManifestMissing(t *testing.T) {
|
|
t.Parallel()
|
|
envRoot := t.TempDir()
|
|
if err := CleanupSidecars(envRoot); err != nil {
|
|
t.Errorf("CleanupSidecars on empty envRoot returned error: %v", err)
|
|
}
|
|
if err := CleanupSidecars(""); err != nil {
|
|
t.Errorf("CleanupSidecars with empty envRoot returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCleanupSidecarsLeavesUserContentInTrackedDirIntact is the
|
|
// directed unit test for the "non-empty rmdir is silently skipped"
|
|
// branch. We build a manifest by hand that claims ownership of a
|
|
// directory the user later populated; Cleanup must rmdir-skip and
|
|
// leave the user's payload in place without surfacing an error.
|
|
func TestCleanupSidecarsLeavesUserContentInTrackedDirIntact(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
// Imagine Prepare wrote .multica/sidecar.txt and created
|
|
// .multica/ + .multica/project/, then exited. Between Prepare
|
|
// and Cleanup the user dropped their own file under .multica/.
|
|
managedDir := filepath.Join(workDir, ".multica")
|
|
managedProject := filepath.Join(managedDir, "project")
|
|
managedFile := filepath.Join(managedProject, "resources.json")
|
|
if err := os.MkdirAll(managedProject, 0o755); err != nil {
|
|
t.Fatalf("seed dirs: %v", err)
|
|
}
|
|
if err := os.WriteFile(managedFile, []byte("{}"), 0o644); err != nil {
|
|
t.Fatalf("seed managed file: %v", err)
|
|
}
|
|
userFile := filepath.Join(managedDir, "user-notes.txt")
|
|
if err := os.WriteFile(userFile, []byte("hello"), 0o644); err != nil {
|
|
t.Fatalf("seed user file: %v", err)
|
|
}
|
|
|
|
manifest := &sidecarManifest{
|
|
Files: []string{managedFile},
|
|
Dirs: []string{managedDir, managedProject},
|
|
}
|
|
if err := writeSidecarManifest(envRoot, manifest); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
|
|
if err := CleanupSidecars(envRoot); err != nil {
|
|
t.Errorf("CleanupSidecars: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(managedFile); !os.IsNotExist(err) {
|
|
t.Errorf("managed file %s should be gone, stat err=%v", managedFile, err)
|
|
}
|
|
if _, err := os.Stat(managedProject); !os.IsNotExist(err) {
|
|
t.Errorf("inner managed dir %s should be empty and removed, stat err=%v", managedProject, err)
|
|
}
|
|
// .multica still holds user-notes.txt, so rmdir must have been
|
|
// skipped silently — the directory must survive.
|
|
got, err := os.ReadFile(userFile)
|
|
if err != nil {
|
|
t.Fatalf("user file went missing: %v", err)
|
|
}
|
|
if string(got) != "hello" {
|
|
t.Errorf("user file content changed: %q", string(got))
|
|
}
|
|
}
|
|
|
|
// TestCleanupSidecarsDoesNotRemovePreExistingDirs is the directed unit
|
|
// test for the "skip recording pre-existing ancestors" branch in
|
|
// recordMkdirAll. A directory the user owned before Prepare must NOT
|
|
// appear in the manifest, and therefore must NOT be eligible for rmdir
|
|
// during Cleanup — even if Cleanup runs after the user removed the
|
|
// last file from inside it.
|
|
func TestCleanupSidecarsDoesNotRemovePreExistingDirs(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
userDir := filepath.Join(workDir, ".claude")
|
|
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
|
t.Fatalf("seed user dir: %v", err)
|
|
}
|
|
|
|
manifest := &sidecarManifest{}
|
|
target := filepath.Join(userDir, "skills", "ours")
|
|
if err := recordMkdirAll(target, 0o755, manifest); err != nil {
|
|
t.Fatalf("recordMkdirAll: %v", err)
|
|
}
|
|
for _, d := range manifest.Dirs {
|
|
if d == userDir {
|
|
t.Fatalf("manifest must not record pre-existing user dir %s\nfull dirs: %v", userDir, manifest.Dirs)
|
|
}
|
|
}
|
|
|
|
if err := writeSidecarManifest(envRoot, manifest); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
if err := CleanupSidecars(envRoot); err != nil {
|
|
t.Fatalf("CleanupSidecars: %v", err)
|
|
}
|
|
if _, err := os.Stat(userDir); err != nil {
|
|
t.Errorf("pre-existing user dir %s removed by cleanup: %v", userDir, err)
|
|
}
|
|
}
|
|
|
|
// TestRecordWriteFileRefusesToOverwritePreExistingFile pins the
|
|
// invariant the PR #3444 review identified as must-fix: a pre-existing
|
|
// path is user-owned (or stale state we can't safely distinguish from
|
|
// user-owned), and recordWriteFile MUST refuse to mutate it. The
|
|
// function returns errPathPreExists, the file's bytes survive
|
|
// untouched, and nothing is added to the manifest.
|
|
//
|
|
// The previous behaviour — overwrite at write time, then skip the
|
|
// manifest record so Cleanup wouldn't undo the damage — destroyed
|
|
// user data and called it preservation.
|
|
func TestRecordWriteFileRefusesToOverwritePreExistingFile(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "user.md")
|
|
if err := os.WriteFile(target, []byte("user bytes"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
m := &sidecarManifest{}
|
|
err := recordWriteFile(target, []byte("ours"), 0o644, m)
|
|
if !errors.Is(err, errPathPreExists) {
|
|
t.Fatalf("recordWriteFile must return errPathPreExists for a pre-existing target, got: %v", err)
|
|
}
|
|
for _, f := range m.Files {
|
|
if f == target {
|
|
t.Errorf("manifest must not record pre-existing user file %s", target)
|
|
}
|
|
}
|
|
// User bytes must survive the refused write — the whole point of
|
|
// the new behaviour is that pre-existing paths are NEVER mutated.
|
|
got, err := os.ReadFile(target)
|
|
if err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if string(got) != "user bytes" {
|
|
t.Errorf("user bytes must survive refused write\n want: %q\n got: %q", "user bytes", string(got))
|
|
}
|
|
}
|
|
|
|
// TestRecordWriteFileRefusesToOverwriteSymlinkOrDir is the directed
|
|
// edge-case companion: any kind of pre-existing entry at the target
|
|
// path — symlink or directory, not just a regular file — counts as
|
|
// user-owned. We use Lstat to detect symlinks (so a symlink to a
|
|
// regular file doesn't accidentally pass the existence check via
|
|
// Stat-follows-symlinks semantics) and bail out the same way.
|
|
func TestRecordWriteFileRefusesToOverwriteSymlinkOrDir(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
// Pre-existing symlink (dangling — points nowhere). Should still
|
|
// count as "occupied" so we don't accidentally create a file the
|
|
// symlink would then expose at an unexpected target.
|
|
symlinkPath := filepath.Join(dir, "symlink.md")
|
|
if err := os.Symlink(filepath.Join(dir, "does-not-exist"), symlinkPath); err != nil {
|
|
t.Skipf("symlink not supported on this platform: %v", err)
|
|
}
|
|
if err := recordWriteFile(symlinkPath, []byte("ours"), 0o644, &sidecarManifest{}); !errors.Is(err, errPathPreExists) {
|
|
t.Errorf("recordWriteFile on dangling symlink should refuse, got: %v", err)
|
|
}
|
|
|
|
// Pre-existing directory at the file path — should also refuse.
|
|
dirPath := filepath.Join(dir, "subdir")
|
|
if err := os.Mkdir(dirPath, 0o755); err != nil {
|
|
t.Fatalf("seed dir: %v", err)
|
|
}
|
|
if err := recordWriteFile(dirPath, []byte("ours"), 0o644, &sidecarManifest{}); !errors.Is(err, errPathPreExists) {
|
|
t.Errorf("recordWriteFile on pre-existing directory should refuse, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSidecarManifestRoundTripJSON pins the on-disk encoding so a
|
|
// future field rename or json-tag change doesn't silently break Cleanup
|
|
// for envRoots that carry an in-flight manifest at the moment of an
|
|
// upgrade.
|
|
func TestSidecarManifestRoundTripJSON(t *testing.T) {
|
|
t.Parallel()
|
|
envRoot := t.TempDir()
|
|
original := &sidecarManifest{
|
|
Files: []string{"/x/.agent_context/issue_context.md"},
|
|
Dirs: []string{"/x/.agent_context"},
|
|
}
|
|
if err := writeSidecarManifest(envRoot, original); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(envRoot, sidecarManifestFile))
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
for _, want := range []string{"files", "dirs", "issue_context.md", ".agent_context"} {
|
|
if !strings.Contains(string(raw), want) {
|
|
t.Errorf("manifest JSON missing %q\n got: %s", want, string(raw))
|
|
}
|
|
}
|
|
}
|
|
|
|
// sameSlugSkillProviderCases lists every file-based provider that
|
|
// writes per-skill subdirectories under workDir, paired with the
|
|
// natural skill path (relative to workDir) that a user-installed
|
|
// skill named "issue-review" would occupy. The same-slug collision
|
|
// matrix below replays the user-skill-already-present scenario per
|
|
// provider and asserts byte-exact round-trip — including that the
|
|
// user's SKILL.md bytes survive the task untouched and our
|
|
// collision-free sibling (which lives under .../issue-review-multica/)
|
|
// is fully cleaned up.
|
|
//
|
|
// Codex skills live under codex-home (not workdir), so the per-skill
|
|
// collision branch doesn't apply to it. Gemini falls back to
|
|
// .agent_context/skills/ same as the default; Hermes goes there too.
|
|
var sameSlugSkillProviderCases = []struct {
|
|
provider string
|
|
skillDir string // relative path under workDir for the colliding slug
|
|
}{
|
|
{"claude", filepath.Join(".claude", "skills", "issue-review")},
|
|
{"copilot", filepath.Join(".github", "skills", "issue-review")},
|
|
{"opencode", filepath.Join(".opencode", "skills", "issue-review")},
|
|
{"openclaw", filepath.Join("skills", "issue-review")},
|
|
{"pi", filepath.Join(".pi", "skills", "issue-review")},
|
|
{"cursor", filepath.Join(".cursor", "skills", "issue-review")},
|
|
{"kimi", filepath.Join(".kimi", "skills", "issue-review")},
|
|
{"kiro", filepath.Join(".kiro", "skills", "issue-review")},
|
|
{"antigravity", filepath.Join(".agents", "skills", "issue-review")},
|
|
{"hermes", filepath.Join(".agent_context", "skills", "issue-review")},
|
|
{"gemini", filepath.Join(".agent_context", "skills", "issue-review")},
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsSameSlugCollisionPerProvider is the
|
|
// must-fix byte-exact matrix the PR #3444 review required: per
|
|
// provider, seed a user skill at the exact slug Multica would use
|
|
// (`.claude/skills/issue-review/SKILL.md` etc.), run the full
|
|
// Prepare → Inject → Cleanup cycle with a Multica skill of the same
|
|
// name, and assert the workdir snapshot is byte-identical to the
|
|
// seed. The user's SKILL.md must not be touched, and the Multica
|
|
// sibling (which lives at `<slug>-multica`) must be fully removed by
|
|
// CleanupSidecars.
|
|
func TestPrepareThenCleanupSidecarsSameSlugCollisionPerProvider(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range sameSlugSkillProviderCases {
|
|
tc := tc
|
|
t.Run(tc.provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
userSkillDir := filepath.Join(workDir, tc.skillDir)
|
|
if err := os.MkdirAll(userSkillDir, 0o755); err != nil {
|
|
t.Fatalf("seed user skill dir: %v", err)
|
|
}
|
|
userBody := "---\nname: issue-review\ndescription: user-authored\n---\n\nUser owns this slug.\n"
|
|
userSkillFile := filepath.Join(userSkillDir, "SKILL.md")
|
|
if err := os.WriteFile(userSkillFile, []byte(userBody), 0o644); err != nil {
|
|
t.Fatalf("seed user SKILL.md: %v", err)
|
|
}
|
|
// A second user-authored file under the same skill dir
|
|
// — exercises the case where the user has more than
|
|
// just SKILL.md in their skill (templates, scripts).
|
|
userExtra := filepath.Join(userSkillDir, "notes.md")
|
|
if err := os.WriteFile(userExtra, []byte("private notes"), 0o644); err != nil {
|
|
t.Fatalf("seed user extra file: %v", err)
|
|
}
|
|
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{
|
|
Name: "Issue Review",
|
|
Description: "Multica's version",
|
|
Content: "---\nname: issue-review\n---\n\nMultica skill content.\n",
|
|
Files: []SkillFileContextForEnv{
|
|
{Path: "templates/checklist.md", Content: "- [ ] check"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
runPrepareLikeCycle(t, workDir, envRoot, tc.provider, ctx)
|
|
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, tc.provider, before, after)
|
|
|
|
// Defensive double-check: read the user's files
|
|
// directly to make sure their content survived.
|
|
gotBody, err := os.ReadFile(userSkillFile)
|
|
if err != nil {
|
|
t.Fatalf("user SKILL.md went missing: %v", err)
|
|
}
|
|
if string(gotBody) != userBody {
|
|
t.Errorf("user SKILL.md mutated\n want: %q\n got: %q", userBody, string(gotBody))
|
|
}
|
|
gotExtra, err := os.ReadFile(userExtra)
|
|
if err != nil {
|
|
t.Fatalf("user extra file went missing: %v", err)
|
|
}
|
|
if string(gotExtra) != "private notes" {
|
|
t.Errorf("user extra file mutated\n want: %q\n got: %q", "private notes", string(gotExtra))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsIssueContextCollisionPerProvider is
|
|
// the matching byte-exact matrix for `.agent_context/issue_context.md`
|
|
// — a Multica-only namespace file. If the user already has a file at
|
|
// that path, the writer must refuse to overwrite it (the runtime
|
|
// brief carries the same facts anyway) and CleanupSidecars must
|
|
// leave the user's file alone. This covers EVERY file-based provider
|
|
// (including Codex, whose skills don't live in workdir but whose
|
|
// .agent_context/ write goes through the same path).
|
|
func TestPrepareThenCleanupSidecarsIssueContextCollisionPerProvider(t *testing.T) {
|
|
t.Parallel()
|
|
for _, provider := range allFileBasedProviders {
|
|
provider := provider
|
|
t.Run(provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
if err := os.MkdirAll(filepath.Join(workDir, ".agent_context"), 0o755); err != nil {
|
|
t.Fatalf("seed dir: %v", err)
|
|
}
|
|
userBody := "# user-authored issue_context.md\n\nDo not touch.\n"
|
|
userPath := filepath.Join(workDir, ".agent_context", "issue_context.md")
|
|
if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
|
|
t.Fatalf("seed user file: %v", err)
|
|
}
|
|
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
}
|
|
runPrepareLikeCycle(t, workDir, envRoot, provider, ctx)
|
|
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, provider, before, after)
|
|
|
|
got, err := os.ReadFile(userPath)
|
|
if err != nil {
|
|
t.Fatalf("user issue_context.md went missing: %v", err)
|
|
}
|
|
if string(got) != userBody {
|
|
t.Errorf("user issue_context.md mutated\n want: %q\n got: %q", userBody, string(got))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsProjectResourcesCollisionPerProvider
|
|
// is the matching byte-exact matrix for `.multica/project/
|
|
// resources.json` — the other Multica-only namespace file. Same
|
|
// invariant: pre-existing user content survives the round-trip
|
|
// untouched even when the task ships project resources of its own.
|
|
func TestPrepareThenCleanupSidecarsProjectResourcesCollisionPerProvider(t *testing.T) {
|
|
t.Parallel()
|
|
for _, provider := range allFileBasedProviders {
|
|
provider := provider
|
|
t.Run(provider, func(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
if err := os.MkdirAll(filepath.Join(workDir, ".multica", "project"), 0o755); err != nil {
|
|
t.Fatalf("seed dir: %v", err)
|
|
}
|
|
userBody := `{"user":"owns this file"}`
|
|
userPath := filepath.Join(workDir, ".multica", "project", "resources.json")
|
|
if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
|
|
t.Fatalf("seed user file: %v", err)
|
|
}
|
|
|
|
before := snapshot(t, workDir)
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
ProjectID: "proj-1",
|
|
ProjectTitle: "Demo",
|
|
ProjectResources: []ProjectResourceForEnv{
|
|
{
|
|
ID: "res-1",
|
|
ResourceType: "github_repo",
|
|
ResourceRef: []byte(`{"url":"https://github.com/example/repo"}`),
|
|
},
|
|
},
|
|
}
|
|
runPrepareLikeCycle(t, workDir, envRoot, provider, ctx)
|
|
|
|
after := snapshot(t, workDir)
|
|
assertSnapshotEqual(t, provider, before, after)
|
|
|
|
got, err := os.ReadFile(userPath)
|
|
if err != nil {
|
|
t.Fatalf("user resources.json went missing: %v", err)
|
|
}
|
|
if string(got) != userBody {
|
|
t.Errorf("user resources.json mutated\n want: %q\n got: %q", userBody, string(got))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAllocateCollisionFreeSkillDir pins the slug-suffix policy:
|
|
// first try the natural slug, then `-multica`, then `-multica-2`,
|
|
// `-multica-3`, … The PR-review concern is "Multica skill must still
|
|
// be discoverable" — this test demonstrates that we pick a sibling
|
|
// path under the same skillsParent rather than dropping the skill or
|
|
// nesting it under the user's directory.
|
|
func TestAllocateCollisionFreeSkillDir(t *testing.T) {
|
|
t.Parallel()
|
|
parent := t.TempDir()
|
|
|
|
// 1) No collision → use the base slug as-is.
|
|
slug, dir, err := allocateCollisionFreeSkillDir(parent, "issue-review")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if slug != "issue-review" {
|
|
t.Errorf("first allocation should use base slug; got %q", slug)
|
|
}
|
|
if dir != filepath.Join(parent, "issue-review") {
|
|
t.Errorf("first allocation path = %q, want under parent", dir)
|
|
}
|
|
|
|
// 2) Pre-existing user dir at the base slug → bump to `-multica`.
|
|
if err := os.MkdirAll(filepath.Join(parent, "issue-review"), 0o755); err != nil {
|
|
t.Fatalf("seed user dir: %v", err)
|
|
}
|
|
slug, dir, err = allocateCollisionFreeSkillDir(parent, "issue-review")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if slug != "issue-review-multica" {
|
|
t.Errorf("second allocation should bump to `-multica`; got %q", slug)
|
|
}
|
|
if dir != filepath.Join(parent, "issue-review-multica") {
|
|
t.Errorf("second allocation path = %q, want under parent", dir)
|
|
}
|
|
|
|
// 3) Pre-existing collision at the bumped slug too → bump again.
|
|
if err := os.MkdirAll(filepath.Join(parent, "issue-review-multica"), 0o755); err != nil {
|
|
t.Fatalf("seed bumped dir: %v", err)
|
|
}
|
|
slug, dir, err = allocateCollisionFreeSkillDir(parent, "issue-review")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if slug != "issue-review-multica-2" {
|
|
t.Errorf("third allocation should be `-multica-2`; got %q", slug)
|
|
}
|
|
if dir != filepath.Join(parent, "issue-review-multica-2") {
|
|
t.Errorf("third allocation path = %q, want under parent", dir)
|
|
}
|
|
}
|
|
|
|
// TestPrepareThenCleanupSidecarsMultiSkillCollisionFreeAllocation is
|
|
// the end-to-end coverage for the collision-free sibling: a user has
|
|
// `.claude/skills/issue-review/SKILL.md`, the task ships an
|
|
// `Issue Review` skill, the Multica sibling must land at a different
|
|
// slug (so the agent still sees it), AND Cleanup must remove the
|
|
// Multica sibling entirely without touching the user's.
|
|
func TestPrepareThenCleanupSidecarsMultiSkillCollisionFreeAllocation(t *testing.T) {
|
|
t.Parallel()
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
userDir := filepath.Join(workDir, ".claude", "skills", "issue-review")
|
|
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
userBody := "user-authored skill\n"
|
|
userFile := filepath.Join(userDir, "SKILL.md")
|
|
if err := os.WriteFile(userFile, []byte(userBody), 0o644); err != nil {
|
|
t.Fatalf("seed file: %v", err)
|
|
}
|
|
|
|
// Run only the inject side first — verify the Multica skill
|
|
// landed at a NEW path under the same parent, AND the user's
|
|
// path is untouched.
|
|
manifest := &sidecarManifest{}
|
|
if err := writeContextFiles(workDir, "claude", TaskContextForEnv{
|
|
IssueID: "11111111-2222-3333-4444-555555555555",
|
|
AgentSkills: []SkillContextForEnv{
|
|
{Name: "Issue Review", Content: "Multica's version\n"},
|
|
},
|
|
}, manifest); err != nil {
|
|
t.Fatalf("writeContextFiles: %v", err)
|
|
}
|
|
|
|
multicaDir := filepath.Join(workDir, ".claude", "skills", "issue-review-multica")
|
|
if _, err := os.Stat(filepath.Join(multicaDir, "SKILL.md")); err != nil {
|
|
t.Errorf("Multica sibling skill should exist at %s: %v", multicaDir, err)
|
|
}
|
|
got, err := os.ReadFile(userFile)
|
|
if err != nil {
|
|
t.Fatalf("user SKILL.md went missing during inject: %v", err)
|
|
}
|
|
if string(got) != userBody {
|
|
t.Errorf("user SKILL.md mutated during inject\n want: %q\n got: %q", userBody, string(got))
|
|
}
|
|
|
|
// Now persist manifest + run cleanup. After cleanup the
|
|
// Multica sibling is gone; user's path survives.
|
|
if err := writeSidecarManifest(envRoot, manifest); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
if err := CleanupSidecars(envRoot); err != nil {
|
|
t.Fatalf("CleanupSidecars: %v", err)
|
|
}
|
|
if _, err := os.Stat(multicaDir); !os.IsNotExist(err) {
|
|
t.Errorf("Multica sibling should be removed by Cleanup; stat err=%v", err)
|
|
}
|
|
got, err = os.ReadFile(userFile)
|
|
if err != nil {
|
|
t.Fatalf("user SKILL.md went missing after cleanup: %v", err)
|
|
}
|
|
if string(got) != userBody {
|
|
t.Errorf("user SKILL.md mutated after cleanup\n want: %q\n got: %q", userBody, string(got))
|
|
}
|
|
}
|
|
|
|
// TestCleanupSidecarsSwallowsMissingAndNonEmptyDirs pins the two
|
|
// branches that CleanupSidecars is expected to silently skip without
|
|
// surfacing an error:
|
|
//
|
|
// - the recorded directory no longer exists (race-safe ENOENT)
|
|
// - the recorded directory has been populated with user content
|
|
// (ENOTEMPTY — preserving user content is the whole point)
|
|
func TestCleanupSidecarsSwallowsMissingAndNonEmptyDirs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Case 1: recorded dir is missing → ENOENT must be swallowed.
|
|
envRoot1 := t.TempDir()
|
|
missing := filepath.Join(t.TempDir(), "never-existed")
|
|
if err := writeSidecarManifest(envRoot1, &sidecarManifest{Dirs: []string{missing}}); err != nil {
|
|
t.Fatalf("write missing-dir manifest: %v", err)
|
|
}
|
|
if err := CleanupSidecars(envRoot1); err != nil {
|
|
t.Errorf("CleanupSidecars(missing dir) should swallow ENOENT silently, got: %v", err)
|
|
}
|
|
|
|
// Case 2: recorded dir has user content → ENOTEMPTY must be
|
|
// swallowed and the user content must survive.
|
|
envRoot2 := t.TempDir()
|
|
workDir2 := t.TempDir()
|
|
recordedDir := filepath.Join(workDir2, "recorded")
|
|
if err := os.MkdirAll(recordedDir, 0o755); err != nil {
|
|
t.Fatalf("seed recorded dir: %v", err)
|
|
}
|
|
userFile := filepath.Join(recordedDir, "user.txt")
|
|
if err := os.WriteFile(userFile, []byte("user content"), 0o644); err != nil {
|
|
t.Fatalf("seed user file: %v", err)
|
|
}
|
|
if err := writeSidecarManifest(envRoot2, &sidecarManifest{Dirs: []string{recordedDir}}); err != nil {
|
|
t.Fatalf("write non-empty-dir manifest: %v", err)
|
|
}
|
|
if err := CleanupSidecars(envRoot2); err != nil {
|
|
t.Errorf("CleanupSidecars(non-empty dir) should swallow ENOTEMPTY silently, got: %v", err)
|
|
}
|
|
got, err := os.ReadFile(userFile)
|
|
if err != nil {
|
|
t.Fatalf("user content went missing: %v", err)
|
|
}
|
|
if string(got) != "user content" {
|
|
t.Errorf("user content mutated: %q", string(got))
|
|
}
|
|
}
|
|
|
|
// TestCleanupSidecarsSurfacesEACCESOnEmptyRecordedDir is the directed
|
|
// integration test for the should-fix branch: rmdir fails (here with
|
|
// EACCES because the parent is read-only), the directory is verifiably
|
|
// empty, and CleanupSidecars must surface the original rmdir error
|
|
// instead of silently skipping it.
|
|
//
|
|
// Skipped when running as root because chmod is bypassed for uid 0 —
|
|
// the EACCES we want to trigger never materialises. CI's daemon runner
|
|
// is unprivileged, so the branch is exercised in CI.
|
|
func TestCleanupSidecarsSurfacesEACCESOnEmptyRecordedDir(t *testing.T) {
|
|
t.Parallel()
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("chmod is bypassed for uid 0; cannot synthesize EACCES on rmdir")
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
parent := filepath.Join(workDir, "parent")
|
|
recorded := filepath.Join(parent, "empty-dir")
|
|
if err := os.MkdirAll(recorded, 0o755); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := writeSidecarManifest(envRoot, &sidecarManifest{Dirs: []string{recorded}}); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
|
|
// Strip write from parent so rmdir(recorded) fails EACCES.
|
|
// rmdir requires write on the PARENT (it modifies parent's
|
|
// directory entries); readdir(recorded) only requires read on
|
|
// recorded itself, which we leave intact. That isolates this
|
|
// test to the "empty + rmdir refused" branch.
|
|
if err := os.Chmod(parent, 0o555); err != nil {
|
|
t.Fatalf("chmod parent: %v", err)
|
|
}
|
|
// Restore parent permissions for t.TempDir() teardown.
|
|
t.Cleanup(func() { _ = os.Chmod(parent, 0o755) })
|
|
|
|
err := CleanupSidecars(envRoot)
|
|
if err == nil {
|
|
t.Fatal("CleanupSidecars should surface the EACCES rmdir error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty-dir") {
|
|
t.Errorf("expected surfaced error to reference recorded path, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "rmdir") {
|
|
t.Errorf("expected surfaced error to come from rmdir branch, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCleanupSidecarsSurfacesEACCESWhenReadDirFailsToo is the matching
|
|
// test for the must-fix branch: rmdir fails AND the post-rmdir ReadDir
|
|
// also fails. CleanupSidecars must preserve the ORIGINAL rmdir error
|
|
// rather than swallowing it because dirHasEntries couldn't read the
|
|
// directory. This is the exact failure mode PR #3444 review surfaced
|
|
// — the v1 helper returned (true) on ReadDir error, which made
|
|
// CleanupSidecars treat the locked dir as "non-empty" and silently
|
|
// drop the rmdir EACCES.
|
|
//
|
|
// Skipped when running as root for the same reason as above.
|
|
func TestCleanupSidecarsSurfacesEACCESWhenReadDirFailsToo(t *testing.T) {
|
|
t.Parallel()
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("chmod is bypassed for uid 0; cannot synthesize EACCES on rmdir + readdir")
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
envRoot := t.TempDir()
|
|
|
|
parent := filepath.Join(workDir, "parent")
|
|
recorded := filepath.Join(parent, "locked-dir")
|
|
if err := os.MkdirAll(recorded, 0o755); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := writeSidecarManifest(envRoot, &sidecarManifest{Dirs: []string{recorded}}); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
|
|
// Strip both: read on recorded so ReadDir(recorded) fails EACCES,
|
|
// write on parent so rmdir(recorded) also fails EACCES. The
|
|
// helper must report ok=false; CleanupSidecars must surface the
|
|
// rmdir error anyway.
|
|
if err := os.Chmod(recorded, 0o000); err != nil {
|
|
t.Fatalf("chmod recorded: %v", err)
|
|
}
|
|
if err := os.Chmod(parent, 0o555); err != nil {
|
|
_ = os.Chmod(recorded, 0o755)
|
|
t.Fatalf("chmod parent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = os.Chmod(parent, 0o755)
|
|
_ = os.Chmod(recorded, 0o755)
|
|
})
|
|
|
|
err := CleanupSidecars(envRoot)
|
|
if err == nil {
|
|
t.Fatal("CleanupSidecars should surface the rmdir error even when ReadDir also fails, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "locked-dir") {
|
|
t.Errorf("expected surfaced error to reference recorded path, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "rmdir") {
|
|
t.Errorf("expected surfaced error to be the ORIGINAL rmdir error, not the ReadDir failure, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDirHasEntries is the directed unit test for the helper Cleanup
|
|
// uses to tell ENOTEMPTY (silently skip) apart from genuine rmdir
|
|
// errors (surface). The helper returns (hasEntries, ok); CleanupSidecars
|
|
// surfaces the original rmdir error whenever ok=false so a ReadDir
|
|
// failure on a chmod'd / not-a-directory / faulted path doesn't get
|
|
// laundered into a phantom "non-empty directory" skip.
|
|
func TestDirHasEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
|
|
empty := filepath.Join(root, "empty")
|
|
if err := os.Mkdir(empty, 0o755); err != nil {
|
|
t.Fatalf("seed empty: %v", err)
|
|
}
|
|
if has, ok := dirHasEntries(empty); !ok || has {
|
|
t.Errorf("dirHasEntries(empty dir) = (%v, %v), want (false, true)", has, ok)
|
|
}
|
|
|
|
full := filepath.Join(root, "full")
|
|
if err := os.Mkdir(full, 0o755); err != nil {
|
|
t.Fatalf("seed full: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(full, "file"), []byte(""), 0o644); err != nil {
|
|
t.Fatalf("seed full content: %v", err)
|
|
}
|
|
if has, ok := dirHasEntries(full); !ok || !has {
|
|
t.Errorf("dirHasEntries(non-empty dir) = (%v, %v), want (true, true)", has, ok)
|
|
}
|
|
|
|
missing := filepath.Join(root, "missing")
|
|
if has, ok := dirHasEntries(missing); !ok || has {
|
|
t.Errorf("dirHasEntries(missing dir) = (%v, %v), want (false, true) — ENOENT collapses to empty so the rmdir-race resolves cleanly", has, ok)
|
|
}
|
|
|
|
// ReadDir failure path: pass a regular file. ReadDir returns
|
|
// ENOTDIR, which is NOT ENOENT, so the helper must report
|
|
// (false, false) — "couldn't tell" — and let CleanupSidecars
|
|
// surface the underlying rmdir error rather than silently
|
|
// skipping. This is the must-fix branch PR #3444 review caught:
|
|
// the v1 helper returned (true) here, which made every ReadDir
|
|
// failure look like ENOTEMPTY.
|
|
regular := filepath.Join(root, "regular.txt")
|
|
if err := os.WriteFile(regular, []byte("not a dir"), 0o644); err != nil {
|
|
t.Fatalf("seed regular: %v", err)
|
|
}
|
|
if has, ok := dirHasEntries(regular); ok || has {
|
|
t.Errorf("dirHasEntries(regular file) = (%v, %v), want (false, false) — ENOTDIR must NOT be laundered as ENOTEMPTY", has, ok)
|
|
}
|
|
}
|