From f43929d5ea324e65659894892a6b20636f504535 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 28 Oct 2026 16:18:24 -0400 Subject: [PATCH 1/3] cellstore: each codeaf home gets its own private saving connection Windows started every engine's furrow daemon on a socket named only by the build, so two engines with different homes (desktop and terminal, or two homes) bound the same endpoint and broke each other's seals ("use of closed network connection"). The socket now carries the home and the build: same- home clients still share one daemon, different homes never collide, and the address stays under the AF_UNIX limit, falling back only to the user's own temp folder with a private ACL. With no private bounded endpoint, saved file history turns off with one plain sentence instead of failing every seal. On Unix a long home used a predictable /tmp socket that nobody checked. Short endpoints now prefer $XDG_RUNTIME_DIR, then the in-home path, then a validated private temp directory (current uid, 0700, no symlinks, socket owned by the user), and preclaimed or foreign endpoints are skipped before dialing or pruning. Stale endpoints are pruned only under the daemon's start lock, and the next seal still re-dials after a dropped connection. Two gaps in the home-folder policy are closed: an unset HOME falls back to the login home, and the ancestors of a symlinked home's original path count, so neither can make a chat walk the profile. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2017-home-owned-furrow-sockets.md | 20 ++ internal/cellstore/daemon.go | 9 + internal/cellstore/daemon_test.go | 4 + internal/cellstore/daemoncleanup.go | 55 ++++ internal/cellstore/daemoncleanup_test.go | 67 +++++ internal/cellstore/daemonhome_test.go | 155 +++++++++++ internal/cellstore/daemonrecovery_test.go | 85 ++++++ internal/cellstore/daemonstart.go | 4 + internal/cellstore/engine.go | 26 +- .../cellstore/homecapture_regression_test.go | 128 +++++++++ internal/cellstore/homecapture_test.go | 10 +- internal/cellstore/seat.go | 20 ++ .../cellstore/socket_regression_unix_test.go | 251 ++++++++++++++++++ .../socket_regression_windows_test.go | 227 ++++++++++++++++ internal/cellstore/socket_unix.go | 80 +++++- internal/cellstore/socket_unix_test.go | 27 ++ internal/cellstore/socket_windows.go | 110 +++++++- internal/manual/chat/sessions-and-rewind.md | 15 ++ 18 files changed, 1270 insertions(+), 23 deletions(-) create mode 100644 docs/changes/unreleased/2017-home-owned-furrow-sockets.md create mode 100644 internal/cellstore/daemoncleanup.go create mode 100644 internal/cellstore/daemoncleanup_test.go create mode 100644 internal/cellstore/daemonhome_test.go create mode 100644 internal/cellstore/daemonrecovery_test.go create mode 100644 internal/cellstore/homecapture_regression_test.go create mode 100644 internal/cellstore/socket_regression_unix_test.go create mode 100644 internal/cellstore/socket_regression_windows_test.go diff --git a/docs/changes/unreleased/2017-home-owned-furrow-sockets.md b/docs/changes/unreleased/2017-home-owned-furrow-sockets.md new file mode 100644 index 0000000000..738b6d512d --- /dev/null +++ b/docs/changes/unreleased/2017-home-owned-furrow-sockets.md @@ -0,0 +1,20 @@ +--- +kind: fixed +title: Each codeaf home has its own private saving connection +pr: 2017 +surface: [chat, engine, docs] +invalidates: + - "Different codeaf homes on Windows could use the same saving daemon because its socket named only the engine build. The address now identifies both the home and build." + - "A long home on Unix trusted a predictable temporary socket without checking its directory or owner. Short endpoints now use validated private directories and reject preclaimed or foreign endpoints before dialing or pruning." + - "Windows could repeatedly fail every seal when no secure endpoint fit the address limit. Saved file history now turns off with one plain sentence and tools keep working." + - "An unset HOME or the original ancestor of a symlinked home could bypass the home-folder policy. Both now seal only the chat's own folder." +--- + +Same-home clients keep sharing their saving daemon. Unix prefers the user's +private runtime directory, then a fitting in-home address, then validated private +temporary directories. Windows falls back only under the user's own temporary +folder with a private ACL; final addresses stay below the platform byte limit. +Stale endpoints are pruned only while holding their daemon's lifetime lock, +inside the existing per-socket start gate. A lost answer is never replayed and +the next seal reconnects as before. The home-folder policy and its sentence stay +the same; ordinary project folders retain saved history and rewind. diff --git a/internal/cellstore/daemon.go b/internal/cellstore/daemon.go index 54c1f9d398..9a48366c2b 100644 --- a/internal/cellstore/daemon.go +++ b/internal/cellstore/daemon.go @@ -169,6 +169,12 @@ func exchange(conn net.Conn, req wireRequest) (wireResponse, error) { // connect dials the daemon, starting it when nothing listens. func (d Daemon) connect(ctx context.Context) (net.Conn, error) { + if d.Socket == "" { + return nil, fmt.Errorf("no private saving-engine socket directory fits the address limit") + } + if err := validateSocket(d.Socket); err != nil { + return nil, err + } if conn, err := dial(ctx, d.Socket); err == nil { return conn, nil } @@ -176,6 +182,9 @@ func (d Daemon) connect(ctx context.Context) (net.Conn, error) { } func dial(ctx context.Context, socket string) (net.Conn, error) { + if err := validateSocket(socket); err != nil { + return nil, err + } var dialer net.Dialer return dialer.DialContext(ctx, "unix", socket) } diff --git a/internal/cellstore/daemon_test.go b/internal/cellstore/daemon_test.go index 75a57a1ae8..70f43cb173 100644 --- a/internal/cellstore/daemon_test.go +++ b/internal/cellstore/daemon_test.go @@ -103,6 +103,10 @@ func TestDaemonOfANonEngineIsUnavailable(t *testing.T) { // Two engine builds never share a daemon: the socket is named after the binary. func TestSocketIsKeyedToTheEngineBinary(t *testing.T) { + base := filepath.Dir(testSocket(t)) + t.Setenv("CODEAF_HOME", base) + t.Setenv("LOCALAPPDATA", base) + t.Setenv("XDG_RUNTIME_DIR", "") a := socketFor("/x/furrow-0.1.0-aaaaaaaaaaaa") b := socketFor("/x/furrow-0.1.0-bbbbbbbbbbbb") if a == b || (runtime.GOOS != "windows" && filepath.Base(a) != "engine-0.1.0-aaaaaaaaaaaa.sock") { diff --git a/internal/cellstore/daemoncleanup.go b/internal/cellstore/daemoncleanup.go new file mode 100644 index 0000000000..1de5f17853 --- /dev/null +++ b/internal/cellstore/daemoncleanup.go @@ -0,0 +1,55 @@ +package cellstore + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +// pruneStaleSockets only visits the managed socket directory on a cold start. +// Furrow holds socket.lock for its lifetime, including before it listens. +func pruneStaleSockets(ctx context.Context, dir string) { + entries, _ := os.ReadDir(dir) + for _, entry := range entries { + if ctx.Err() != nil { + return + } + name := entry.Name() + if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.HasSuffix(name, ".sock") || (!strings.HasPrefix(name, "e-") && !strings.HasPrefix(name, "engine-")) { + continue + } + pruneStaleSocket(ctx, filepath.Join(dir, name)) + } +} + +func pruneStaleSocket(ctx context.Context, path string) { + if validateSocket(path) != nil { + return + } + lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return + } + defer lock.Close() + if filelock.Lock(lock, true, true) != nil { + return + } + defer filelock.Unlock(lock) + // An older listener without a lock is live too. A timed-out probe is + // inconclusive, so it keeps its endpoint rather than risking a live client. + probe, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + conn, err := dial(probe, path) + if err == nil { + conn.Close() + return + } + if probe.Err() == nil { + _ = os.Remove(path) + } + // Never unlink the lock: a concurrent starter may already have it open. +} diff --git a/internal/cellstore/daemoncleanup_test.go b/internal/cellstore/daemoncleanup_test.go new file mode 100644 index 0000000000..d1af789006 --- /dev/null +++ b/internal/cellstore/daemoncleanup_test.go @@ -0,0 +1,67 @@ +package cellstore + +import ( + "net" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +// TestW3DaemonStartPrunesOnlyDeadSockets proves B3 through a real daemon start: +// abandoned endpoints go; live listeners, held locks and unrelated files stay. +func TestW3DaemonStartPrunesOnlyDeadSockets(t *testing.T) { + e := realEngine(t) + d := daemonFor(t, e.Binary).(Daemon) + e.Transport = d + dir := filepath.Dir(d.Socket) + stale := filepath.Join(dir, "e-abandoned.sock") + listener, err := net.Listen("unix", stale) + if err != nil { + t.Fatal(err) + } + listener.(*net.UnixListener).SetUnlinkOnClose(false) + if err := listener.Close(); err != nil { + t.Fatal(err) + } + livePath := filepath.Join(dir, "e-live.sock") + live, err := net.Listen("unix", livePath) + if err != nil { + t.Fatal(err) + } + defer live.Close() + locked := filepath.Join(dir, "e-starting.sock") + lock, err := os.OpenFile(locked+".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := filelock.Lock(lock, true, true); err != nil { + t.Fatal(err) + } + defer filelock.Unlock(lock) + for _, name := range []string{"e-regular.sock", "e-starting.sock", "keep.sock", "e-copy.sock.bak"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("fixture"), 0600); err != nil { + t.Fatal(err) + } + } + if _, err := e.Seal(t.Context(), newCell(t), TurnInfo{}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"e-abandoned.sock", "e-regular.sock"} { + if _, err := os.Lstat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Errorf("dead endpoint %s remains: %v", name, err) + } + } + for _, name := range []string{"e-live.sock", "e-starting.sock", "e-starting.sock.lock", "keep.sock", "e-copy.sock.bak"} { + if _, err := os.Lstat(filepath.Join(dir, name)); err != nil { + t.Errorf("active or unrelated %s removed: %v", name, err) + } + } + conn, err := net.Dial("unix", livePath) + if err != nil { + t.Fatalf("cleanup broke a live endpoint: %v", err) + } + conn.Close() +} diff --git a/internal/cellstore/daemonhome_test.go b/internal/cellstore/daemonhome_test.go new file mode 100644 index 0000000000..c5b441aa10 --- /dev/null +++ b/internal/cellstore/daemonhome_test.go @@ -0,0 +1,155 @@ +package cellstore + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// TestW3SocketIdentityIncludesHomeAndBuild proves B1, including equivalent +// absolute home spellings and symlink aliases where the platform allows them. +func TestW3SocketIdentityIncludesHomeAndBuild(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + bin := "/engine/furrow-0.2.0-aaaaaaaaaaaa" + t.Setenv("LOCALAPPDATA", filepath.Dir(testSocket(t))) + t.Setenv("CODEAF_HOME", first) + a := socketFor(bin) + if a != socketFor(bin) || a == socketFor("/engine/furrow-0.2.0-bbbbbbbbbbbb") { + t.Fatal("same home/build must share; distinct builds must not") + } + t.Setenv("CODEAF_HOME", second) + if a == socketFor(bin) { + t.Fatal("distinct engine homes shared a socket") + } + relative, err := filepath.Rel(mustWorkingDir(t), first) + if err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_HOME", relative) + if a != socketFor(bin) { + t.Fatal("relative and absolute spellings of one home did not share") + } + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(first, alias); err == nil { + t.Setenv("CODEAF_HOME", alias) + if a != socketFor(bin) { + t.Fatal("home symlink alias did not share its daemon") + } + } else { + t.Logf("symlink alias unavailable: %v", err) + } +} + +func mustWorkingDir(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return dir +} + +// TestW3HomeDaemonsSealConcurrentlyAndRestart proves B1/B2 with real furrow: +// different homes seal concurrently; same-home clients share; a killed daemon +// restarts at the next seal without disrupting the other home. +func TestW3HomeDaemonsSealConcurrentlyAndRestart(t *testing.T) { + bin, err := sealingEngine() + if err != nil { + t.Fatal(err) + } + t.Setenv("LOCALAPPDATA", filepath.Dir(testSocket(t))) + engines := make([]Engine, 2) + daemons := make([]Daemon, 2) + cells := []cell.Cell{newCell(t), newCell(t)} + for i := range engines { + t.Setenv("CODEAF_HOME", t.TempDir()) + e := engineFor(t, (Engine{}).Root()) + e.Binary, e.Workspace = bin, t.TempDir() + e.Transport = transportFor(bin, nil) + engines[i] = e + daemons[i] = Daemon{Socket: socketFor(bin), Binary: bin} + d := daemons[i] + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), SealWithin) + defer cancel() + if err := d.Stop(ctx); err != nil { + t.Errorf("stop home daemon: %v", err) + } + }) + } + if daemons[0].Socket == daemons[1].Socket { + t.Fatal("two engines share one socket") + } + errors := make(chan error, 2) + for i, e := range engines { + go func() { + sealed, err := e.Seal(t.Context(), cells[i], TurnInfo{}) + if err == nil && sealed.Turn.ID == "" { + err = os.ErrInvalid + } + errors <- err + }() + } + for range engines { + if err := <-errors; err != nil { + t.Fatal(err) + } + } + pids := []int{homeDaemonPID(t, daemons[0]), homeDaemonPID(t, daemons[1])} + if pids[0] == pids[1] { + t.Fatal("distinct homes used the same daemon process") + } + if _, err := engines[0].Seal(t.Context(), newCell(t), TurnInfo{}); err != nil { + t.Fatal(err) + } + if homeDaemonPID(t, daemons[0]) != pids[0] { + t.Fatal("same-home clients restarted instead of sharing") + } + exited, ok := daemonExits.Load(daemons[0].Socket) + if !ok { + t.Fatal("test's daemon has no owned exit channel") + } + process, err := os.FindProcess(pids[0]) + if err != nil { + t.Fatal(err) + } + if err := process.Kill(); err != nil { + t.Fatal(err) + } + _ = process.Release() + select { + case <-exited.(<-chan struct{}): + case <-time.After(5 * time.Second): + t.Fatal("killed daemon did not exit") + } + if _, err := engines[0].Seal(t.Context(), cells[0], TurnInfo{}); err != nil { + t.Fatalf("next seal did not restart the dead daemon: %v", err) + } + if homeDaemonPID(t, daemons[0]) == pids[0] || homeDaemonPID(t, daemons[1]) != pids[1] { + t.Fatal("restart did not stay within its own home") + } +} + +func homeDaemonPID(t *testing.T, d Daemon) int { + t.Helper() + conn, err := dial(t.Context(), d.Socket) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + response, err := exchange(conn, wireRequest{V: wireVersion, ID: nextID.Add(1), Verb: "health"}) + if err != nil { + t.Fatal(err) + } + var health struct{ PID int } + if err := json.Unmarshal(response.Ok, &health); err != nil || health.PID == 0 { + t.Fatalf("no daemon pid: %s %v", response.Ok, err) + } + return health.PID +} diff --git a/internal/cellstore/daemonrecovery_test.go b/internal/cellstore/daemonrecovery_test.go new file mode 100644 index 0000000000..4374a05a63 --- /dev/null +++ b/internal/cellstore/daemonrecovery_test.go @@ -0,0 +1,85 @@ +package cellstore + +import ( + "bufio" + "encoding/json" + "net" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +// TestW3NextSealRecoversAfterAnInterruptedAnswer proves B2 at the wire and +// recorder boundaries: do not replay a lost answer; re-dial on the next seal, +// carry the pending calls, and say the existing recovery sentence once. +func TestW3NextSealRecoversAfterAnInterruptedAnswer(t *testing.T) { + socket := testSocket(t) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var plans atomic.Int32 + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + line, err := bufio.NewReader(conn).ReadBytes('\n') + var request wireRequest + if err != nil || json.Unmarshal(line, &request) != nil { + conn.Close() + continue + } + var ok string + switch request.Verb { + case "capture-plan": + if plans.Add(1) == 1 { + conn.Close() + continue + } + ok = `{"token":"reconnected","full":true,"paths":[]}` + case "seal": + ok = `{"snapshot":"recovered"}` + case "sync": + ok = `{"durable":true}` + } + raw, _ := json.Marshal(wireResponse{ID: request.ID, Ok: json.RawMessage(ok)}) + _, _ = conn.Write(append(raw, '\n')) + conn.Close() + } + }() + c := newCell(t) + e := realEngine(t) + fallback := &fixedTransport{out: `{"snapshot":"must-not-replay"}`} + e.Transport = Fallback{Primary: Daemon{Socket: socket}, Secondary: fallback} + watch := &SealWatch{} + recorder, err := NewRecorder(&stubExec{}, e, c, e.WALPath(c), Options{Report: watch.Report}) + if err != nil { + t.Fatal(err) + } + call := func() { + t.Helper() + if err := recorder.Around(t.Context(), executor.Call{Tool: "read"}, executor.EffectLocal, AgentRun, func() ([]byte, bool) { return []byte("ok"), false }); err != nil { + t.Fatal(err) + } + } + call() + if !watch.Failing() || plans.Load() != 1 || fallback.calls != 0 { + t.Fatal("interrupted answer was hidden or replayed") + } + if notice := watch.Take(); !strings.Contains(notice, "answer interrupted") { + t.Fatalf("lost answer was not reported: %q", notice) + } + call() + head, err := Head(c) + if err != nil || head == nil || len(head.Receipt.Calls) != 2 || watch.Failing() || fallback.calls != 0 { + t.Fatalf("next seal did not recover pending calls: head=%+v err=%v", head, err) + } + if notice := watch.Take(); notice != "sealing works again, and the calls made meanwhile are sealed now" || watch.Take() != "" { + t.Fatalf("recovery notice = %q", notice) + } +} diff --git a/internal/cellstore/daemonstart.go b/internal/cellstore/daemonstart.go index bfdab77f8a..e5ae5b37ee 100644 --- a/internal/cellstore/daemonstart.go +++ b/internal/cellstore/daemonstart.go @@ -73,6 +73,9 @@ func startAndDial(ctx context.Context, d Daemon) (net.Conn, error) { } return nil, errors.New("saving is waiting for the storage process to start; try again shortly") } + if err := validateSocket(d.Socket); err != nil { + return nil, err + } if conn, err := dial(ctx, d.Socket); err == nil { return conn, nil } @@ -105,6 +108,7 @@ func startAndDial(ctx context.Context, d Daemon) (net.Conn, error) { } } + pruneStaleSockets(ctx, filepath.Dir(d.Socket)) conn, err := launchAndDial(ctx, d) if err != nil { startMu.Lock() diff --git a/internal/cellstore/engine.go b/internal/cellstore/engine.go index 932d7abc7d..53ae7e3211 100644 --- a/internal/cellstore/engine.go +++ b/internal/cellstore/engine.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "os" + "os/user" "path/filepath" + "runtime" "strings" "time" @@ -308,13 +310,29 @@ func holdsHome(dir string) bool { } house, err := os.UserHomeDir() if err != nil || strings.TrimSpace(house) == "" { - return false + login, err := user.Current() + if err != nil || strings.TrimSpace(login.HomeDir) == "" { + return false + } + house = login.HomeDir } - rel, err := filepath.Rel(tree, canonicalTree(house)) - if err != nil || filepath.IsAbs(rel) { + absolute, err := filepath.Abs(house) + if err != nil { return false } - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) + // Resolving a symlinked home must not discard its original ancestors. + for _, start := range []string{absolute, canonicalTree(absolute)} { + for dir := start; ; dir = filepath.Dir(dir) { + ancestor := canonicalTree(dir) + if tree == ancestor || (runtime.GOOS == "windows" && strings.EqualFold(tree, ancestor)) { + return true + } + if filepath.Dir(dir) == dir { + break + } + } + } + return false } // policyDir is where the harness keeps its exclusions: the cell's directory diff --git a/internal/cellstore/homecapture_regression_test.go b/internal/cellstore/homecapture_regression_test.go new file mode 100644 index 0000000000..7341d8f86b --- /dev/null +++ b/internal/cellstore/homecapture_regression_test.go @@ -0,0 +1,128 @@ +package cellstore + +import ( + "os" + "os/user" + "path/filepath" + "runtime" + "testing" +) + +// C5: Sealing protects both spellings of a symlinked home without planning it. +func TestW3SymlinkHomeAncestorsSealOnlyTheChat(t *testing.T) { + base := t.TempDir() + physical := filepath.Join(base, "physical", "person") + profiles := filepath.Join(base, "profiles") + for _, dir := range []string{physical, profiles} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + login := filepath.Join(profiles, "person") + if err := os.Symlink(physical, login); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("home symlink unavailable: %v", err) + } + t.Fatal(err) + } + useHome(t, login) + for _, tc := range []struct { + name, workspace string + ownFolder bool + }{ + {"original home", login, true}, + {"original ancestor", profiles, true}, + {"resolved home", physical, true}, + {"resolved ancestor", filepath.Dir(physical), true}, + {"project child", filepath.Join(login, "project"), false}, + {"sibling prefix", filepath.Join(profiles, "person-other"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.MkdirAll(tc.workspace, 0700); err != nil { + t.Fatal(err) + } + c := newCell(t) + ledger := &planLedger{} + e := EngineFor(tc.workspace) + e.DataRoot, e.Transport = t.TempDir(), ledger.over(fakePlans()) + if got := e.workspace(c) == ""; got != tc.ownFolder { + t.Errorf("conversation-only workspace = %v, want %v", got, tc.ownFolder) + } + if _, err := e.Seal(t.Context(), c, TurnInfo{}); err != nil { + t.Fatal(err) + } + want := tc.workspace + if tc.ownFolder { + want = c.Root + if ledger.asked(tc.workspace) != 0 { + t.Errorf("home tree was planned: %v", ledger.trees) + } + } + if ledger.asked(want) == 0 { + t.Fatalf("expected tree was not planned: %v", ledger.trees) + } + }) + } +} + +// C5: Missing HOME still protects the passwd home without reading its contents. +func TestW3MissingHomeProtectsLoginHome(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix HOME/passwd fallback") + } + login, err := user.Current() + if err != nil || login.HomeDir == "" { + t.Fatalf("no OS login home: %v", err) + } + for _, mode := range []string{"empty", "unset"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("HOME", "") + if mode == "unset" { + if err := os.Unsetenv("HOME"); err != nil { + t.Fatal(err) + } + } + c := newCell(t) + for workspace := login.HomeDir; ; workspace = filepath.Dir(workspace) { + if EngineFor(workspace).workspace(c) != "" { + t.Errorf("login home or ancestor was allowed: %s", workspace) + } + if filepath.Dir(workspace) == workspace { + break + } + } + if got := EngineFor(filepath.Join(login.HomeDir, "project")).workspace(c); got == "" { + t.Error("project child lost its file history") + } + }) + } +} + +// C5: Home aliases still select the chat's own folder; project children do not. +func TestR8HomeAliasesAndProjectChildren(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix symlink fixture") + } + base := t.TempDir() + login := filepath.Join(base, "person") + project := filepath.Join(login, "project") + if err := os.MkdirAll(project, 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(base, "alias") + if err := os.Symlink(login, alias); err != nil { + t.Fatal(err) + } + useHome(t, alias) + c := newCell(t) + for _, p := range []string{login, alias, login + "/", filepath.Dir(login), "/"} { + if EngineFor(p).workspace(c) != "" { + t.Errorf("home alias was allowed: %s", p) + } + } + for _, p := range []string{project, filepath.Join(alias, "project"), filepath.Join(base, "person-2")} { + if EngineFor(p).workspace(c) == "" { + t.Errorf("ordinary project lost saved file history: %s", p) + } + } +} diff --git a/internal/cellstore/homecapture_test.go b/internal/cellstore/homecapture_test.go index 7e3686ab58..1eb5bf8748 100644 --- a/internal/cellstore/homecapture_test.go +++ b/internal/cellstore/homecapture_test.go @@ -160,9 +160,10 @@ func TestAHomeSeatSaysOnceThatItsFilesAreNotSaved(t *testing.T) { } // TestStartupInALargeHomeWalksNoHome is the startup gate: a seat opened with -// warm capture in a home of many files, through the real engine, finishes its +// warm capture in a home of many files, through the real engine, seals its // first capture having planned none of the home's files and having written no -// plan that lists them. It counts paths and plan bytes; it never times. +// plan that lists them. A real tool call supplies the turn: warm preparation +// alone adds none. It counts paths and plan bytes; it never times. func TestStartupInALargeHomeWalksNoHome(t *testing.T) { const dirs, files = 100, 200 // 20,000 files house := t.TempDir() @@ -181,6 +182,11 @@ func TestStartupInALargeHomeWalksNoHome(t *testing.T) { if err != nil { t.Fatal(err) } + if err := seat.Around(t.Context(), executor.Call{Tool: "read"}, func() ([]byte, bool) { + return []byte("ok"), false + }); err != nil { + t.Fatal(err) + } if err := executor.Drain(t.Context(), seat); err != nil { t.Fatal(err) } diff --git a/internal/cellstore/seat.go b/internal/cellstore/seat.go index 71e34bfa6d..d4502af803 100644 --- a/internal/cellstore/seat.go +++ b/internal/cellstore/seat.go @@ -2,6 +2,8 @@ package cellstore import ( "context" + "os" + "runtime" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/chatfiles" @@ -35,6 +37,13 @@ func SeatOver(class executor.Class, c cell.Cell, workspace string, obs executor. for _, opt := range opts { opt(&engine) } + if runtime.GOOS == "windows" && engine.Transport == nil && engine.Run == nil { + bin, _ := (Spawn{Binary: engine.Binary}).program() + if socketFor(bin) == "" { + engine.notifyHistoryOff(c) + return executor.Watching(base, obs), nil + } + } if workspace != "" && engine.workspace(c) == "" { engine.Guard.notify(homeFolderWord) } @@ -56,6 +65,17 @@ func SeatOver(class executor.Class, c cell.Cell, workspace string, obs executor. // folder: the conversation is saved, the folder's files are not. const homeFolderWord = "this folder holds your home folder, so its files are not saved with this chat · rewind brings back the conversation only · open codeaf in a project folder to save its files" +// notifyHistoryOff records the sentence on the chat, so reopening a seat +// cannot repeat it. A notice must never make tools wait on saved history. +func (e Engine) notifyHistoryOff(c cell.Cell) { + f, err := os.OpenFile(rel(c, cell.StateDir+"/saving-engine-notice"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return + } + _ = f.Close() + e.Guard.notify("rewind is off because this chat cannot save file history securely; restart codeaf to try again.") +} + // SeatOption adjusts the engine a seat seals through. type SeatOption func(*Engine) diff --git a/internal/cellstore/socket_regression_unix_test.go b/internal/cellstore/socket_regression_unix_test.go new file mode 100644 index 0000000000..6bdc363bdc --- /dev/null +++ b/internal/cellstore/socket_regression_unix_test.go @@ -0,0 +1,251 @@ +//go:build !windows + +package cellstore + +import ( + "bufio" + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// F5/C7: A preclaimed short directory must not let an impersonator receive work. +// The lane-local temp base models a shared /tmp without involving another user. +func TestR9UnixShortSocketRejectsPreclaimedDirectory(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + for _, kind := range []string{"writable-directory", "symlink"} { + t.Run(kind, func(t *testing.T) { + bin := "/engine/furrow-review-" + kind + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), strings.Repeat("home", 30))) + socket := socketFor(bin) + dir := filepath.Dir(socket) + // Selection now creates the private base; replace it to model preclaiming. + if err := os.Remove(dir); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if !strings.HasPrefix(dir, os.TempDir()+string(filepath.Separator)) || len(socket) >= maxSocketBytes { + t.Fatalf("C7: fixture did not select its lane-local short fallback: %q", socket) + } + if _, err := os.Lstat(dir); !os.IsNotExist(err) { + t.Fatalf("C7: fixture directory already exists: %v", err) + } + if kind == "symlink" { + if err := os.Symlink(t.TempDir(), dir); err != nil { + t.Fatal(err) + } + } else { + if err := os.Mkdir(dir, 0777); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0777); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + requests := make(chan wireRequest, 2) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + reader := bufio.NewReader(conn) + for range 2 { + line, err := reader.ReadBytes('\n') + var req wireRequest + if err != nil || json.Unmarshal(line, &req) != nil { + return + } + requests <- req + ok, _ := json.Marshal(map[string]any{"engine": filepath.Base(bin), "impersonated": true}) + reply, _ := json.Marshal(wireResponse{ID: req.ID, Ok: ok}) + _, _ = conn.Write(append(reply, '\n')) + } + }() + out, err := (Daemon{Socket: socketFor(bin), Binary: bin}).Do(t.Context(), Target{Tree: t.TempDir()}, snapOp{}) + if err == nil && strings.Contains(string(out), `"impersonated":true`) { + t.Errorf("C7: daemon accepted an impersonated engine in %s and forwarded the operation: %s", kind, out) + } + if _, err := (Daemon{Socket: socket, Binary: bin}).Do(t.Context(), Target{Tree: t.TempDir()}, snapOp{}); err == nil { + t.Error("F5: a previously selected endpoint bypassed directory validation") + } + select { + case req := <-requests: + t.Errorf("C7: unsafe endpoint received %q before directory ownership/permission validation", req.Verb) + default: + } + }) + } +} + +// TestR9UnixShortSocketRejectsSocketSymlink proves F5 before any dial or prune. +func TestR9UnixShortSocketRejectsSocketSymlink(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), strings.Repeat("home", 30))) + bin := "/engine/furrow-review-socket-link" + socket := socketFor(bin) + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(socket)) }) + target := testSocket(t) + listener, err := net.Listen("unix", target) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + accepted := make(chan bool, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + conn.Close() + } + accepted <- err == nil + }() + if err := os.Symlink(target, socket); err != nil { + t.Fatal(err) + } + other := socketFor(bin) + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(other)) }) + if other == "" || other == socket || len(other) >= maxSocketBytes || other != socketFor(bin) { + t.Fatalf("F5: socket symlink did not select another stable bounded address: %q", other) + } + stale := filepath.Join(filepath.Dir(socket), "e-stale.sock") + if err := os.WriteFile(stale, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if _, err := (Daemon{Socket: socket, Binary: bin}).Do(ctx, Target{}, snapOp{}); err == nil { + t.Error("F5: socket symlink was accepted") + } + listener.Close() + if <-accepted { + t.Error("F5: socket symlink received a connection") + } + if _, err := os.Lstat(stale); err != nil { + t.Errorf("F5: an unsafe endpoint triggered stale pruning: %v", err) + } + if _, err := os.Lstat(stale + ".lock"); !os.IsNotExist(err) { + t.Errorf("F5: an unsafe endpoint opened a pruning lock: %v", err) + } +} + +// TestR9UnixRuntimeSocketPreference proves F5 without starting an engine. +func TestR9UnixRuntimeSocketPreference(t *testing.T) { + runtime, err := os.MkdirTemp("/tmp", "r9-runtime-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtime) }) + login, err := os.MkdirTemp("/tmp", "r9-home-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(login) }) + t.Setenv("CODEAF_HOME", login) + t.Setenv("XDG_RUNTIME_DIR", runtime) + bin := "/engine/furrow-review-runtime" + socket := socketFor(bin) + if !strings.HasPrefix(socket, runtime+string(filepath.Separator)) || socket != socketFor(bin) || len(socket) >= maxSocketBytes { + t.Fatalf("F5: private runtime directory was not preferred: %q", socket) + } + for _, path := range []string{runtime, filepath.Dir(socket)} { + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !info.IsDir() || info.Mode().Perm() != 0700 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Getuid()) { + t.Fatalf("F5: selected base is not private and owned: %s", info.Mode()) + } + } + alias := filepath.Join(t.TempDir(), "runtime-link") + if err := os.Symlink(runtime, alias); err != nil { + t.Fatal(err) + } + for _, unusable := range []string{"relative", alias, filepath.Join(runtime, "missing"), filepath.Join(runtime, strings.Repeat("long", 30))} { + t.Setenv("XDG_RUNTIME_DIR", unusable) + if got := socketFor(bin); got != filepath.Join(login, "v3", "engine-review-runtime.sock") { + t.Fatalf("F5: unusable runtime directory displaced the fitting home: %q", got) + } + } + if err := os.Chmod(runtime, 0777); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_RUNTIME_DIR", runtime) + if got := socketFor(bin); got != filepath.Join(login, "v3", "engine-review-runtime.sock") { + t.Fatalf("F5: writable runtime directory was accepted: %q", got) + } +} + +// F5/C1/C4/C7: A project below home still rewinds through a real, private fallback. +func TestR9ProjectChildRewindsThroughPrivateSocket(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) + defer cancel() + login := t.TempDir() + project := filepath.Join(login, "project") + if err := os.Mkdir(project, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", login) + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), strings.Repeat("state", 25))) + bin, err := sealingEngine() + if err != nil { + t.Fatal(err) + } + d := Daemon{Socket: socketFor(bin), Binary: bin} + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := d.Stop(ctx); err != nil { + t.Error(err) + } + }) + e := realEngine(t) + e.Workspace, e.Transport = project, d + c := newCell(t) + file := filepath.Join(project, "example.txt") + var first Sealed + for i, value := range []string{"before\n", "after\n"} { + if err := os.WriteFile(file, []byte(value), 0600); err != nil { + t.Fatal(err) + } + sealed, err := e.Seal(ctx, c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if err := durable(ctx, c.Root); err != nil { + t.Fatalf("C1: project seal did not reach durable storage: %v", err) + } + if i == 0 { + first = sealed + } + } + if _, err := e.Rewind(ctx, c, first.Turn.ID); err != nil { + t.Fatalf("C1: project below home lost rewind: %v", err) + } + if err := durable(ctx, c.Root); err != nil { + t.Fatalf("C1: project rewind did not reach durable storage: %v", err) + } + if raw, err := os.ReadFile(file); err != nil || string(raw) != "before\n" { + t.Fatalf("C1: rewind did not restore project file: %q %v", raw, err) + } + for path, mode := range map[string]os.FileMode{filepath.Dir(d.Socket): 0700, d.Socket: 0600} { + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + stat := info.Sys().(*syscall.Stat_t) + if info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != mode || stat.Uid != uint32(os.Getuid()) { + t.Errorf("C7: managed endpoint is not owner-only: %s mode=%s uid=%d", path, info.Mode(), stat.Uid) + } + } +} diff --git a/internal/cellstore/socket_regression_windows_test.go b/internal/cellstore/socket_regression_windows_test.go new file mode 100644 index 0000000000..499147206a --- /dev/null +++ b/internal/cellstore/socket_regression_windows_test.go @@ -0,0 +1,227 @@ +package cellstore + +import ( + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" + "golang.org/x/sys/windows" +) + +// TestR9WindowsUnavailableSocketsKeepToolsWorking proves F6 through the seat: +// denied or preclaimed short bases disable history without starting capture. +func TestR9WindowsUnavailableSocketsKeepToolsWorking(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(true)) + base := t.TempDir() + for range 20 { + base = filepath.Join(base, "subdir") + } + if err := os.MkdirAll(base, 0700); err != nil { + t.Fatal(err) + } + // The user's own temp is the only fallback; a short one here makes it a + // candidate that each case then preclaims or denies. + short := shortUserTemp(t) + t.Setenv("LOCALAPPDATA", base) + t.Setenv("TMP", short) + t.Setenv("TEMP", short) + for _, kind := range []string{"preclaimed", "denied"} { + t.Run(kind, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + bin := `C:\engine\furrow-r9-fixture.exe` + exercised := 0 + for attempt := 0; ; attempt++ { + socket := socketFor(bin) + if socket == "" { + break + } + exercised++ + if attempt == 3 { + t.Fatal("F6: selector did not exhaust the bounded candidates") + } + dir := filepath.Dir(socket) + if kind == "preclaimed" { + if err := os.Remove(dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir, []byte("preclaimed"), 0600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(dir) }) + } else { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Fatal(err) + } + // Owner Rights denies the normally implicit owner READ_CONTROL. + sd, err := windows.SecurityDescriptorFromString("D:P(D;;RC;;;OW)(A;OICI;FA;;;" + user.User.Sid.String() + ")") + if err != nil { + t.Fatal(err) + } + dacl, _, err := sd.DACL() + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo(dir, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + // The fixture denied READ_CONTROL, so its DACL cannot be put back; the + // parent temp folder's delete-child right removes the empty directory. + if err := os.Remove(dir); err != nil { + t.Error(err) + } + }) + } + if err := privateSocketDir(dir); err == nil { + t.Fatalf("F6: %s candidate remained usable", kind) + } + } + if exercised == 0 { + t.Fatalf("F6: no usable %s candidate to take away; the fixture tested nothing", kind) + } + c := newCell(t) + project := t.TempDir() + var notices []string + reports := make(chan error, 8) + store := blockingCapture{make(chan struct{}, 1), make(chan struct{}), make(chan TurnInfo, 1)} + defer close(store.release) + for range 2 { + var err error + c, err = cell.OpenAt(c.Root, c.ID) + if err != nil { + t.Fatal(err) + } + opened := false + seat, err := SeatOver(executor.HostBound, c, project, nil, func(err error) { reports <- err }, func(Engine) Store { + opened = true + return store + }, func(e *Engine) { e.Binary = bin }, WithWarmCapture(), WithPipeline(), WithNotices(func(line string) { notices = append(notices, line) })) + if err != nil || opened { + t.Fatalf("F6: unavailable endpoint opened capture: opened=%v err=%v", opened, err) + } + for _, tool := range []string{"ls", "read", "ls"} { + ran := false + err := seat.Around(t.Context(), executor.Call{Tool: tool}, func() ([]byte, bool) { ran = true; return []byte("ok"), false }) + if err != nil || !ran { + t.Fatalf("F6: %s blocked: ran=%v err=%v", tool, ran, err) + } + } + executor.Settle(t.Context(), seat) + if err := executor.Drain(t.Context(), seat); err != nil { + t.Fatal(err) + } + } + want := "rewind is off because this chat cannot save file history securely; restart codeaf to try again." + if len(notices) != 1 || notices[0] != want { + t.Fatalf("F6: notices across reopen = %q, want one plain sentence", notices) + } + select { + case err := <-reports: + t.Fatalf("F6: history off reported a saving outcome: %v", err) + default: + } + }) + } +} + +// TestR8WindowsDeepLocalAppDataSocketBudget proves F2 beyond short-name conversion. +// Each nested component already fits 8.3, so a short alias cannot bound depth. +func TestR8WindowsDeepLocalAppDataSocketBudget(t *testing.T) { + base := t.TempDir() + for range 20 { + base = filepath.Join(base, "subdir") + } + if err := os.MkdirAll(base, 0700); err != nil { + t.Fatal(err) + } + homes := []string{t.TempDir(), t.TempDir()} + short := shortUserTemp(t) + for _, profile := range []string{base, filepath.Join(base, strings.Repeat("é", 40), "missing")} { + t.Run(filepath.Base(profile), func(t *testing.T) { + t.Setenv("LOCALAPPDATA", profile) + t.Setenv("TMP", short) + t.Setenv("TEMP", short) + t.Setenv("CODEAF_HOME", homes[0]) + address := func(bin string) string { + socket := socketFor(bin) + if socket != "" { + // These home/build identities belong only to this fixture. + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(socket)) }) + } + return socket + } + socket := address(`C:\engine\furrow.exe`) + if !filepath.IsAbs(socket) || len(socket) >= 108 { + t.Fatalf("F2: invalid AF_UNIX socket %q (%d bytes), want absolute and <108", socket, len(socket)) + } + if socket != address(`C:\engine\furrow.exe`) || socket == address(`C:\engine\furrow-next.exe`) { + t.Fatal("F2: short fallback lost stable build identity") + } + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatalf("F2: shortened address cannot bind: %v", err) + } + defer listener.Close() + conn, err := net.Dial("unix", socket) + if err != nil { + t.Fatalf("F2: shortened address cannot dial: %v", err) + } + conn.Close() + t.Setenv("CODEAF_HOME", homes[1]) + if socket == address(`C:\engine\furrow.exe`) { + t.Fatal("F2: short fallback lost home identity") + } + }) + } +} + +// C1/C3: Common Windows spellings of the same home retain guard/socket identity. +func TestR8WindowsHomeCaseTrailingSeparators(t *testing.T) { + login := t.TempDir() + t.Setenv("USERPROFILE", login) + t.Setenv("LOCALAPPDATA", t.TempDir()) + t.Setenv("CODEAF_HOME", login) + a := socketFor(`C:\engine\furrow.exe`) + for _, p := range []string{strings.ToUpper(login) + `\`, strings.ToLower(login) + `\`} { + if !holdsHome(p) { + t.Errorf("C1: home spelling accepted: %s", p) + } + t.Setenv("CODEAF_HOME", p) + if socketFor(`C:\engine\furrow.exe`) != a { + t.Errorf("C3: home spelling changed socket: %s", p) + } + } +} + +// shortUserTemp is a short directory in the user's own temp folder: the one +// fallback base a deep LOCALAPPDATA may use. +func shortUserTemp(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp(os.TempDir(), "r9") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +// C4: A configured drive root is never the user's private temp folder. +func TestW3WindowsFallbackRequiresUserTemp(t *testing.T) { + base := filepath.Join(t.TempDir(), strings.Repeat("subdir\\", 20)) + if err := os.MkdirAll(base, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("LOCALAPPDATA", base) + t.Setenv("CODEAF_HOME", t.TempDir()) + root := filepath.VolumeName(base) + string(filepath.Separator) + t.Setenv("TMP", root) + t.Setenv("TEMP", root) + if got := socketFor(`C:\engine\furrow-root-temp.exe`); got != "" { + t.Fatalf("C4: drive-root temp selected an endpoint: %q", got) + } +} diff --git a/internal/cellstore/socket_unix.go b/internal/cellstore/socket_unix.go index d5f53fb454..ac4e86183c 100644 --- a/internal/cellstore/socket_unix.go +++ b/internal/cellstore/socket_unix.go @@ -5,28 +5,90 @@ package cellstore import ( "crypto/sha256" "fmt" - "github.com/Agent-Field/codeaf/internal/home" "os" "path/filepath" "strings" + "syscall" + + "github.com/Agent-Field/codeaf/internal/home" ) -// socketFor keys the daemon to its engine build, preserving Unix socket names. +// socketFor keys the daemon to its absolute home and engine build. func socketFor(bin string) string { - socket := home.Join("v3", "engine-"+strings.TrimPrefix(filepath.Base(bin), "furrow-")+".sock") + root := canonicalTree(home.Dir()) + sum := sha256.Sum256([]byte(root + "\x00" + bin)) + name := fmt.Sprintf("codeaf-e-%d-%x", os.Getuid(), sum[:12]) + if runtime := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtime) && ownedSocketDir(runtime) == nil { + socket := filepath.Join(runtime, name, "e.sock") + if len(socket) < maxSocketBytes && privateSocketDir(filepath.Dir(socket)) == nil && validateSocket(socket) == nil { + return socket + } + } + socket := filepath.Join(root, "v3", "engine-"+strings.TrimPrefix(filepath.Base(bin), "furrow-")+".sock") if len(socket) < maxSocketBytes { return socket } // Long project/test homes still share one managed daemon. Unix kernels count // the address bytes rather than the resolved path, so a short temp alias is // required even when the home directory itself is valid (U1/U4). - sum := sha256.Sum256([]byte(home.Dir() + "\x00" + bin)) - name := fmt.Sprintf("codeaf-e-%d-%x", os.Getuid(), sum[:12]) - base := os.TempDir() - if len(filepath.Join(base, name, "e.sock")) >= maxSocketBytes { - base = "/tmp" + for _, base := range []string{os.TempDir(), "/tmp"} { + for candidate := range shortSocketCandidates { + dir := name + if candidate > 0 { + dir += fmt.Sprintf("-%d", candidate) + } + socket := filepath.Join(base, dir, "e.sock") + if filepath.IsAbs(socket) && len(socket) < maxSocketBytes && privateSocketDir(filepath.Dir(socket)) == nil && validateSocket(socket) == nil { + return socket + } + } + } + return "" +} + +const shortSocketCandidates = 4 + +// privateSocketDir never repairs a preclaimed directory into acceptance. +func privateSocketDir(dir string) error { + if err := os.Mkdir(dir, 0700); err != nil && !os.IsExist(err) { + return err + } + return ownedSocketDir(dir) +} + +func ownedSocketDir(dir string) error { + info, err := os.Lstat(dir) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.IsDir() || info.Mode().Perm() != 0700 || !ok || stat.Uid != uint32(os.Getuid()) { + return fmt.Errorf("saving-engine socket directory is not private") + } + return nil +} + +// validateSocket rechecks short endpoints before dialing or pruning them. +func validateSocket(socket string) error { + dir := filepath.Dir(socket) + if !strings.HasPrefix(filepath.Base(dir), "codeaf-e-") { + return nil + } + if err := ownedSocketDir(dir); err != nil { + return err + } + info, err := os.Lstat(socket) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if info.Mode()&os.ModeSymlink != 0 || !ok || stat.Uid != uint32(os.Getuid()) { + return fmt.Errorf("saving-engine socket is not private") } - return filepath.Join(base, name, "e.sock") + return nil } // maxSocketBytes leaves room for the terminator on both Linux and macOS. diff --git a/internal/cellstore/socket_unix_test.go b/internal/cellstore/socket_unix_test.go index b3d27c6a7b..c20d21e158 100644 --- a/internal/cellstore/socket_unix_test.go +++ b/internal/cellstore/socket_unix_test.go @@ -3,6 +3,8 @@ package cellstore import ( + "os" + "path/filepath" "strings" "testing" ) @@ -20,3 +22,28 @@ func TestU1LongHomesStillUseOneManagedDaemon(t *testing.T) { t.Fatal("different homes shared a private socket") } } + +// TestR8UnixDeepTempSocketBudget proves F2 when both home and temp are long. +func TestR8UnixDeepTempSocketBudget(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + base := t.TempDir() + for range 20 { + base = filepath.Join(base, "subdir") + } + if err := os.MkdirAll(base, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("TMPDIR", base) + t.Setenv("CODEAF_HOME", filepath.Join(base, "home")) + first := socketFor("/engine/furrow-a") + if len(first) >= 100 || first != socketFor("/engine/furrow-a") { + t.Fatalf("F2: unstable or over-budget socket: %q (%d bytes)", first, len(first)) + } + if first == socketFor("/engine/furrow-b") { + t.Fatal("F2: short fallback lost build identity") + } + t.Setenv("CODEAF_HOME", filepath.Join(base, "second-home")) + if first == socketFor("/engine/furrow-a") { + t.Fatal("F2: short fallback lost home identity") + } +} diff --git a/internal/cellstore/socket_windows.go b/internal/cellstore/socket_windows.go index 640743231a..5251081e62 100644 --- a/internal/cellstore/socket_windows.go +++ b/internal/cellstore/socket_windows.go @@ -3,25 +3,119 @@ package cellstore import ( "crypto/sha256" "fmt" - "golang.org/x/sys/windows" "os" "path/filepath" + "strings" + "unsafe" + + "github.com/Agent-Field/codeaf/internal/home" + "golang.org/x/sys/windows" ) +const maxSocketBytes = 108 + // socketFor puts the daemon under the user's local profile. Hashing the full -// binary path isolates both engine upgrades and explicit development engines, -// while keeping the AF_UNIX address below its 108-byte ceiling (K2). +// home and binary path isolates homes, engine upgrades and development engines. func socketFor(bin string) string { base := os.Getenv("LOCALAPPDATA") if base == "" { base, _ = os.UserCacheDir() } - if short, err := windows.UTF16PtrFromString(base); err == nil { + root := strings.ToLower(canonicalTree(home.Dir())) + sum := sha256.Sum256([]byte(root + "\x00" + bin)) + socket := filepath.Join(shortSocketBase(base), "codeaf", fmt.Sprintf("e-%x.sock", sum[:8])) + if base != "" && len(socket) < maxSocketBytes { + return socket + } + // Short names cannot bound directory depth. Keep a private, home/build- + // keyed directory under the user's own temp folder, never a shared system + // folder or a drive root. With no bounded private place, saved history turns + // itself off for this engine with one sentence instead of failing each seal. + temp := shortSocketBase(os.TempDir()) + if !filepath.IsAbs(temp) || filepath.Dir(temp) == filepath.Clean(temp) || ownedSocketDir(temp) != nil { + return "" + } + socket = filepath.Join(temp, fmt.Sprintf("codeaf-e-%x", sum[:8]), fmt.Sprintf("e-%x.sock", sum[:8])) + if filepath.IsAbs(socket) && len(socket) < maxSocketBytes && privateSocketDir(filepath.Dir(socket)) == nil { + return socket + } + return "" +} + +func shortSocketBase(base string) string { + if path, err := windows.UTF16PtrFromString(base); err == nil { buf := make([]uint16, 32768) - if n, err := windows.GetShortPathName(short, &buf[0], uint32(len(buf))); err == nil && n < uint32(len(buf)) { - base = windows.UTF16ToString(buf[:n]) + if n, err := windows.GetShortPathName(path, &buf[0], uint32(len(buf))); err == nil && n > 0 && n < uint32(len(buf)) { + return windows.UTF16ToString(buf[:n]) } } - sum := sha256.Sum256([]byte(bin)) - return filepath.Join(base, "codeaf", fmt.Sprintf("e-%x.sock", sum[:8])) + return base +} + +// Windows checks the private fallback while selecting the socket address. +func validateSocket(string) error { + return nil +} + +// privateSocketDir protects a short fallback before either dialing or pruning. +// Windows ignores Mkdir's mode, so creation needs a non-inherited user ACL. +func privateSocketDir(dir string) error { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return err + } + sd, err := windows.SecurityDescriptorFromString("O:" + user.User.Sid.String() + "D:P(A;OICI;FA;;;" + user.User.Sid.String() + ")") + if err != nil { + return err + } + path, err := windows.UTF16PtrFromString(dir) + if err != nil { + return err + } + attrs := windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd} + if err := windows.CreateDirectory(path, &attrs); err != nil && err != windows.ERROR_ALREADY_EXISTS { + return err + } + if err := ownedSocketDir(dir); err != nil { + return err + } + dacl, _, err := sd.DACL() + if err != nil { + return err + } + return windows.SetNamedSecurityInfo(dir, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil) +} + +// ownedSocketDir rejects shared temp bases and preclaimed fallback directories. +func ownedSocketDir(dir string) error { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return err + } + path, err := windows.UTF16PtrFromString(dir) + if err != nil { + return err + } + flags, err := windows.GetFileAttributes(path) + if err != nil { + return err + } + if flags&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || flags&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return fmt.Errorf("saving-engine socket directory is not private") + } + existing, err := windows.GetNamedSecurityInfo(dir, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("saving-engine socket directory has no owner") + } + owner, _, err := existing.Owner() + if err != nil { + return err + } + if owner == nil || !owner.Equals(user.User.Sid) { + return fmt.Errorf("saving-engine socket directory belongs to another user") + } + return nil } diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index aee27f4ce1..9391378187 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -1007,6 +1007,10 @@ Chats already open in this window switch directly. ## Saving stopped or could not save this chat +Each codeaf home shares its own local saving engine, separate from other homes. +If its connection closes, the next save reconnects, starting that engine again +if needed. An interrupted operation is not replayed. + If the storage process is alive but its socket has disappeared, codeaf says “saving is stuck”. Your turns stay on this computer. When codeaf can safely replace its own unreachable storage process, saving resumes with a fresh one. @@ -1024,3 +1028,14 @@ does not clear this chat's saving failure. If closing cannot finish saving, the error names the local transcript that keeps the unsaved turns. Keep that file: after storage becomes reachable again, the turns can be saved and published, or kept separately when you continue the chat. + +## Rewind is off when Windows cannot save file history securely + +If Windows cannot provide a private local saving connection within its address +limit, codeaf turns saved file history off for that chat and says once: + +> rewind is off because this chat cannot save file history securely; restart codeaf to try again. + +Tools keep working and the conversation still saves normally. Reopening the chat +does not repeat the sentence. Restart codeaf to try saving file history again. +Ordinary project chats with a usable saving connection keep their history. From 0243b472629480d7f1ae86ea8d4e209b225e0743 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 28 Oct 2026 16:29:20 -0400 Subject: [PATCH 2/3] cellstore: an upgraded engine keeps the saving address earlier engines used The Unix selector preferred $XDG_RUNTIME_DIR, so after an upgrade a home whose daemon an older engine had started got a second daemon on a new address for the same store. A home whose own address fits keeps it again; a long home keeps the short alias earlier engines used while it is a private directory of this user, and only a preclaimed alias falls to the private runtime directory and then other private candidates. A regression starts the old daemon, upgrades the client and checks both seals share one daemon. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2017-home-owned-furrow-sockets.md | 8 +- .../cellstore/socket_regression_unix_test.go | 123 +++++++++++++++--- internal/cellstore/socket_unix.go | 44 +++++-- 3 files changed, 142 insertions(+), 33 deletions(-) diff --git a/docs/changes/unreleased/2017-home-owned-furrow-sockets.md b/docs/changes/unreleased/2017-home-owned-furrow-sockets.md index 738b6d512d..54795bf5e1 100644 --- a/docs/changes/unreleased/2017-home-owned-furrow-sockets.md +++ b/docs/changes/unreleased/2017-home-owned-furrow-sockets.md @@ -10,9 +10,11 @@ invalidates: - "An unset HOME or the original ancestor of a symlinked home could bypass the home-folder policy. Both now seal only the chat's own folder." --- -Same-home clients keep sharing their saving daemon. Unix prefers the user's -private runtime directory, then a fitting in-home address, then validated private -temporary directories. Windows falls back only under the user's own temporary +Same-home clients keep sharing their saving daemon, also across an upgrade. +Unix keeps a fitting in-home address; a long home keeps the short temporary +address earlier engines used while it is this user's private directory, and only +a preclaimed one falls to the user's private runtime directory or other +validated private temporary directories. Windows falls back only under the user's own temporary folder with a private ACL; final addresses stay below the platform byte limit. Stale endpoints are pruned only while holding their daemon's lifetime lock, inside the existing per-socket start gate. A lost answer is never replayed and diff --git a/internal/cellstore/socket_regression_unix_test.go b/internal/cellstore/socket_regression_unix_test.go index 6bdc363bdc..da30c7e384 100644 --- a/internal/cellstore/socket_regression_unix_test.go +++ b/internal/cellstore/socket_regression_unix_test.go @@ -5,7 +5,9 @@ package cellstore import ( "bufio" "context" + "crypto/sha256" "encoding/json" + "fmt" "net" "os" "path/filepath" @@ -13,6 +15,8 @@ import ( "syscall" "testing" "time" + + "github.com/Agent-Field/codeaf/internal/home" ) // F5/C7: A preclaimed short directory must not let an impersonator receive work. @@ -139,8 +143,11 @@ func TestR9UnixShortSocketRejectsSocketSymlink(t *testing.T) { } } -// TestR9UnixRuntimeSocketPreference proves F5 without starting an engine. -func TestR9UnixRuntimeSocketPreference(t *testing.T) { +// TestR9UnixShortSocketOrder proves F5's order without starting an engine: a home +// whose own address fits keeps it (an upgraded engine shares a live daemon), a long +// home keeps the alias earlier engines used while it is private, and only a +// preclaimed alias falls to the private runtime directory. +func TestR9UnixShortSocketOrder(t *testing.T) { runtime, err := os.MkdirTemp("/tmp", "r9-runtime-") if err != nil { t.Fatal(err) @@ -154,9 +161,36 @@ func TestR9UnixRuntimeSocketPreference(t *testing.T) { t.Setenv("CODEAF_HOME", login) t.Setenv("XDG_RUNTIME_DIR", runtime) bin := "/engine/furrow-review-runtime" + if got := socketFor(bin); got != filepath.Join(login, "v3", "engine-review-runtime.sock") { + t.Fatalf("F5: a fitting home's own address was displaced: %q", got) + } + long := filepath.Join(login, strings.Repeat("long-home-", 12)) + if err := os.MkdirAll(long, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_HOME", long) + sum := sha256.Sum256([]byte(home.Dir() + "\x00" + bin)) + base := os.TempDir() + alias := filepath.Join(base, fmt.Sprintf("codeaf-e-%d-%x", os.Getuid(), sum[:12])) + if len(filepath.Join(alias, "e.sock")) >= maxSocketBytes { + alias = filepath.Join("/tmp", filepath.Base(alias)) + } + t.Cleanup(func() { _ = os.RemoveAll(alias) }) + if got := socketFor(bin); filepath.Dir(got) != alias || got != socketFor(bin) { + t.Fatalf("F5: a long home left the alias earlier engines used: %q", got) + } + if err := os.RemoveAll(alias); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(alias, 0700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(alias, 0777); err != nil { + t.Fatal(err) + } socket := socketFor(bin) - if !strings.HasPrefix(socket, runtime+string(filepath.Separator)) || socket != socketFor(bin) || len(socket) >= maxSocketBytes { - t.Fatalf("F5: private runtime directory was not preferred: %q", socket) + if !strings.HasPrefix(socket, runtime+string(filepath.Separator)) || len(socket) >= maxSocketBytes { + t.Fatalf("F5: a preclaimed alias did not fall to the private runtime directory: %q", socket) } for _, path := range []string{runtime, filepath.Dir(socket)} { info, err := os.Lstat(path) @@ -167,23 +201,16 @@ func TestR9UnixRuntimeSocketPreference(t *testing.T) { t.Fatalf("F5: selected base is not private and owned: %s", info.Mode()) } } - alias := filepath.Join(t.TempDir(), "runtime-link") - if err := os.Symlink(runtime, alias); err != nil { + link := filepath.Join(t.TempDir(), "runtime-link") + if err := os.Symlink(runtime, link); err != nil { t.Fatal(err) } - for _, unusable := range []string{"relative", alias, filepath.Join(runtime, "missing"), filepath.Join(runtime, strings.Repeat("long", 30))} { + for _, unusable := range []string{"relative", link, filepath.Join(runtime, "missing")} { t.Setenv("XDG_RUNTIME_DIR", unusable) - if got := socketFor(bin); got != filepath.Join(login, "v3", "engine-review-runtime.sock") { - t.Fatalf("F5: unusable runtime directory displaced the fitting home: %q", got) + if got := socketFor(bin); got == "" || strings.HasPrefix(got, unusable) || filepath.Dir(got) == alias { + t.Fatalf("F5: an unusable runtime directory or the preclaimed alias was used: %q", got) } } - if err := os.Chmod(runtime, 0777); err != nil { - t.Fatal(err) - } - t.Setenv("XDG_RUNTIME_DIR", runtime) - if got := socketFor(bin); got != filepath.Join(login, "v3", "engine-review-runtime.sock") { - t.Fatalf("F5: writable runtime directory was accepted: %q", got) - } } // F5/C1/C4/C7: A project below home still rewinds through a real, private fallback. @@ -249,3 +276,67 @@ func TestR9ProjectChildRewindsThroughPrivateSocket(t *testing.T) { } } } + +// C1: an engine upgraded beside a live daemon of the same long home shares it, +// even with a private runtime directory available. +func TestR11UnixUpgradeSharesExistingFallback(t *testing.T) { + bin, err := sealingEngine() + if err != nil { + t.Fatal(err) + } + root := filepath.Join(t.TempDir(), strings.Repeat("long-home-", 15)) + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_HOME", root) + runtime := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", runtime) + + // This is d90273989's exact long-home address selection, before this port. + sum := sha256.Sum256([]byte(home.Dir() + "\x00" + bin)) + name := fmt.Sprintf("codeaf-e-%d-%x", os.Getuid(), sum[:12]) + base := os.TempDir() + if len(filepath.Join(base, name, "e.sock")) >= maxSocketBytes { + base = "/tmp" + } + old := Daemon{Socket: filepath.Join(base, name, "e.sock"), Binary: bin} + // Upstream launch creates this directory before starting its old daemon. + if err := os.MkdirAll(filepath.Dir(old.Socket), 0700); err != nil { + t.Fatal(err) + } + newSocket := socketFor(bin) + next := Daemon{Socket: newSocket, Binary: bin} + for _, daemon := range []Daemon{old, next} { + d := daemon + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := d.Stop(ctx); err != nil { + t.Errorf("stop review daemon: %v", err) + } + _ = os.RemoveAll(filepath.Dir(d.Socket)) + }) + } + e := engineFor(t, filepath.Join(root, "v3", "stores")) + e.Workspace, e.Transport = t.TempDir(), old + c := newCell(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if _, err := e.Seal(ctx, c, TurnInfo{}); err != nil { + t.Fatalf("old client fixture could not seal: %v", err) + } + if err := durable(ctx, c.Root); err != nil { + t.Fatal(err) + } + pid := homeDaemonPID(t, old) + e.Transport = transportFor(bin, nil) + _, sealErr := e.Seal(ctx, c, TurnInfo{}) + nextPID := homeDaemonPID(t, next) + t.Logf("old=%s pid=%d; selected=%s pid=%d; seal=%v", old.Socket, pid, newSocket, nextPID, sealErr) + if sealErr != nil { + t.Errorf("C1/C3: upgraded same-home client failed to seal while its old daemon remains live: %v", sealErr) + } + if pid != nextPID { + t.Errorf("C1: one home/build started two daemons across the port: %d and %d", pid, nextPID) + } +} diff --git a/internal/cellstore/socket_unix.go b/internal/cellstore/socket_unix.go index ac4e86183c..4e5d39694f 100644 --- a/internal/cellstore/socket_unix.go +++ b/internal/cellstore/socket_unix.go @@ -13,32 +13,48 @@ import ( "github.com/Agent-Field/codeaf/internal/home" ) -// socketFor keys the daemon to its absolute home and engine build. +// socketFor keys the daemon to its home and engine build. A home whose own +// address fits keeps it (the same file earlier engines used, spelled through the +// home's canonical path so every alias of one home shares one start lock), so an +// upgraded engine shares the daemon an older one started. func socketFor(bin string) string { - root := canonicalTree(home.Dir()) - sum := sha256.Sum256([]byte(root + "\x00" + bin)) + socket := filepath.Join(canonicalTree(home.Dir()), "v3", "engine-"+strings.TrimPrefix(filepath.Base(bin), "furrow-")+".sock") + if len(socket) < maxSocketBytes { + return socket + } + // Long project/test homes still share one managed daemon. Unix kernels count + // the address bytes rather than the resolved path, so a short alias is + // required even when the home directory itself is valid (U1/U4). The alias + // earlier engines used comes first, so an upgrade beside a live daemon shares + // it; every short base must be a private directory of this user, and a + // preclaimed one is skipped for the runtime directory or another candidate. + sum := sha256.Sum256([]byte(home.Dir() + "\x00" + bin)) name := fmt.Sprintf("codeaf-e-%d-%x", os.Getuid(), sum[:12]) - if runtime := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtime) && ownedSocketDir(runtime) == nil { - socket := filepath.Join(runtime, name, "e.sock") - if len(socket) < maxSocketBytes && privateSocketDir(filepath.Dir(socket)) == nil && validateSocket(socket) == nil { + legacy := os.TempDir() + if len(filepath.Join(legacy, name, "e.sock")) >= maxSocketBytes { + legacy = "/tmp" + } + short := func(base, dir string) string { + socket := filepath.Join(base, dir, "e.sock") + if filepath.IsAbs(socket) && len(socket) < maxSocketBytes && privateSocketDir(filepath.Dir(socket)) == nil && validateSocket(socket) == nil { return socket } + return "" } - socket := filepath.Join(root, "v3", "engine-"+strings.TrimPrefix(filepath.Base(bin), "furrow-")+".sock") - if len(socket) < maxSocketBytes { + if socket := short(legacy, name); socket != "" { return socket } - // Long project/test homes still share one managed daemon. Unix kernels count - // the address bytes rather than the resolved path, so a short temp alias is - // required even when the home directory itself is valid (U1/U4). - for _, base := range []string{os.TempDir(), "/tmp"} { + var bases []string + if runtime := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtime) && ownedSocketDir(runtime) == nil { + bases = append(bases, runtime) + } + for _, base := range append(bases, os.TempDir(), "/tmp") { for candidate := range shortSocketCandidates { dir := name if candidate > 0 { dir += fmt.Sprintf("-%d", candidate) } - socket := filepath.Join(base, dir, "e.sock") - if filepath.IsAbs(socket) && len(socket) < maxSocketBytes && privateSocketDir(filepath.Dir(socket)) == nil && validateSocket(socket) == nil { + if socket := short(base, dir); socket != "" { return socket } } From 649220d11a3185b8cee75b708dc2e9a1bde472d9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 28 Oct 2026 16:58:53 -0400 Subject: [PATCH 3/3] cellstore: every spelling of a long home shares one short saving address The Unix short alias hashed the home as spelled, so a long home reached through a relative path or a link got its own daemon beside the one its absolute spelling started. It is keyed by the canonical home now, which for an absolute home without links is the key earlier engines used. The tests that need a real engine skip, like the package's others, when the build has none. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/cellstore/daemonhome_test.go | 2 +- internal/cellstore/socket_regression_unix_test.go | 7 ++++--- internal/cellstore/socket_unix.go | 7 +++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/cellstore/daemonhome_test.go b/internal/cellstore/daemonhome_test.go index c5b441aa10..7ba32a7389 100644 --- a/internal/cellstore/daemonhome_test.go +++ b/internal/cellstore/daemonhome_test.go @@ -61,7 +61,7 @@ func mustWorkingDir(t *testing.T) string { func TestW3HomeDaemonsSealConcurrentlyAndRestart(t *testing.T) { bin, err := sealingEngine() if err != nil { - t.Fatal(err) + t.Skipf("no engine binary: %v", err) } t.Setenv("LOCALAPPDATA", filepath.Dir(testSocket(t))) engines := make([]Engine, 2) diff --git a/internal/cellstore/socket_regression_unix_test.go b/internal/cellstore/socket_regression_unix_test.go index da30c7e384..f34ab23a98 100644 --- a/internal/cellstore/socket_regression_unix_test.go +++ b/internal/cellstore/socket_regression_unix_test.go @@ -226,7 +226,7 @@ func TestR9ProjectChildRewindsThroughPrivateSocket(t *testing.T) { t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), strings.Repeat("state", 25))) bin, err := sealingEngine() if err != nil { - t.Fatal(err) + t.Skipf("no engine binary: %v", err) } d := Daemon{Socket: socketFor(bin), Binary: bin} t.Cleanup(func() { @@ -282,9 +282,10 @@ func TestR9ProjectChildRewindsThroughPrivateSocket(t *testing.T) { func TestR11UnixUpgradeSharesExistingFallback(t *testing.T) { bin, err := sealingEngine() if err != nil { - t.Fatal(err) + t.Skipf("no engine binary: %v", err) } - root := filepath.Join(t.TempDir(), strings.Repeat("long-home-", 15)) + // An absolute home without links: earlier engines hashed it as spelled. + root := filepath.Join(canonicalTree(t.TempDir()), strings.Repeat("long-home-", 15)) if err := os.MkdirAll(root, 0700); err != nil { t.Fatal(err) } diff --git a/internal/cellstore/socket_unix.go b/internal/cellstore/socket_unix.go index 4e5d39694f..c037821922 100644 --- a/internal/cellstore/socket_unix.go +++ b/internal/cellstore/socket_unix.go @@ -18,7 +18,8 @@ import ( // home's canonical path so every alias of one home shares one start lock), so an // upgraded engine shares the daemon an older one started. func socketFor(bin string) string { - socket := filepath.Join(canonicalTree(home.Dir()), "v3", "engine-"+strings.TrimPrefix(filepath.Base(bin), "furrow-")+".sock") + root := canonicalTree(home.Dir()) + socket := filepath.Join(root, "v3", "engine-"+strings.TrimPrefix(filepath.Base(bin), "furrow-")+".sock") if len(socket) < maxSocketBytes { return socket } @@ -28,7 +29,9 @@ func socketFor(bin string) string { // earlier engines used comes first, so an upgrade beside a live daemon shares // it; every short base must be a private directory of this user, and a // preclaimed one is skipped for the runtime directory or another candidate. - sum := sha256.Sum256([]byte(home.Dir() + "\x00" + bin)) + // The alias is keyed by the canonical home, so every spelling of one home + // shares it; an absolute home without links keeps the key earlier engines used. + sum := sha256.Sum256([]byte(root + "\x00" + bin)) name := fmt.Sprintf("codeaf-e-%d-%x", os.Getuid(), sum[:12]) legacy := os.TempDir() if len(filepath.Join(legacy, name, "e.sock")) >= maxSocketBytes {