From d75fa45cd32dd86e0d2c03be28361a1e972bd38e Mon Sep 17 00:00:00 2001 From: zeropoint95 Date: Fri, 23 Oct 2026 12:34:53 -0400 Subject: [PATCH] CODEAF_HOME does not hold furrow's snapshot store, so two profiles share one under ~/Library Assisted-by: CodeAF Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- cmd/codeaf/main.go | 12 +- .../1769-furrow-store-under-codeaf-home.md | 16 ++ internal/e2e/furrowhome_e2e_test.go | 57 +++++++ internal/furrow/adopt.go | 140 ++++++++++++++++++ internal/furrow/adopt_test.go | 112 ++++++++++++++ internal/furrow/furrow.go | 26 +++- internal/furrow/furrow_test.go | 40 +++++ 7 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 docs/changes/unreleased/1769-furrow-store-under-codeaf-home.md create mode 100644 internal/e2e/furrowhome_e2e_test.go create mode 100644 internal/furrow/adopt.go create mode 100644 internal/furrow/adopt_test.go diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 39c0af6d1c..8c4fc94f3c 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -31,6 +31,7 @@ import ( "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/furrow" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" lanes "github.com/Agent-Field/codeaf/internal/lane" @@ -45,6 +46,12 @@ import ( func main() { home.Adopt(log.Printf) + // The furrow snapshot store follows the state root now ([runBinary] names + // FURROW_DATA_DIR for every child), and a store a pre-fix build left in the + // OS data root is carried into the default profile once so its timelines + // and forks are not orphaned (internal/furrow/adopt.go). Best effort: a + // failure here costs one logged line. + furrow.Adopt(log.Printf) // CODEAF SCHEDULES NOTHING ON THE MACHINE. The timers the feature that // automations replaced installed are taken off this login on every start, // whoever installed them (internal/automation's legacy.go). @@ -687,8 +694,9 @@ A variable that pins a ` + "`/settings`" + ` row wins over that row. 1 spends past the day's limit without stopping a headless run to ask CODEAF_HOME the whole state root — journal, workspace, CAS, craft, - profiles, catalog, skills (default ~/.codeaf). Move it - to run a disposable store that touches nothing of yours. + profiles, catalog, skills and furrow's snapshot store + (default ~/.codeaf). Move it to run a disposable store + that touches nothing of yours. CODEAF_NO_UPDATE_CHECK 1 skips the launch check and the update a released codeaf installs as it opens; /update and codeaf diff --git a/docs/changes/unreleased/1769-furrow-store-under-codeaf-home.md b/docs/changes/unreleased/1769-furrow-store-under-codeaf-home.md new file mode 100644 index 0000000000..07b3bace34 --- /dev/null +++ b/docs/changes/unreleased/1769-furrow-store-under-codeaf-home.md @@ -0,0 +1,16 @@ +--- +kind: fixed +title: The furrow snapshot store follows CODEAF_HOME, not the login's data root +pr: 1769 +surface: [chat, engine] +invalidates: + - "furrow's snapshot store lived in the login's OS data root (`~/Library/Application Support/dev.furrow.furrow` on macOS), so two profiles that shared a HOME shared one store. It now lives under the state root (`CODEAF_HOME/furrow`, default `~/.codeaf/furrow`), a store found at the old root is moved there once on the next start, and a link left at the old root keeps an older binary resolving the same store." + - "`CODEAF_HOME` was described as the whole state root but left furrow's store out; the `help env` row now names it." +--- + +On macOS and Linux, `codeaf chat` sessions launched with their own CODEAF_HOME +still wrote furrow's snapshot store under the shared HOME (and test runs on a +developer's machine wrote into the developer's own store). Every furrow child +now receives a `FURROW_DATA_DIR` under the state root, and the one previous +store a profile may already own is carried into its new home once, with a +link at the old root so an older binary reads the same store. \ No newline at end of file diff --git a/internal/e2e/furrowhome_e2e_test.go b/internal/e2e/furrowhome_e2e_test.go new file mode 100644 index 0000000000..c30798ee59 --- /dev/null +++ b/internal/e2e/furrowhome_e2e_test.go @@ -0,0 +1,57 @@ +//go:build e2e + +package e2e + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// The acceptance of #1769: a session launched with its own CODEAF_HOME, behind +// the host guard's throwaway HOME, must keep furrow's snapshot store inside +// the state root and leave the platform data root empty. Before the fix the +// store went wherever furrow's own default put it — the OS data root of the +// login, `~/Library/Application Support/dev.furrow.furrow` on macOS — so two +// profiles that shared a HOME shared one store, and test drives wrote into the +// developer's own store. +// +// One turn is the unit: the belt is assembled per turn, and the belt's furrow +// detection is the first status call, which opens (and therefore creates) the +// store eagerly even in a folder furrow was never attached to. +func TestTheFurrowStoreFollowsTheStateRoot(t *testing.T) { + requireTmuxAndKey(t) + home := newHome(t, nil) + ws := newWorkspace(t, "furrow-home", false) + r := start(t, "furrow_home", home, ws, 100, 32) + + r.lit("Answer with only these words joined by spaces: Furrow / store / check.") + r.keys("Enter") + r.waitFor(3*time.Minute, "Furrow store check") + + store := filepath.Join(home, "furrow", "store-v1") + if _, err := os.Stat(store); err != nil { + t.Fatalf("the snapshot store did not land under the state root: %v (CODEAF_HOME=%s)", err, home) + } + // The login the guard gave this launch is throwaway; furrow's default data + // root there must not exist, or the store still escaped the profile. + legacyRoot := furrowDefaultDataRoot(r.host.login) + if _, err := os.Stat(legacyRoot); !os.IsNotExist(err) { + t.Fatalf("the login's data root holds a furrow store: %s", legacyRoot) + } + t.Logf("store under the state root: %s\ndefault data root empty: %s", store, legacyRoot) +} + +// furrowDefaultDataRoot is where furrow's own default would put its store on +// this platform: the directories crate's ProjectDirs::from("dev", "furrow", +// "furrow").data_dir(), verified against the pinned v0.1.0 source. The +// qualifier shapes only macOS; Linux uses the lowercase application name under +// the XDG data root (which the guard does not set, so the fallback applies). +func furrowDefaultDataRoot(login string) string { + if runtime.GOOS == "darwin" { + return filepath.Join(login, "Library", "Application Support", "dev.furrow.furrow") + } + return filepath.Join(login, ".local", "share", "furrow") +} diff --git a/internal/furrow/adopt.go b/internal/furrow/adopt.go new file mode 100644 index 0000000000..201924d6c0 --- /dev/null +++ b/internal/furrow/adopt.go @@ -0,0 +1,140 @@ +package furrow + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/home" +) + +// Logf is the one-line reporting door boot adoption uses when best effort was +// not enough. It matches log.Printf without making this package choose a log. +type Logf func(string, ...any) + +// Adopt moves a furrow snapshot store that predates the state-root rule into +// the default profile once. Before codeaf started naming FURROW_DATA_DIR +// ([runBinary]), a store lived wherever furrow's own default put it — the OS +// data root of the login, `~/Library/Application Support/dev.furrow.furrow` on +// macOS, `~/.local/share/furrow` on Linux. Pointing every child at the state +// root's own store orphans months of sealed timelines and forks unless they +// are carried across, so the first start of the new layout moves them. A link +// left at the old root keeps an older binary — a resident of an earlier build +// still running, a downgraded copy — resolving the same store: the companion +// link home.Adopt leaves at the old state folder, here for the store. +// +// It is a boot operation, not store resolution: the real binary calls it from +// main, and the testing guard makes an accidental library call harmless in +// test binaries. It is default-profile only — a CODEAF_HOME the caller named +// is a deliberate separation, and the old store belongs to the profile it +// leaves behind, not to the disposable one. Best effort throughout: a store +// furrow has already opened at the new place wins, and every failure is +// reported on one line and forgotten, the way [home.Adopt] treats its own. +func Adopt(logf Logf) { + if testing.Testing() { + return + } + adoptProfile(logf) +} + +func adoptProfile(logf Logf) { + if _, set := env.Lookup(home.EnvVar); set { + return + } + base, err := os.UserHomeDir() + if err != nil { + adoptionFailed(logf, err) + return + } + adopt(base, logf, adoptOps{ + lstat: os.Lstat, + mkdirAll: os.MkdirAll, + rename: os.Rename, + symlink: os.Symlink, + }) +} + +type adoptOps struct { + lstat func(string) (os.FileInfo, error) + mkdirAll func(string, os.FileMode) error + rename func(string, string) error + symlink func(string, string) error +} + +func adopt(base string, logf Logf, operations adoptOps) { + current := home.Join("furrow", "store-v1") + legacy := filepath.Join(platformDataRoot(base), "store-v1") + if _, err := operations.lstat(current); err == nil { + // A store already lives under the state root — this profile has been + // used since the rule, or another process is mid-flight. Leave both. + return + } else if !os.IsNotExist(err) { + adoptionFailed(logf, err) + return + } + info, err := operations.lstat(legacy) + if os.IsNotExist(err) { + return + } + if err != nil { + adoptionFailed(logf, err) + return + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return + } + if err := operations.mkdirAll(home.Join("furrow"), 0o700); err != nil { + adoptionFailed(logf, err) + return + } + if err := operations.rename(legacy, current); err != nil { + adoptionFailed(logf, err) + return + } + if logf != nil { + logf("codeaf: moved furrow's snapshot store from %s to %s", legacy, current) + } + if operations.symlink != nil { + // THE OLD BUILD KEEPS READING THE SAME STORE. A process of an older + // binary still running — a resident that outlived an update — resolves + // the OS data root and would otherwise open a fresh empty store there, + // splitting the timelines between two stores nobody sees both of. + // Redirect the old path to the new one; relative, like home.Adopt's, + // so the pair survives the home directory moving. Best effort: the move + // above is the adoption, this is its compatibility seam. + target, err := filepath.Rel(filepath.Dir(legacy), current) + if err != nil { + target = current + } + if err := operations.symlink(target, legacy); err != nil { + adoptionFailed(logf, err) + } + } +} + +// platformDataRoot is where furrow's own default puts its data on this +// platform: the directories crate's ProjectDirs::from("dev", "furrow", +// "furrow").data_dir(), verified against the pinned v0.1.0 source's +// data_root(). The qualifier only shapes macOS, and the login's OS goes here +// because the legacy store was chosen by the OS the store ran on. +func platformDataRoot(base string) string { + switch runtime.GOOS { + case "darwin": + return filepath.Join(base, "Library", "Application Support", "dev.furrow.furrow") + default: + // Linux: ~/.local/share/furrow, or $XDG_DATA_HOME when the person + // moved the XDG data root (directories reads it before falling back). + if xdg := os.Getenv("XDG_DATA_HOME"); filepath.IsAbs(xdg) { + return filepath.Join(xdg, "furrow") + } + return filepath.Join(base, ".local", "share", "furrow") + } +} + +func adoptionFailed(logf Logf, err error) { + if logf != nil { + logf("codeaf: could not adopt furrow's former snapshot store: %v", err) + } +} diff --git a/internal/furrow/adopt_test.go b/internal/furrow/adopt_test.go new file mode 100644 index 0000000000..113ae7c116 --- /dev/null +++ b/internal/furrow/adopt_test.go @@ -0,0 +1,112 @@ +package furrow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" +) + +// A store that predates the state-root rule is carried into the default +// profile once: the timeline and forks a person sealed while the store sat in +// the OS data root must not be orphaned by the move. The rename is recorded, +// not assumed — this test pins WHICH directories move where, because the whole +// fix is that the store's home changes. +func TestAdoptMovesALegacyStoreIntoTheDefaultProfile(t *testing.T) { + base := t.TempDir() + legacy := filepath.Join(platformDataRoot(base), "store-v1") + if err := os.MkdirAll(legacy, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacy, "timeline.db"), []byte("sealed work"), 0o600); err != nil { + t.Fatal(err) + } + current := home.Join("furrow", "store-v1") + var moved [][2]string + var links [][2]string + var lines []string + adopt(base, func(format string, args ...any) { lines = append(lines, format) }, adoptOps{ + lstat: os.Lstat, + mkdirAll: os.MkdirAll, + rename: func(from, to string) error { + moved = append(moved, [2]string{from, to}) + return os.Rename(from, to) + }, + symlink: func(target, name string) error { + links = append(links, [2]string{target, name}) + return os.Symlink(target, name) + }, + }) + if len(moved) != 1 || moved[0] != [2]string{legacy, current} { + t.Fatalf("renames = %v; want exactly %s -> %s", moved, legacy, current) + } + if _, err := os.Stat(filepath.Join(current, "timeline.db")); err != nil { + t.Fatalf("the store did not land under the state root: %v", err) + } + // AN OLDER BINARY MUST STILL RESOLVE THE OLD ROOT TO THE SAME STORE: the + // move is the adoption, and the link left at the old root is its + // compatibility seam. A pre-fix resident still running would otherwise + // open a fresh empty store where the old one sat, and the timelines would + // be split between two stores nobody sees both of. + target, err := os.Readlink(legacy) + if err != nil { + t.Fatalf("no link left at the old root: %v", err) + } + if resolved := filepath.Clean(filepath.Join(filepath.Dir(legacy), target)); resolved != current { + t.Fatalf("the link at %s resolves to %s; want the moved store %s", legacy, resolved, current) + } + if len(links) != 1 || links[0][1] != legacy { + t.Fatalf("links = %v; want exactly one link at %s", links, legacy) + } + if len(lines) != 1 || !strings.Contains(lines[0], "moved furrow's snapshot store") { + t.Fatalf("log lines = %v; want one line saying the store moved", lines) + } + // A second pass finds the current store and leaves everything alone — the + // renamed store and the link both stay exactly as they are. + moved, links = nil, nil + adopt(base, nil, adoptOps{lstat: os.Lstat, mkdirAll: os.MkdirAll, rename: func(from, to string) error { + moved = append(moved, [2]string{from, to}) + return nil + }, symlink: func(target, name string) error { + links = append(links, [2]string{target, name}) + return nil + }}) + if len(moved) != 0 || len(links) != 0 { + t.Fatalf("second pass renamed %v and linked %v; a live store must win", moved, links) + } + if _, err := os.Lstat(legacy); err != nil { + t.Fatalf("the link at the old root was disturbed: %v", err) + } +} + +// With nothing at the OS data root there is nothing to adopt, and a legacy +// store that is a symlink is left alone — the same caution home.Adopt applies +// to its own legacy folder. +func TestAdoptLeavesNothingAndNoSymlinksAlone(t *testing.T) { + base := t.TempDir() + legacy := filepath.Join(platformDataRoot(base), "store-v1") + touched := false + noOps := adoptOps{ + lstat: os.Lstat, + mkdirAll: os.MkdirAll, + rename: func(from, to string) error { touched = true; return nil }, + symlink: func(target, name string) error { touched = true; return nil }, + } + adopt(base, nil, noOps) + if touched { + t.Fatal("adopted a store that was not there") + } + if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(home.Join("furrow", "store-v1"), legacy); err != nil { + t.Fatal(err) + } + touched = false + adopt(base, nil, noOps) + if touched { + t.Fatal("a symlink was adopted as a store") + } +} diff --git a/internal/furrow/furrow.go b/internal/furrow/furrow.go index 4f1944d7a9..8f1d555929 100644 --- a/internal/furrow/furrow.go +++ b/internal/furrow/furrow.go @@ -48,8 +48,8 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/env" - "github.com/Agent-Field/codeaf/internal/furrowbin" + "github.com/Agent-Field/codeaf/internal/home" ) // Binary is the program this package shells out to. It is normally the copy @@ -60,6 +60,18 @@ import ( const ( Binary = "furrow" BinaryEnvVar = "CODEAF_FURROW" + + // DataDirEnv is furrow's OWN variable, not codeaf's: the one thing that + // moves its snapshot store. furrow resolves the store as + // $FURROW_DATA_DIR/store-v1, and when the variable is absent it falls back + // to the OS data root of the login it runs as — `~/Library/Application + // Support/dev.furrow.furrow` on macOS, `~/.local/share/furrow` on Linux + // (directories' ProjectDirs::from("dev", "furrow", "furrow")). Two + // profiles that share one HOME would therefore share one store behind + // codeaf's back, which is why [runBinary] always names it. It is furrow's + // name, so this package reads it with os.LookupEnv and not the env door + // that guards CODEAF_ variables. + DataDirEnv = "FURROW_DATA_DIR" ) // detectTimeout bounds the two cheap calls [Detect] makes. Detection sits in @@ -465,6 +477,18 @@ func runBinary(ctx context.Context, binary, root string, args ...string) ([]byte var out, errOut bytes.Buffer command.Stdout = &out command.Stderr = &errOut + // THE STORE FOLLOWS THE STATE ROOT. One CODEAF_HOME is one complete, + // separable profile, and a furrow child that resolves its store from the + // login's data root would violate that the moment two profiles share a + // HOME (#1769) — the test-drive store landing inside the developer's own. + // So every child is pointed at the state root's own furrow/ directory. A + // variable the environment already names is left alone: a caller that aims + // the store somewhere on purpose — or a test proving furrow's own + // retirement behaviour against the real binary — keeps exactly what it + // set. + if _, set := os.LookupEnv(DataDirEnv); !set { + command.Env = append(os.Environ(), DataDirEnv+"="+home.Join("furrow")) + } // furrow's own law is that a prompt in machine mode is an error and not a // hang, and its rewind honours that by refusing without --yes when stdin is // not a terminal. Handing it a closed stdin is codeaf agreeing to that diff --git a/internal/furrow/furrow_test.go b/internal/furrow/furrow_test.go index 7cb0710dc1..6a40c737b9 100644 --- a/internal/furrow/furrow_test.go +++ b/internal/furrow/furrow_test.go @@ -13,6 +13,7 @@ import ( "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/furrowbin" + "github.com/Agent-Field/codeaf/internal/home" ) // Every test here runs against a FAKE furrow: a shell script on PATH that @@ -966,3 +967,42 @@ esac t.Fatalf("Program() = %q, want the furrow on PATH", program) } } + +// THE STORE IS THE STATE ROOT'S, NOT THE LOGIN'S. [runBinary] points every +// furrow child at a FURROW_DATA_DIR under the state root, so two profiles that +// share a HOME stop sharing one snapshot store (#1769). A data dir the +// environment already names survives: a caller that aims the store somewhere +// on purpose, or a test proving furrow's own retirement behaviour against the +// real binary, keeps exactly what it set. +func TestEveryFurrowChildGetsTheStateRootsStoreDir(t *testing.T) { + prev, hadPrev := os.LookupEnv(DataDirEnv) + os.Unsetenv(DataDirEnv) + t.Cleanup(func() { + if hadPrev { + os.Setenv(DataDirEnv, prev) + } else { + os.Unsetenv(DataDirEnv) + } + }) + probe := filepath.Join(t.TempDir(), "probe") + if err := os.WriteFile(probe, []byte("#!/bin/sh\nprintf '%s\\n' \"$FURROW_DATA_DIR\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + out, _, err := runBinary(context.Background(), probe, "", "--version") + if err != nil { + t.Fatalf("probe run: %v", err) + } + if got := strings.TrimSpace(string(out)); got != home.Join("furrow") { + t.Fatalf("FURROW_DATA_DIR = %q; want the state root's own store dir %q", got, home.Join("furrow")) + } + + t.Setenv(DataDirEnv, "/somewhere-else") + out, _, err = runBinary(context.Background(), probe, "", "--version") + if err != nil { + t.Fatalf("probe run with a named data dir: %v", err) + } + if got := strings.TrimSpace(string(out)); got != "/somewhere-else" { + t.Fatalf("FURROW_DATA_DIR = %q; want the caller's own %q to survive", got, "/somewhere-else") + } +}