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:
Y. L.
2026-04-26 10:57:51 +08:00
committed by GitHub
parent 6f04a6d26b
commit 25b393df17
4 changed files with 290 additions and 32 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)