From 7bce0e25a8542f73bb092856e6a36be09b6ac98d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Fri, 9 Oct 2026 13:32:50 +0800 Subject: [PATCH 1/2] fix(workspace): name a workspace by its resolved path A folder reached through a symlink became two projects, two conversation histories and two engines: engineWorkspace and /project stopped at filepath.Clean, so each spelling hashed to its own enginehost host directory and its own project row. Resolve symlinks (filepath.EvalSymlinks) at both doors where a person's path becomes a key: engineWorkspace (--daemon/--status/--stop and the host's workspace name) and runProjectCommand (the project pin). Fixes Agent-Field/CodeAF#1761 --- cmd/codeaf/engine.go | 8 ++++ cmd/codeaf/engine_symlink_test.go | 67 +++++++++++++++++++++++++++++++ internal/tui3/projectcmd.go | 11 +++++ 3 files changed, 86 insertions(+) create mode 100644 cmd/codeaf/engine_symlink_test.go diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 2577e8011c..673331a302 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1377,6 +1377,14 @@ func engineWorkspace(path string) (string, error) { expanded = filepath.Join(home, expanded) } expanded = filepath.Clean(expanded) + // A WORKSPACE IS NAMED BY ITS RESOLVED PATH, so a folder reached through a + // symlink keys the same project, conversation history and engine host as + // the folder it points at. Clean alone leaves both spellings distinct and + // sha256(Clean(...)) in enginehost.where then builds one host directory per + // spelling — two engines, two histories, one folder. + if resolved, err := filepath.EvalSymlinks(expanded); err == nil { + expanded = resolved + } info, err := os.Stat(expanded) if err != nil { return "", fmt.Errorf("open %s: %w", expanded, err) diff --git a/cmd/codeaf/engine_symlink_test.go b/cmd/codeaf/engine_symlink_test.go new file mode 100644 index 0000000000..48e488c445 --- /dev/null +++ b/cmd/codeaf/engine_symlink_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestEngineWorkspaceResolvesSymlink is the regression for issue #1761: a +// folder reached through a symlink must key to the same workspace as the +// folder it points at, so one folder cannot become two projects and two +// engines. Before the fix engineWorkspace stopped at filepath.Clean, so the +// alias and the target returned different paths and enginehost.where hashed +// each spelling to its own host directory. +func TestEngineWorkspaceResolvesSymlink(t *testing.T) { + tmp := t.TempDir() + real := filepath.Join(tmp, "realrepo") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "linkedrepo") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + fromTarget, err := engineWorkspace(real) + if err != nil { + t.Fatalf("engineWorkspace(target): %v", err) + } + fromLink, err := engineWorkspace(link) + if err != nil { + t.Fatalf("engineWorkspace(link): %v", err) + } + + resolvedLink, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatalf("EvalSymlinks(link): %v", err) + } + + if fromLink != resolvedLink { + t.Fatalf("link spelling not resolved: got %q, want %q", fromLink, resolvedLink) + } + if fromLink != fromTarget { + t.Fatalf("two spellings keyed differently: link=%q target=%q", fromLink, fromTarget) + } +} + +// TestEngineWorkspaceKeepsNonSymlinkPathUnchanged guards that an ordinary +// folder still resolves to itself, so the fix only moves symlinked spellings. +func TestEngineWorkspaceKeepsNonSymlinkPathUnchanged(t *testing.T) { + tmp := t.TempDir() + real := filepath.Join(tmp, "plainrepo") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + resolvedReal, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatalf("EvalSymlinks(plain): %v", err) + } + got, err := engineWorkspace(real) + if err != nil { + t.Fatalf("engineWorkspace(plain): %v", err) + } + if got != resolvedReal { + t.Fatalf("plain path changed: got %q, want %q", got, resolvedReal) + } +} diff --git a/internal/tui3/projectcmd.go b/internal/tui3/projectcmd.go index bf6c882cc3..1bc3f8264a 100644 --- a/internal/tui3/projectcmd.go +++ b/internal/tui3/projectcmd.go @@ -22,6 +22,7 @@ package tui3 import ( "os" + "path/filepath" "strings" tea "charm.land/bubbletea/v2" @@ -75,6 +76,16 @@ func (a *app) runProjectCommand(rest string) tea.Cmd { return nil } path := a.resolvePath(rest) + // A PIN NAMES A FOLDER, NOT A SPELLING. A path taken through a symlink must + // pin the folder the link reaches, or home stores one project under the + // alias while a launch from the resolved path stores another — two + // projects, two histories, two engines for one folder (#1761). The stat + // below already pays the walk EvalSymlinks costs on a missing target, so a + // path that does not resolve simply falls through to the refusal word as + // it always did. + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } info, err := os.Stat(path) if path == "" || err != nil || !info.IsDir() { a.home.say(projectNoFolderWord+rest, "") From fa7d4383a77025c7b659254bb1b8b4bcb4a9978d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Fri, 9 Oct 2026 13:46:44 +0800 Subject: [PATCH 2/2] changelog: a folder reached through a symlink is one project and one engine (#1798) --- .../unreleased/1798-symlink-workspace-keys.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/changes/unreleased/1798-symlink-workspace-keys.md diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md new file mode 100644 index 0000000000..015c7f2de0 --- /dev/null +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: a folder reached through a symlink is one project and one engine, not two +pr: 1798 +surface: [engine, chat] +invalidates: + - "A folder reached through a symlink became two projects, two conversation histories and two engines. `codeaf engine --status --workspace ` missed the engine holding ``, and home's projects panel listed the folder once per spelling. engineWorkspace stopped at filepath.Clean, so each spelling hashed to its own enginehost host directory. engineWorkspace now resolves symlinks (filepath.EvalSymlinks after the absolute path is settled), so both spellings of one folder reach one project, one conversation history and one engine." +--- + +Found in the v0.7.1 happy-path pass on macOS, where `/tmp` is a link to +`/private/tmp`, and reproducible on any system with `mkdir d && ln -s d l`. +The workspace key-minting door in `cmd/codeaf` — the one every engine command +(`--daemon`, `--status`, `--stop`) and the host's workspace name go through — +expanded and cleaned the path but never resolved its links. Two spellings of +one folder then keyed two host directories in `internal/enginehost.where` +(sha256 of the cleaned string), so the person saw one folder while codeaf kept +two of everything. The fix names a workspace by its resolved path at that door; +a regression test drives a symlink through `engineWorkspace` and asserts both +spellings land on the same directory. \ No newline at end of file