Files
multica/server/internal/daemon/execenv/codex_user_skills.go
Bohan Jiang 384ddcbe65 fix(execenv): seed user-installed Codex skills into per-task CODEX_HOME MUL-1626 (#2519)
* fix(execenv): seed user-installed Codex skills into per-task CODEX_HOME

Codex is the only daemon runtime whose HOME is redirected — the daemon
sets CODEX_HOME to a per-task isolated directory so each task gets a
clean config slate without polluting ~/.codex/. Side effect: the codex
CLI never sees the user's `~/.codex/skills/` and tells the user no skill
was found.

Other runtimes (claude / copilot / opencode / pi / cursor / kimi / kiro)
don't have this issue: they leave HOME untouched and discover both
user-level skills (from ~/.<runtime>/skills) and workspace-assigned
skills (written to a workdir-local dotfile dir) natively. Codex is the
outlier.

Fix: in execenv.Prepare and execenv.Reuse, copy each subdirectory under
`~/.codex/skills/` into the per-task `codex-home/skills/` before writing
workspace-assigned skills. Workspace skills still win on sanitized-name
conflict; user-level installer symlinks (lark-cli style) are followed so
the per-task home gets real content rather than dangling links.

Closes #1922

Co-authored-by: multica-agent <github@multica.ai>

* fix(execenv): wipe per-task codex skills dir before each hydration

Without this, the Reuse path leaves two classes of stale state behind:

1. Round 1 seeded user skill `writing/drafts/stale.md`. Round 2 reuses
   the same workdir with workspace skill `Writing` assigned: seed
   stage skips user `writing` (reserved), workspace stage writes
   `SKILL.md` via MkdirAll + WriteFile but never clears the directory,
   so the round-1 user support files surface under the workspace
   skill — violating "workspace fully wins on name conflict" and
   potentially leaking user-level files into a workspace skill view.

2. User uninstalls a skill from ~/.codex/skills between two runs. The
   prior copy in codex-home/skills/<name>/ lingers, so the codex CLI
   keeps seeing the removed skill.

Fix: RemoveAll(codex-home/skills) at the start of hydrateCodexSkills,
then re-seed user skills and re-write workspace skills. On Prepare
this is a no-op (envRoot was already wiped); on Reuse it resets the
slate.

Added two regression tests covering both scenarios.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 16:35:03 +08:00

115 lines
3.5 KiB
Go

package execenv
import (
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
)
// seedUserCodexSkills copies user-installed skill directories from the shared
// ~/.codex/skills/ into the per-task CODEX_HOME so the codex CLI discovers
// them natively. Codex is the only runtime whose HOME is redirected to a
// per-task directory (via the CODEX_HOME env var), so without this step the
// CLI never sees the user's `~/.codex/skills/` content.
//
// Workspace-assigned skills take precedence on name conflict: any user skill
// whose sanitized name matches a workspace skill's sanitized name is skipped
// here, and writeSkillFiles then writes the workspace version into a clean
// slot.
//
// Per-skill failures are logged and skipped — a single broken user skill
// must not prevent the task from running. Returning an error is reserved for
// failures that prevent listing the shared skills directory at all.
func seedUserCodexSkills(codexHome string, workspaceSkills []SkillContextForEnv, logger *slog.Logger) error {
sharedSkillsDir := filepath.Join(resolveSharedCodexHome(), "skills")
info, err := os.Stat(sharedSkillsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("stat shared skills dir: %w", err)
}
if !info.IsDir() {
return nil
}
reserved := make(map[string]struct{}, len(workspaceSkills))
for _, s := range workspaceSkills {
reserved[sanitizeSkillName(s.Name)] = struct{}{}
}
entries, err := os.ReadDir(sharedSkillsDir)
if err != nil {
return fmt.Errorf("read shared skills dir: %w", err)
}
targetSkillsDir := filepath.Join(codexHome, "skills")
for _, entry := range entries {
name := entry.Name()
if name == "" || strings.HasPrefix(name, ".") {
continue
}
if _, claimed := reserved[sanitizeSkillName(name)]; claimed {
logger.Info("execenv: codex user-skill yields to workspace skill", "name", name)
continue
}
src := filepath.Join(sharedSkillsDir, name)
// Installers like lark-cli ship each skill as a symlink into a
// shared ~/.agents/skills/<name>/ directory. Resolve symlinks so we
// copy the real content into the per-task home.
resolved, err := filepath.EvalSymlinks(src)
if err != nil {
logger.Warn("execenv: codex user-skill resolve failed", "name", name, "error", err)
continue
}
fi, err := os.Stat(resolved)
if err != nil || !fi.IsDir() {
continue
}
dst := filepath.Join(targetSkillsDir, name)
if err := os.RemoveAll(dst); err != nil {
logger.Warn("execenv: codex user-skill clean dst failed", "name", name, "error", err)
continue
}
if err := copyDirTree(resolved, dst); err != nil {
logger.Warn("execenv: codex user-skill copy failed", "name", name, "error", err)
continue
}
}
return nil
}
// copyDirTree walks src recursively and copies every regular file under it
// to the matching path under dst. Nested symlinks are ignored to keep the
// per-task home self-contained; the caller is expected to resolve the root
// before calling.
func copyDirTree(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if !d.Type().IsRegular() {
return nil
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
return copyFile(path, target)
})
}