Skip to content
Open
24 changes: 21 additions & 3 deletions cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,32 @@ func encodeWorkspace(workspace string) string {
// explicit workspace — the engine's --workspace, a door that has already
// resolved one — is taken as given and is always borrowed: it is a place
// somebody named, and naming a place is asking to work in it.
//
// A PROJECT IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. Every answer here is
// a key this surface reads and writes sessions, tasks and memory under, so the
// spelling a terminal carried into the directory (a path reached through a
// symlink) may not leak into it — the engine's door resolved (#1761), and a
// bucket minted from the alias here would still have been a second project,
// a second history, for one folder. A path that does not resolve keeps its
// spelling.
func v3Workspace(cwd, explicit string) (string, bool) {
if named := strings.TrimSpace(explicit); named != "" {
return named, false
return v3CanonicalPlace(named), false
}
if root, found := v3GitRoot(cwd); found {
return root, false
return v3CanonicalPlace(root), false
}
return v3CanonicalPlace(cwd), v3NoProjectPlace(cwd)
}

// v3CanonicalPlace resolves the links in a path that is about to become a key,
// and hands back the spelling it was given when the path does not resolve — a
// workspace not on disk yet, a test's imagined directory.
func v3CanonicalPlace(dir string) string {
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
return resolved
}
return cwd, v3NoProjectPlace(cwd)
return dir
}

// v3NoProjectPlace reports whether a directory is somewhere the person stood by
Expand Down
55 changes: 55 additions & 0 deletions cmd/codeaf/chatv3_symlink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"os"
"path/filepath"
"testing"
)

// A PROJECT IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. The surface-side
// regression for issue #1761: the bucket key a launch reads and writes sessions
// under used to carry whatever spelling the terminal walked in with, so one
// folder reached through a link was a second project — a second history —
// beside the same folder named by its resolved path.
func TestAProjectIsNamedByTheFolderNotTheSpelling(t *testing.T) {
target := t.TempDir()
link := filepath.Join(t.TempDir(), "linkedrepo")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
resolved, err := filepath.EvalSymlinks(link)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}

// A non-git directory reached through the link keys the same bucket as the
// folder it reaches.
aliasProject, _ := v3Workspace(link, "")
if aliasProject != resolved {
t.Fatalf("the alias spelling became the project: got %q, want %q", aliasProject, resolved)
}
bucketFromAlias, err := v3ProjectDir(aliasProject)
if err != nil {
t.Fatalf("v3ProjectDir(alias): %v", err)
}
bucketFromTarget, err := v3ProjectDir(resolved)
if err != nil {
t.Fatalf("v3ProjectDir(target): %v", err)
}
if bucketFromAlias != bucketFromTarget {
t.Fatalf("one folder was given two buckets: alias=%q target=%q", bucketFromAlias, bucketFromTarget)
}

// Naming the folder explicitly keys one project either way.
namedAlias, borrowed := v3Workspace("", link)
if namedAlias != resolved || borrowed {
t.Fatalf("an explicit workspace keys %q (borrowed=%v), want %q borrowed", namedAlias, borrowed, resolved)
}

// And a path that does not resolve keeps its spelling.
imagined := filepath.Join(t.TempDir(), "nowhere-at-all")
kept, _ := v3Workspace(imagined, "")
if kept != imagined {
t.Fatalf("a path that is not on disk was rewritten: got %q, want %q", kept, imagined)
}
}
16 changes: 16 additions & 0 deletions cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,14 @@ func engineWorkspace(path string) (string, error) {
}
path = strings.TrimSpace(path)
if path == "" {
// THE DEFAULT IS THE SAME DOOR AS THE NAMED ONE. Omitting --workspace
// means the home directory, and home reaches the resolution below only
// when somebody TYPES it — an omitted flag returned the raw spelling, so
// a home behind a symlink keyed two hosts depending on how it was said
// (#1761).
if resolved, err := filepath.EvalSymlinks(home); err == nil {
home = resolved
}
return home, nil
}
expanded, err := expandHome(path)
Expand All @@ -1377,6 +1385,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)
Expand Down
147 changes: 147 additions & 0 deletions cmd/codeaf/engine_symlink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/Agent-Field/codeaf/internal/buildinfo"
"github.com/Agent-Field/codeaf/internal/remote"
)

