Compare commits

...

1 Commits

Author SHA1 Message Date
yushen
426595ab1d feat(execenv): add Windows fallback for symlink operations
On Windows, os.Symlink requires Developer Mode or admin privileges.
Extract symlink creation into platform-specific files: on non-Windows,
behavior is unchanged (os.Symlink). On Windows, try os.Symlink first,
then fall back to directory junctions (mklink /J) for dirs and file
copy for files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:19:42 +08:00
4 changed files with 249 additions and 2 deletions

View File

@@ -114,7 +114,7 @@ func ensureDirSymlink(src, dst string) error {
}
}
return os.Symlink(src, dst)
return createDirLink(src, dst)
}
// ensureSymlink creates a symlink dst → src. If src doesn't exist, it's a no-op.
@@ -141,7 +141,7 @@ func ensureSymlink(src, dst string) error {
}
}
return os.Symlink(src, dst)
return createFileLink(src, dst)
}
// defaultCodexConfig is the minimal config.toml for Codex tasks.
@@ -204,6 +204,11 @@ func copyFileIfExists(src, dst string) error {
return nil
}
return copyFile(src, dst)
}
// copyFile copies src to dst unconditionally.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)

View File

@@ -0,0 +1,13 @@
//go:build !windows
package execenv
import "os"
func createDirLink(src, dst string) error {
return os.Symlink(src, dst)
}
func createFileLink(src, dst string) error {
return os.Symlink(src, dst)
}

View File

@@ -0,0 +1,197 @@
package execenv
import (
"os"
"path/filepath"
"testing"
)
func TestEnsureDirSymlink_CreatesLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "shared-sessions")
dst := filepath.Join(dir, "task-sessions")
if err := ensureDirSymlink(src, dst); err != nil {
t.Fatalf("ensureDirSymlink: %v", err)
}
// Source dir should be created.
if fi, err := os.Stat(src); err != nil || !fi.IsDir() {
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)
}
}
func TestEnsureDirSymlink_Idempotent(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "shared")
dst := filepath.Join(dir, "link")
if err := ensureDirSymlink(src, dst); err != nil {
t.Fatalf("first call: %v", err)
}
if err := ensureDirSymlink(src, dst); err != nil {
t.Fatalf("second call: %v", err)
}
target, _ := os.Readlink(dst)
if target != src {
t.Errorf("link target = %q, want %q", target, src)
}
}
func TestEnsureDirSymlink_ReplacesWrongTarget(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldSrc := filepath.Join(dir, "old")
newSrc := filepath.Join(dir, "new")
dst := filepath.Join(dir, "link")
os.MkdirAll(oldSrc, 0o755)
os.Symlink(oldSrc, dst)
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)
}
}
func TestEnsureDirSymlink_SkipsExistingRegularDir(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "shared")
dst := filepath.Join(dir, "existing")
os.MkdirAll(dst, 0o755)
if err := ensureDirSymlink(src, dst); err != nil {
t.Fatalf("ensureDirSymlink: %v", err)
}
// Should not be replaced — still a regular directory.
fi, _ := os.Lstat(dst)
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("expected regular dir to be preserved, not replaced with symlink")
}
}
func TestEnsureSymlink_SkipsWhenSourceMissing(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "missing.json")
dst := filepath.Join(dir, "link.json")
if err := ensureSymlink(src, dst); err != nil {
t.Fatalf("ensureSymlink: %v", err)
}
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Error("expected dst to not be created when src is missing")
}
}
func TestEnsureSymlink_SkipsExistingRegularFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "source.json")
dst := filepath.Join(dir, "existing.json")
os.WriteFile(src, []byte("new"), 0o644)
os.WriteFile(dst, []byte("old"), 0o644)
if err := ensureSymlink(src, dst); err != nil {
t.Fatalf("ensureSymlink: %v", err)
}
// Should not be replaced.
data, _ := os.ReadFile(dst)
if string(data) != "old" {
t.Errorf("existing file content changed to %q", data)
}
}
func TestCreateDirLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
os.MkdirAll(src, 0o755)
os.WriteFile(filepath.Join(src, "test.txt"), []byte("hello"), 0o644)
if err := createDirLink(src, dst); err != nil {
t.Fatalf("createDirLink: %v", err)
}
// Should be able to read files through the link.
data, err := os.ReadFile(filepath.Join(dst, "test.txt"))
if err != nil {
t.Fatalf("read through link: %v", err)
}
if string(data) != "hello" {
t.Errorf("content = %q, want %q", data, "hello")
}
}
func TestCreateFileLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "source.json")
dst := filepath.Join(dir, "link.json")
os.WriteFile(src, []byte(`{"key":"value"}`), 0o644)
if err := createFileLink(src, dst); err != nil {
t.Fatalf("createFileLink: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read link: %v", err)
}
if string(data) != `{"key":"value"}` {
t.Errorf("content = %q", data)
}
}
func TestCopyFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "src.txt")
dst := filepath.Join(dir, "dst.txt")
os.WriteFile(src, []byte("content"), 0o644)
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile: %v", err)
}
data, _ := os.ReadFile(dst)
if string(data) != "content" {
t.Errorf("content = %q", data)
}
// Verify it's a copy, not a symlink.
fi, _ := os.Lstat(dst)
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("expected regular file, not symlink")
}
}

View File

@@ -0,0 +1,32 @@
//go:build windows
package execenv
import (
"fmt"
"os"
"os/exec"
)
// createDirLink tries os.Symlink first (requires Developer Mode or admin on
// Windows). If that fails, it falls back to a directory junction (mklink /J)
// which works without elevated privileges.
func createDirLink(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
return nil
}
out, err := exec.Command("cmd", "/c", "mklink", "/J", dst, src).CombinedOutput()
if err != nil {
return fmt.Errorf("mklink /J %s %s: %s: %w", dst, src, out, err)
}
return nil
}
// createFileLink tries os.Symlink first. If that fails, it falls back to
// copying the file so the content is still available.
func createFileLink(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
return nil
}
return copyFile(src, dst)
}