mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
* 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>
115 lines
3.5 KiB
Go
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)
|
|
})
|
|
}
|