// 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)
}
}

// TestADialThroughASymlinkMeetsTheHostAtTheFolder is the launch-side regression
// for the review of #1798: the local surface used to hash the spelling the
// person's terminal carried (os.Getwd keeps a symlinked alias), while the
// daemon resolved its --workspace before it listened — so the surface dialed
// one host directory and the daemon was born in another, and the launch fell
// back to a conversation in the terminal. Host lookup, dial and spawn now all
// funnel through the one resolved identity (enginehost.where), so a host
// standing at the folder answers a dial that arrives through any spelling of
// it.
func TestADialThroughASymlinkMeetsTheHostAtTheFolder(t *testing.T) {
shortStateRoot(t)
target := t.TempDir()
link := filepath.Join(t.TempDir(), "linkedrepo")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}

// A daemon was handed --workspace by a door that resolves (engineWorkspace),
// so a host stands where the FOLDER hashes. Stand one there, speaking this
// build's wire so the stale-host question before the dial is answered.
standIn(t, target, remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity()}, false)

// The surface reached the folder through the link and dials with the
// spelling it carries. The dial must still meet the host — and the spawn
// callback must never fire, because something is already answering.
linkDial := &localLink{workspace: link}
conn, err := linkDial.dial()
if err != nil {
t.Fatalf("a dial through the symlink missed the host at the folder: %v", err)
}
_ = conn.Close()

// And the target's own spelling reaches the same place, since one folder is
// one host.
targetDial := &localLink{workspace: target}
conn, err = targetDial.dial()
if err != nil {
t.Fatalf("a dial from the target spelling missed its own host: %v", err)
}
_ = conn.Close()
}

// TestTheDefaultWorkspaceIsTheSameFolderAsTheNamedHome is the default-home
// half of the #1798 review: engineWorkspace("") used to return home without
// the resolution a TYPED home goes through, so a home behind a symlink keyed
// two hosts depending on whether --workspace was omitted or spelled out.
func TestTheDefaultWorkspaceIsTheSameFolderAsTheNamedHome(t *testing.T) {
target := t.TempDir()
link := filepath.Join(t.TempDir(), "homelink")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
// os.UserHomeDir reads HOME on unix and USERPROFILE on Windows; name both
// so the default reaches the link on every machine this runs on.
t.Setenv("HOME", link)
t.Setenv("USERPROFILE", link)

omitted, err := engineWorkspace("")
if err != nil {
t.Fatalf("engineWorkspace(\"\"): %v", err)
}
named, err := engineWorkspace(link)
if err != nil {
t.Fatalf("engineWorkspace(home): %v", err)
}
resolved, err := filepath.EvalSymlinks(link)
if err != nil {
t.Fatalf("EvalSymlinks(home): %v", err)
}
if omitted != resolved {
t.Fatalf("the omitted flag kept the raw home spelling: got %q, want %q", omitted, resolved)
}
if omitted != named {
t.Fatalf("omitting --workspace and naming the same home keyed two workspaces: %q vs %q", omitted, named)
}
}
26 changes: 26 additions & 0 deletions docs/changes/unreleased/1798-symlink-workspace-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
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 <link>` missed the engine holding `<dir>`, and home's projects panel listed the folder once per spelling. The key-minting doors stopped at filepath.Clean, so each spelling hashed to its own key. Every door that turns a path into a key now resolves symlinks first (filepath.EvalSymlinks, with the original spelling kept when the path does not resolve): engineWorkspace (the engine commands and the host's workspace name, including the default home), enginehost.where (the host directory every lookup, dial, spawn, lock and status is keyed by), v3Workspace (the session bucket and tools root), gitidentity's path fallback (the memory project key), and the typed /project pin. 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`.
`engineWorkspace` — the door every engine command (`--daemon`, `--status`,
`--stop`) and the host's workspace name go through — expanded and cleaned the
path but never resolved its links, so two spellings of one folder keyed two
host directories in `internal/enginehost.where` (sha256 of the cleaned string),
and the person saw one folder while codeaf kept two of everything.

