Files
multica/server/internal/daemon/execenv/codex_user_skills_windows_test.go
a1da8e536f MUL-6000 fix(daemon): link user Codex skills into codex-home instead of copying them (#6742)
* 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]>
2026-08-11 13:51:50 +08:00

52 lines
1.7 KiB
Go

//go:build windows
package execenv
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// On Windows createDirLink falls back to a directory junction (mklink /J) when
// os.Symlink is denied — the default without Developer Mode. hydrateCodexSkills
// wipes codex-home/skills on every task start, so the wipe must delete the
// junction itself and never reach through it into the user's real skill files.
// os.RemoveAll drops a junction with RemoveDirectory, but only a real Windows
// filesystem proves it; mklink /J is used directly so the junction shape is
// exercised even on a runner where symlinks are permitted.
func TestHydrateCodexSkillsWipeKeepsJunctionTarget(t *testing.T) {
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkill := filepath.Join(sharedHome, "skills", "alpha")
writeUserSkill(t, userSkill, "alpha v1")
codexHome := t.TempDir()
junction := filepath.Join(codexHome, "skills", "alpha")
if err := os.MkdirAll(filepath.Dir(junction), 0o755); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("cmd", "/c", "mklink", "/J", junction, userSkill).CombinedOutput(); err != nil {
t.Fatalf("mklink /J: %s: %v", out, err)
}
if fi, err := os.Lstat(junction); err != nil {
t.Fatal(err)
} else if fi.Mode()&os.ModeSymlink != 0 {
t.Fatalf("expected a junction, got a symlink (mode %v)", fi.Mode())
}
if err := hydrateCodexSkills(codexHome, nil, nil, testLogger()); err != nil {
t.Fatalf("hydrateCodexSkills: %v", err)
}
body, err := os.ReadFile(filepath.Join(userSkill, "SKILL.md"))
if err != nil {
t.Fatalf("the skills wipe reached through the junction: %v", err)
}
if string(body) != "alpha v1" {
t.Errorf("user SKILL.md = %q, want %q", body, "alpha v1")
}
assertIsLink(t, junction)
}