mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 09:56:47 +08:00
* fix(daemon): link user Codex skills into codex-home instead of copying them Every Codex task got a full recursive copy of ~/.codex/skills/ inside its per-task CODEX_HOME. For a user with npm-backed skills that is ~100 MB per task directory, re-copied on every task start — hydrateCodexSkills wipes and re-seeds on both the Prepare and the Reuse path — and never reclaimed: the GC artifact sweep only matches node_modules / .next / .turbo, and whole-directory reclaim needs the issue to reach a terminal status first. Total cost scales with the number of live task directories. Link instead. The same per-task home already links its other two shared resources, sessions and the plugin cache, and a skill directory is read-only input to the CLI, so the write isolation a copy incidentally bought has no requester. EvalSymlinks still runs first, so an installer-shipped symlink (lark-cli into ~/.agents/skills/) resolves to the real directory rather than producing a link to a link. The three paths that could reach through the new link are unchanged in behavior: os.RemoveAll deletes a link and not its target, writeSkillFiles probes with os.Lstat so a workspace skill colliding with a linked user skill lands in a `-multica` sibling, and the GC refuses to walk into links. Tests pin all three, plus a windows-tagged test for the junction form of the link. Co-authored-by: multica-agent <[email protected]> * fix(daemon): stop GC walks from descending into Windows directory junctions The GC's artifact sweep and its size accounting both refuse to follow links out of a task directory, but they tested for os.ModeSymlink alone. That misses the link shape Windows actually produces: createDirLink falls back to a directory junction (mklink /J) whenever os.Symlink is denied, which is the default without Developer Mode, and since Go 1.23 os.Lstat reports a junction as ModeDir|ModeIrregular with no ModeSymlink bit while its DirEntry still answers IsDir() == true. filepath.WalkDir therefore descends straight into the link target, and the sweep would delete a node_modules / .next / .turbo directory that lives inside the user's real home. Match both mode bits in all three walks. The task directory links user content in three places today — the Codex session store, the plugin cache, and now the user's skills — so this is reachable on any Windows host running Codex tasks. Windows-tagged tests create the junction with mklink /J directly, so the junction shape is covered even on a runner where symlinks are permitted, and they run in the existing windows-execenv CI job. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
221 lines
8.0 KiB
Go
221 lines
8.0 KiB
Go
package execenv
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
// User skills reach the per-task CODEX_HOME as links, not copies (MUL-6000).
|
|
// Copying charged every task directory the full skill tree — ~100 MB for a
|
|
// user with npm-backed skills — re-paid on every task start and never
|
|
// reclaimed while the issue stayed open.
|
|
|
|
func writeUserSkill(t *testing.T, dir, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", dir, err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write SKILL.md in %s: %v", dir, err)
|
|
}
|
|
}
|
|
|
|
// assertIsLink fails when path is a real directory rather than a link to one.
|
|
// A POSIX symlink reports ModeSymlink; a Windows junction reports
|
|
// ModeDir|ModeIrregular (Go 1.23+), which is why the mask covers both.
|
|
func assertIsLink(t *testing.T, path string) {
|
|
t.Helper()
|
|
fi, err := os.Lstat(path)
|
|
if err != nil {
|
|
t.Fatalf("lstat %s: %v", path, err)
|
|
}
|
|
if fi.Mode()&(os.ModeSymlink|os.ModeIrregular) == 0 {
|
|
t.Fatalf("%s is a real directory (mode %v), want a link", path, fi.Mode())
|
|
}
|
|
}
|
|
|
|
func TestSeedUserCodexSkillsLinksInsteadOfCopying(t *testing.T) {
|
|
// Cannot use t.Parallel() with t.Setenv.
|
|
sharedHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
userSkill := filepath.Join(sharedHome, "skills", "alpha")
|
|
writeUserSkill(t, userSkill, "alpha v1")
|
|
|
|
codexHome := t.TempDir()
|
|
if err := seedUserCodexSkills(codexHome, nil, testLogger()); err != nil {
|
|
t.Fatalf("seedUserCodexSkills: %v", err)
|
|
}
|
|
|
|
dst := filepath.Join(codexHome, "skills", "alpha")
|
|
assertIsLink(t, dst)
|
|
|
|
body, err := os.ReadFile(filepath.Join(dst, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("read seeded SKILL.md: %v", err)
|
|
}
|
|
if string(body) != "alpha v1" {
|
|
t.Errorf("seeded SKILL.md = %q, want %q", body, "alpha v1")
|
|
}
|
|
|
|
// The decisive proof it is not a copy: content added to the user's real
|
|
// skill directory after seeding is visible through the per-task path.
|
|
if err := os.WriteFile(filepath.Join(userSkill, "reference.md"), []byte("added later"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dst, "reference.md")); err != nil {
|
|
t.Errorf("per-task skill dir is a snapshot copy, not a link: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSeedUserCodexSkillsResolvesInstallerSymlink(t *testing.T) {
|
|
// Installers like lark-cli put a symlink in ~/.codex/skills/<name>
|
|
// pointing into a shared ~/.agents/skills/<name>/.
|
|
agentsRoot := t.TempDir()
|
|
realSkill := filepath.Join(agentsRoot, "lark-base")
|
|
writeUserSkill(t, realSkill, "lark-base v1")
|
|
|
|
sharedHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
if err := os.MkdirAll(filepath.Join(sharedHome, "skills"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
installerLink := filepath.Join(sharedHome, "skills", "lark-base")
|
|
if err := os.Symlink(realSkill, installerLink); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
|
|
codexHome := t.TempDir()
|
|
if err := seedUserCodexSkills(codexHome, nil, testLogger()); err != nil {
|
|
t.Fatalf("seedUserCodexSkills: %v", err)
|
|
}
|
|
|
|
dst := filepath.Join(codexHome, "skills", "lark-base")
|
|
assertIsLink(t, dst)
|
|
body, err := os.ReadFile(filepath.Join(dst, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("symlink-installed skill unreadable through the per-task link: %v", err)
|
|
}
|
|
if string(body) != "lark-base v1" {
|
|
t.Errorf("SKILL.md = %q, want %q", body, "lark-base v1")
|
|
}
|
|
|
|
// The per-task link must point at the real directory, not at the
|
|
// installer's own link, so it stays valid if the installer re-points it.
|
|
if runtime.GOOS != "windows" {
|
|
target, err := os.Readlink(dst)
|
|
if err != nil {
|
|
t.Fatalf("readlink %s: %v", dst, err)
|
|
}
|
|
want, err := filepath.EvalSymlinks(realSkill)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if target != want {
|
|
t.Errorf("link target = %q, want the resolved skill dir %q", target, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSeedUserCodexSkillsYieldsToWorkspaceSkill(t *testing.T) {
|
|
sharedHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
writeUserSkill(t, filepath.Join(sharedHome, "skills", "issue-review"), "user version")
|
|
writeUserSkill(t, filepath.Join(sharedHome, "skills", "keeper"), "keeper version")
|
|
|
|
codexHome := t.TempDir()
|
|
skills := []SkillContextForEnv{{Name: "Issue Review", Content: "workspace version"}}
|
|
if err := seedUserCodexSkills(codexHome, skills, testLogger()); err != nil {
|
|
t.Fatalf("seedUserCodexSkills: %v", err)
|
|
}
|
|
|
|
if _, err := os.Lstat(filepath.Join(codexHome, "skills", "issue-review")); !os.IsNotExist(err) {
|
|
t.Errorf("user skill claimed a workspace skill's slot (err = %v)", err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(codexHome, "skills", "keeper")); err != nil {
|
|
t.Errorf("unrelated user skill was dropped: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSeedUserCodexSkillsWithNoEligibleSkillsCreatesNoDir(t *testing.T) {
|
|
sharedHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
if err := os.MkdirAll(filepath.Join(sharedHome, "skills"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
codexHome := t.TempDir()
|
|
if err := seedUserCodexSkills(codexHome, nil, testLogger()); err != nil {
|
|
t.Fatalf("seedUserCodexSkills: %v", err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(codexHome, "skills")); !os.IsNotExist(err) {
|
|
t.Errorf("skills dir created with nothing to seed (err = %v)", err)
|
|
}
|
|
}
|
|
|
|
// hydrateCodexSkills wipes the skills dir before every seed, on both the
|
|
// Prepare and the Reuse path. With links in place that RemoveAll must delete
|
|
// the link and never reach through it into the user's real skill directory.
|
|
func TestHydrateCodexSkillsWipeKeepsUserSkillContent(t *testing.T) {
|
|
sharedHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
userSkill := filepath.Join(sharedHome, "skills", "alpha")
|
|
writeUserSkill(t, userSkill, "alpha v1")
|
|
if err := os.WriteFile(filepath.Join(userSkill, "reference.md"), []byte("support"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
codexHome := t.TempDir()
|
|
workspaceSkills := []SkillContextForEnv{{Name: "Beta", Description: "workspace skill", Content: "beta body"}}
|
|
|
|
// Twice: Prepare hydrates, then a follow-up task on the same env re-hydrates.
|
|
for i := 0; i < 2; i++ {
|
|
if err := hydrateCodexSkills(codexHome, workspaceSkills, nil, testLogger()); err != nil {
|
|
t.Fatalf("hydrateCodexSkills run %d: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
for _, name := range []string{"SKILL.md", "reference.md"} {
|
|
if _, err := os.Stat(filepath.Join(userSkill, name)); err != nil {
|
|
t.Fatalf("hydrate deleted %s from the user's real skill dir: %v", name, err)
|
|
}
|
|
}
|
|
assertIsLink(t, filepath.Join(codexHome, "skills", "alpha"))
|
|
if _, err := os.Stat(filepath.Join(codexHome, "skills", "beta", "SKILL.md")); err != nil {
|
|
t.Errorf("workspace skill missing after re-hydration: %v", err)
|
|
}
|
|
}
|
|
|
|
// A workspace skill whose name only collides after directory naming — the
|
|
// sanitized-name pre-filter does not catch it — must land in a sibling
|
|
// directory instead of being written through the user's link.
|
|
func TestHydrateCodexSkillsNeverWritesThroughAUserSkillLink(t *testing.T) {
|
|
sharedHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", sharedHome)
|
|
userSkill := filepath.Join(sharedHome, "skills", "alpha")
|
|
writeUserSkill(t, userSkill, "user version")
|
|
|
|
codexHome := t.TempDir()
|
|
skillsDir := filepath.Join(codexHome, "skills")
|
|
if err := seedUserCodexSkills(codexHome, nil, testLogger()); err != nil {
|
|
t.Fatalf("seedUserCodexSkills: %v", err)
|
|
}
|
|
// writeSkillFiles runs without the pre-filter that hydrateCodexSkills
|
|
// applies, which is exactly the case allocateCollisionFreeSkillDir guards.
|
|
if err := writeSkillFiles(skillsDir, []SkillContextForEnv{{Name: "alpha", Content: "workspace version"}}, nil); err != nil {
|
|
t.Fatalf("writeSkillFiles: %v", err)
|
|
}
|
|
|
|
body, err := os.ReadFile(filepath.Join(userSkill, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatalf("read user skill: %v", err)
|
|
}
|
|
if string(body) != "user version" {
|
|
t.Fatalf("workspace skill was written through the link into the user's skill dir: %q", body)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(skillsDir, "alpha-multica", "SKILL.md")); err != nil {
|
|
t.Errorf("colliding workspace skill did not land in a sibling dir: %v", err)
|
|
}
|
|
}
|