Files
multica/server/internal/daemon/execenv/codex_home_link_test.go
Bohan Jiang 144661e68f fix(daemon/execenv): refresh stale Codex auth.json across env reuse (#2126)
`ensureSymlink` previously short-circuited whenever `dst` already existed
as a regular file ("Regular file exists — don't overwrite"). On Windows
that branch is reachable via the createFileLink copy fallback that fires
when `os.Symlink` is unavailable, so once a per-task `codex-home/auth.json`
was written as a copy it would never be refreshed by subsequent
Prepare/Reuse calls. If the shared `~/.codex/auth.json` rotated (e.g.
Codex Desktop refreshed the token in the background), the daemon kept
handing Codex a now-revoked refresh_token, which the OAuth server
rejected with `refresh_token_reused` / `token_expired`. Renaming the
workspace directory was the only recovery path.

Treat any non-matching dst — wrong-target symlink, broken symlink, or
stale regular file — as something to delete and re-create via
createFileLink, so each Prepare/Reuse mirrors the current shared source.
Add a `logCodexAuthState` info log (file kind, link target, size, mtime —
never contents) so operators chasing the same symptom can see at a glance
whether the per-task home is tracking the shared auth or has drifted.

Tests cover: stale regular-file dst is replaced, copy-fallback dst is
refreshed when the shared source rotates, and a high-level
prepareCodexHome regression simulating the Windows + token-rotation
scenario from issue #2081.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 15:18:04 +08:00

247 lines
6.1 KiB
Go

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()
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, or be an accessible junction on Windows.
assertDirLinkTarget(t, dst, 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)
}
assertDirLinkTarget(t, dst, 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)
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)
}
assertDirLinkTarget(t, dst, 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_ReplacesStaleRegularFile(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)
// Regression for issue #2081: a regular file at dst (e.g. left over from
// the Windows copy fallback in createFileLink) must be replaced so the
// per-task home picks up changes to the shared source — otherwise a
// once-stale auth.json never refreshes across env reuses.
if err := ensureSymlink(src, dst); err != nil {
t.Fatalf("ensureSymlink: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read dst: %v", err)
}
if string(data) != "new" {
t.Errorf("dst content = %q, want %q (file should be re-linked/re-copied from src)", data, "new")
}
}
func TestEnsureSymlink_RefreshesAfterCopyFallbackThenSrcChange(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "auth.json")
dst := filepath.Join(dir, "task-auth.json")
// Simulate the Windows copy fallback: first link is a copy of v1.
os.WriteFile(src, []byte(`{"refresh_token":"v1"}`), 0o644)
if err := copyFile(src, dst); err != nil {
t.Fatalf("seed copy fallback: %v", err)
}
// Shared source rotates to v2 (e.g. Codex Desktop refreshed the token).
os.WriteFile(src, []byte(`{"refresh_token":"v2"}`), 0o644)
// Reuse path runs ensureSymlink again — expected to refresh dst from src.
if err := ensureSymlink(src, dst); err != nil {
t.Fatalf("ensureSymlink: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read dst: %v", err)
}
if string(data) != `{"refresh_token":"v2"}` {
t.Errorf("dst content after refresh = %q, want v2 contents", 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")
}
}