mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
fix(execenv): hydrate Codex skill sources (#1668)
Expose the shared Codex plugin cache inside each per-task CODEX_HOME before launch so plugin-provided skills are available on the first session. Refresh agent-assigned workspace skills for both newly prepared and reused Codex environments, and cover plugin cache plus reuse behavior with focused execenv tests.
This commit is contained in:
@@ -89,6 +89,10 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s
|
||||
}
|
||||
}
|
||||
|
||||
if err := exposeSharedCodexPluginCache(codexHome, sharedHome); err != nil {
|
||||
logger.Warn("execenv: codex-home plugin cache exposure failed", "error", err)
|
||||
}
|
||||
|
||||
// Write a daemon-managed sandbox block into config.toml. On macOS we may
|
||||
// need to fall back to danger-full-access because of openai/codex#10390;
|
||||
// see codex_sandbox.go for the full rationale.
|
||||
@@ -116,6 +120,42 @@ func resolveSharedCodexHome() string {
|
||||
return filepath.Join(home, ".codex")
|
||||
}
|
||||
|
||||
func exposeSharedCodexPluginCache(codexHome, sharedHome string) error {
|
||||
src := filepath.Join(sharedHome, "plugins", "cache")
|
||||
dst := filepath.Join(codexHome, "plugins", "cache")
|
||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||
return fmt.Errorf("create shared plugin cache dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("create codex plugin dir: %w", err)
|
||||
}
|
||||
|
||||
if fi, err := os.Lstat(dst); err == nil {
|
||||
target, readlinkErr := os.Readlink(dst)
|
||||
if readlinkErr == nil {
|
||||
if target == src {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(dst); err != nil {
|
||||
return fmt.Errorf("remove stale plugin cache link: %w", err)
|
||||
}
|
||||
} else if fi.Mode()&os.ModeSymlink != 0 {
|
||||
if err := os.Remove(dst); err != nil {
|
||||
return fmt.Errorf("remove stale plugin cache symlink: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return fmt.Errorf("remove stale plugin cache path: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := createDirLink(src, dst); err != nil {
|
||||
return fmt.Errorf("expose shared plugin cache: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDirSymlink creates a symlink dst → src for a directory.
|
||||
// Unlike ensureSymlink, it creates the source directory if it doesn't exist,
|
||||
// so Codex can write to it immediately.
|
||||
|
||||
@@ -3,9 +3,29 @@ package execenv
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func assertDirLinkTarget(t *testing.T, dst, src string) {
|
||||
t.Helper()
|
||||
|
||||
target, err := os.Readlink(dst)
|
||||
if err == nil {
|
||||
if target != src {
|
||||
t.Errorf("link target = %q, want %q", target, src)
|
||||
}
|
||||
return
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if fi, statErr := os.Stat(dst); statErr != nil || !fi.IsDir() {
|
||||
t.Fatalf("expected accessible linked directory, stat err: %v", statErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("Readlink: %v", err)
|
||||
}
|
||||
|
||||
func TestEnsureDirSymlink_CreatesLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
@@ -22,14 +42,8 @@ func TestEnsureDirSymlink_CreatesLink(t *testing.T) {
|
||||
t.Fatal("expected source directory to be created")
|
||||
}
|
||||
|
||||
// dst should resolve to src.
|
||||
target, err := os.Readlink(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("Readlink: %v", err)
|
||||
}
|
||||
if target != src {
|
||||
t.Errorf("link target = %q, want %q", target, src)
|
||||
}
|
||||
// dst should resolve to src, or be an accessible junction on Windows.
|
||||
assertDirLinkTarget(t, dst, src)
|
||||
}
|
||||
|
||||
func TestEnsureDirSymlink_Idempotent(t *testing.T) {
|
||||
@@ -46,10 +60,7 @@ func TestEnsureDirSymlink_Idempotent(t *testing.T) {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
|
||||
target, _ := os.Readlink(dst)
|
||||
if target != src {
|
||||
t.Errorf("link target = %q, want %q", target, src)
|
||||
}
|
||||
assertDirLinkTarget(t, dst, src)
|
||||
}
|
||||
|
||||
func TestEnsureDirSymlink_ReplacesWrongTarget(t *testing.T) {
|
||||
@@ -61,16 +72,18 @@ func TestEnsureDirSymlink_ReplacesWrongTarget(t *testing.T) {
|
||||
dst := filepath.Join(dir, "link")
|
||||
|
||||
os.MkdirAll(oldSrc, 0o755)
|
||||
os.Symlink(oldSrc, dst)
|
||||
if err := os.Symlink(oldSrc, dst); err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("directory symlink unavailable on this Windows session: %v", err)
|
||||
}
|
||||
t.Fatalf("seed wrong symlink: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDirSymlink(newSrc, dst); err != nil {
|
||||
t.Fatalf("ensureDirSymlink: %v", err)
|
||||
}
|
||||
|
||||
target, _ := os.Readlink(dst)
|
||||
if target != newSrc {
|
||||
t.Errorf("link target = %q, want %q", target, newSrc)
|
||||
}
|
||||
assertDirLinkTarget(t, dst, newSrc)
|
||||
}
|
||||
|
||||
func TestEnsureDirSymlink_SkipsExistingRegularDir(t *testing.T) {
|
||||
|
||||
@@ -120,10 +120,8 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
|
||||
if err := prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{CodexVersion: params.CodexVersion}, logger); err != nil {
|
||||
return nil, fmt.Errorf("execenv: prepare codex-home: %w", err)
|
||||
}
|
||||
if len(params.Task.AgentSkills) > 0 {
|
||||
if err := writeSkillFiles(filepath.Join(codexHome, "skills"), params.Task.AgentSkills); err != nil {
|
||||
return nil, fmt.Errorf("execenv: write codex skills: %w", err)
|
||||
}
|
||||
if err := writeCodexWorkspaceSkills(codexHome, params.Task.AgentSkills); err != nil {
|
||||
return nil, fmt.Errorf("execenv: write codex skills: %w", err)
|
||||
}
|
||||
env.CodexHome = codexHome
|
||||
}
|
||||
@@ -163,6 +161,9 @@ func Reuse(workDir, provider, codexVersion string, task TaskContextForEnv, logge
|
||||
logger.Warn("execenv: refresh codex-home failed", "error", err)
|
||||
} else {
|
||||
env.CodexHome = codexHome
|
||||
if err := writeCodexWorkspaceSkills(codexHome, task.AgentSkills); err != nil {
|
||||
logger.Warn("execenv: refresh codex skills failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +171,13 @@ func Reuse(workDir, provider, codexVersion string, task TaskContextForEnv, logge
|
||||
return env
|
||||
}
|
||||
|
||||
func writeCodexWorkspaceSkills(codexHome string, skills []SkillContextForEnv) error {
|
||||
if len(skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
return writeSkillFiles(filepath.Join(codexHome, "skills"), skills)
|
||||
}
|
||||
|
||||
// GCMeta is persisted to .gc_meta.json inside the env root so the GC loop
|
||||
// can determine which issue this directory belongs to.
|
||||
type GCMeta struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -906,6 +907,13 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(sharedHome, "config.json"), []byte(`{"model":"o3"}`), 0o644)
|
||||
os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(`model = "o3"`), 0o644)
|
||||
os.WriteFile(filepath.Join(sharedHome, "instructions.md"), []byte("Be helpful."), 0o644)
|
||||
sharedPluginCache := filepath.Join(sharedHome, "plugins", "cache")
|
||||
if err := os.MkdirAll(filepath.Join(sharedPluginCache, "superpowers"), 0o755); err != nil {
|
||||
t.Fatalf("create shared plugin cache: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sharedPluginCache, "superpowers", "SKILL.md"), []byte("Use superpowers."), 0o644); err != nil {
|
||||
t.Fatalf("write shared plugin skill: %v", err)
|
||||
}
|
||||
|
||||
// Point CODEX_HOME to our fake shared home.
|
||||
t.Setenv("CODEX_HOME", sharedHome)
|
||||
@@ -921,12 +929,19 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sessions not found: %v", err)
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink == 0 {
|
||||
sessionsIsLink := fi.Mode()&os.ModeSymlink != 0
|
||||
if !sessionsIsLink && runtime.GOOS != "windows" {
|
||||
t.Error("sessions should be a symlink")
|
||||
}
|
||||
sessTarget, _ := os.Readlink(sessionsPath)
|
||||
if sessTarget != filepath.Join(sharedHome, "sessions") {
|
||||
t.Errorf("sessions symlink target = %q, want %q", sessTarget, filepath.Join(sharedHome, "sessions"))
|
||||
if sessionsIsLink {
|
||||
sessTarget, _ := os.Readlink(sessionsPath)
|
||||
if sessTarget != filepath.Join(sharedHome, "sessions") {
|
||||
t.Errorf("sessions symlink target = %q, want %q", sessTarget, filepath.Join(sharedHome, "sessions"))
|
||||
}
|
||||
} else if fi.IsDir() {
|
||||
if _, err := os.Stat(sessionsPath); err != nil {
|
||||
t.Fatalf("sessions link target should be accessible: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// auth.json should be a symlink.
|
||||
@@ -935,12 +950,15 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("auth.json not found: %v", err)
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink == 0 {
|
||||
authIsLink := fi.Mode()&os.ModeSymlink != 0
|
||||
if !authIsLink && runtime.GOOS != "windows" {
|
||||
t.Error("auth.json should be a symlink")
|
||||
}
|
||||
target, _ := os.Readlink(authPath)
|
||||
if target != filepath.Join(sharedHome, "auth.json") {
|
||||
t.Errorf("auth.json symlink target = %q, want %q", target, filepath.Join(sharedHome, "auth.json"))
|
||||
if authIsLink {
|
||||
target, _ := os.Readlink(authPath)
|
||||
if target != filepath.Join(sharedHome, "auth.json") {
|
||||
t.Errorf("auth.json symlink target = %q, want %q", target, filepath.Join(sharedHome, "auth.json"))
|
||||
}
|
||||
}
|
||||
// Verify content is accessible through symlink.
|
||||
data, _ := os.ReadFile(authPath)
|
||||
@@ -977,6 +995,16 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
|
||||
if string(data) != "Be helpful." {
|
||||
t.Errorf("instructions.md content = %q", data)
|
||||
}
|
||||
|
||||
// plugin cache should be exposed at the same relative path in codex-home.
|
||||
pluginSkillPath := filepath.Join(codexHome, "plugins", "cache", "superpowers", "SKILL.md")
|
||||
data, err = os.ReadFile(pluginSkillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("plugin cache skill not exposed: %v", err)
|
||||
}
|
||||
if string(data) != "Use superpowers." {
|
||||
t.Errorf("plugin cache skill content = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
|
||||
@@ -1006,8 +1034,11 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
|
||||
if !entryNames["config.toml"] {
|
||||
t.Error("expected config.toml (auto-generated for network access)")
|
||||
}
|
||||
if !entryNames["plugins"] {
|
||||
t.Error("expected plugins directory for plugin cache exposure")
|
||||
}
|
||||
for name := range entryNames {
|
||||
if name != "sessions" && name != "config.toml" {
|
||||
if name != "sessions" && name != "config.toml" && name != "plugins" {
|
||||
t.Errorf("unexpected entry: %s", name)
|
||||
}
|
||||
}
|
||||
@@ -1017,9 +1048,12 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sessions not found: %v", err)
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink == 0 {
|
||||
if fi.Mode()&os.ModeSymlink == 0 && runtime.GOOS != "windows" {
|
||||
t.Error("sessions should be a symlink")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(codexHome, "plugins", "cache")); err != nil {
|
||||
t.Fatalf("missing shared plugin cache exposure should still be tolerated and created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCodexSandboxConfigCreatesDefaultLinux(t *testing.T) {
|
||||
@@ -1366,6 +1400,164 @@ func TestReuseRestoresCodexHome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseRestoresCodexPluginCache(t *testing.T) {
|
||||
// Cannot use t.Parallel() with t.Setenv.
|
||||
|
||||
sharedHome := t.TempDir()
|
||||
sharedPluginCache := filepath.Join(sharedHome, "plugins", "cache")
|
||||
if err := os.MkdirAll(filepath.Join(sharedPluginCache, "superpowers"), 0o755); err != nil {
|
||||
t.Fatalf("create shared plugin cache: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sharedPluginCache, "superpowers", "SKILL.md"), []byte("Use superpowers."), 0o644); err != nil {
|
||||
t.Fatalf("write shared plugin skill: %v", err)
|
||||
}
|
||||
t.Setenv("CODEX_HOME", sharedHome)
|
||||
|
||||
workspacesRoot := t.TempDir()
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
WorkspaceID: "ws-codex-plugin-reuse",
|
||||
TaskID: "a5f6a7b8-c9d0-1234-efab-567890123456",
|
||||
AgentName: "Codex Agent",
|
||||
Provider: "codex",
|
||||
Task: TaskContextForEnv{IssueID: "reuse-plugin-test"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare failed: %v", err)
|
||||
}
|
||||
defer env.Cleanup(true)
|
||||
|
||||
if err := os.RemoveAll(filepath.Join(env.CodexHome, "plugins")); err != nil {
|
||||
t.Fatalf("remove codex plugins dir: %v", err)
|
||||
}
|
||||
|
||||
reused := Reuse(env.WorkDir, "codex", "", TaskContextForEnv{IssueID: "reuse-plugin-test"}, testLogger())
|
||||
if reused == nil {
|
||||
t.Fatal("Reuse returned nil")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "plugins", "cache", "superpowers", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("reused codex plugin cache not restored: %v", err)
|
||||
}
|
||||
if string(data) != "Use superpowers." {
|
||||
t.Errorf("reused plugin cache skill content = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseWritesMissingCodexWorkspaceSkills(t *testing.T) {
|
||||
// Cannot use t.Parallel() with t.Setenv.
|
||||
|
||||
sharedHome := t.TempDir()
|
||||
t.Setenv("CODEX_HOME", sharedHome)
|
||||
|
||||
workspacesRoot := t.TempDir()
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
WorkspaceID: "ws-codex-skill-reuse",
|
||||
TaskID: "b5f6a7b8-c9d0-1234-efab-567890123456",
|
||||
AgentName: "Codex Agent",
|
||||
Provider: "codex",
|
||||
Task: TaskContextForEnv{IssueID: "reuse-skill-test"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare failed: %v", err)
|
||||
}
|
||||
defer env.Cleanup(true)
|
||||
|
||||
if err := os.RemoveAll(filepath.Join(env.CodexHome, "skills")); err != nil {
|
||||
t.Fatalf("remove codex skills dir: %v", err)
|
||||
}
|
||||
|
||||
reused := Reuse(env.WorkDir, "codex", "", TaskContextForEnv{
|
||||
IssueID: "reuse-skill-test",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{
|
||||
Name: "Writing",
|
||||
Content: "Write clearly.",
|
||||
Files: []SkillFileContextForEnv{{Path: "examples/example.md", Content: "Example"}},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if reused == nil {
|
||||
t.Fatal("Reuse returned nil")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing reused codex workspace skill: %v", err)
|
||||
}
|
||||
if string(data) != "Write clearly." {
|
||||
t.Errorf("skill content = %q", data)
|
||||
}
|
||||
example, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "examples", "example.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing reused codex workspace skill support file: %v", err)
|
||||
}
|
||||
if string(example) != "Example" {
|
||||
t.Errorf("support file content = %q", example)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseUpdatesCodexWorkspaceSkills(t *testing.T) {
|
||||
// Cannot use t.Parallel() with t.Setenv.
|
||||
|
||||
sharedHome := t.TempDir()
|
||||
t.Setenv("CODEX_HOME", sharedHome)
|
||||
|
||||
workspacesRoot := t.TempDir()
|
||||
env, err := Prepare(PrepareParams{
|
||||
WorkspacesRoot: workspacesRoot,
|
||||
WorkspaceID: "ws-codex-skill-update",
|
||||
TaskID: "c5f6a7b8-c9d0-1234-efab-567890123456",
|
||||
AgentName: "Codex Agent",
|
||||
Provider: "codex",
|
||||
Task: TaskContextForEnv{
|
||||
IssueID: "reuse-skill-update-test",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{
|
||||
Name: "Writing",
|
||||
Content: "Old writing guidance.",
|
||||
Files: []SkillFileContextForEnv{{Path: "examples/example.md", Content: "Old example"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare failed: %v", err)
|
||||
}
|
||||
defer env.Cleanup(true)
|
||||
|
||||
reused := Reuse(env.WorkDir, "codex", "", TaskContextForEnv{
|
||||
IssueID: "reuse-skill-update-test",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{
|
||||
Name: "Writing",
|
||||
Content: "Updated writing guidance.",
|
||||
Files: []SkillFileContextForEnv{{Path: "examples/example.md", Content: "Updated example"}},
|
||||
},
|
||||
},
|
||||
}, testLogger())
|
||||
if reused == nil {
|
||||
t.Fatal("Reuse returned nil")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing reused codex workspace skill: %v", err)
|
||||
}
|
||||
if string(data) != "Updated writing guidance." {
|
||||
t.Errorf("skill content = %q", data)
|
||||
}
|
||||
example, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "examples", "example.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing reused codex workspace skill support file: %v", err)
|
||||
}
|
||||
if string(example) != "Updated example" {
|
||||
t.Errorf("support file content = %q", example)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
@@ -1376,7 +1568,12 @@ func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) {
|
||||
os.WriteFile(src, []byte("real"), 0o644)
|
||||
|
||||
// Create a broken symlink pointing to a non-existent file.
|
||||
os.Symlink(filepath.Join(dir, "old-source.json"), dst)
|
||||
if err := os.Symlink(filepath.Join(dir, "old-source.json"), dst); err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("file symlink unavailable on this Windows session: %v", err)
|
||||
}
|
||||
t.Fatalf("seed broken symlink: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureSymlink(src, dst); err != nil {
|
||||
t.Fatalf("ensureSymlink failed: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user