diff --git a/server/internal/daemon/execenv/codex_home.go b/server/internal/daemon/execenv/codex_home.go index c81fbff73..46b4a79c6 100644 --- a/server/internal/daemon/execenv/codex_home.go +++ b/server/internal/daemon/execenv/codex_home.go @@ -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. diff --git a/server/internal/daemon/execenv/codex_home_link_test.go b/server/internal/daemon/execenv/codex_home_link_test.go index e0c768196..bd4f05860 100644 --- a/server/internal/daemon/execenv/codex_home_link_test.go +++ b/server/internal/daemon/execenv/codex_home_link_test.go @@ -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) { diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 13eb177f7..c05e96d0c 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -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 { diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index 981044790..b65050ec5 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -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)