The resolution was first added at `engineWorkspace` alone, and the review of
this pull request caught what that left split: the local surface still hashed
the alias its terminal carried while the daemon listened under the resolved
key, and the default home skipped the door entirely. The identity now resolves
at every layer that mints a key — the host directory inside `where` itself, so
lookup, dial, spawn, lock and status cannot disagree; the default home in
`engineWorkspace`; the session bucket and memory project key in `cmd/codeaf`;
the typed `/project` pin in `internal/tui3`. A regression test drives a symlink
through each layer and asserts both spellings land on one key.
14 changes: 13 additions & 1 deletion internal/enginehost/enginehost.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,24 @@ func Dir(workspace string) (string, error) {
// that a question was asked. The doors that are about to WRITE something — the
// host's own listener, the spawn lock, the wait for a host to go — call [Dir]
// and make it themselves.
//
// A HOST DIRECTORY IS NAMED BY THE FOLDER, NOT A SPELLING OF IT. Every identity
// question in this package — host lookup, dial, spawn, lock, status, stop —
// funnels through here, so this is the one place a symlinked spelling must be
// resolved: a surface that reached the folder through a link and a daemon that
// was handed the resolved path would otherwise hash to two host directories and
// spend the launch failing to meet (#1761). A path that does not resolve — a
// workspace not on disk yet, a test's imagined directory — keeps its spelling.
func where(workspace string) (string, error) {
workspace = strings.TrimSpace(workspace)
if workspace == "" {
return "", errors.New("engine host: no workspace to hold")
}
sum := sha256.Sum256([]byte(filepath.Clean(workspace)))
workspace = filepath.Clean(workspace)
if resolved, err := filepath.EvalSymlinks(workspace); err == nil {
workspace = resolved
}
sum := sha256.Sum256([]byte(workspace))
return home.Join("v3", "hosts", hex.EncodeToString(sum[:8])), nil
}

Expand Down
57 changes: 57 additions & 0 deletions internal/enginehost/enginehost_symlink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package enginehost

import (
"os"
"path/filepath"
"testing"
)

// TestAHostDirectoryIsNamedByTheFolderNotASpelling is the identity-layer
// regression for issue #1761 and the review of #1798: every question this
// package answers — lookup, dial, lock, socket — funnels through [where], and
// where is the one place a symlinked spelling must resolve. Before the fix, a
// surface that reached the folder through a link and a daemon named by the
// resolved path hashed to two host directories, and the launch spent its wait
// failing to meet.
func TestAHostDirectoryIsNamedByTheFolderNotASpelling(t *testing.T) {
shortHome(t)
target := t.TempDir()
link := filepath.Join(t.TempDir(), "linkedrepo")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}

socketFromTarget, err := SocketPath(target)
if err != nil {
t.Fatalf("SocketPath(target): %v", err)
}
socketFromLink, err := SocketPath(link)
if err != nil {
t.Fatalf("SocketPath(link): %v", err)
}
if socketFromLink != socketFromTarget {
t.Fatalf("two spellings were given two sockets: link=%q target=%q", socketFromLink, socketFromTarget)
}

lockFromTarget, err := LockPath(target)
if err != nil {
t.Fatalf("LockPath(target): %v", err)
}
lockFromLink, err := LockPath(link)
if err != nil {
t.Fatalf("LockPath(link): %v", err)
}
if lockFromLink != lockFromTarget {
t.Fatalf("two spellings were given two locks: link=%q target=%q", lockFromLink, lockFromTarget)
}

// And a plain directory still keys to itself: resolving only moves
// spellings that a link carries.
plainSocket, err := SocketPath(filepath.Join(t.TempDir(), "plainrepo"))
if err != nil {
t.Fatalf("SocketPath(plain): %v", err)
}
if plainSocket == socketFromTarget {
t.Fatal("a different workspace was given the same socket")
}
}
7 changes: 7 additions & 0 deletions internal/gitidentity/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ func ProjectKey(workspace string) (string, error) {
if err != nil {
return "", nil
}
// THE CANONICAL PATH IS THE RESOLVED ONE. A folder reached through a
// symlink is one project: the alias and the target hash to the same key, or
// memory would hold two owners for one folder (#1761). A path that does not
// resolve keeps the absolute spelling.
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
return hashKey(resolved), nil
}
return hashKey(absolute), nil
}

Expand Down
Loading