Remove global socket fallback, enforce local-only daemons

- Remove ~/.beads/bd.sock fallback in getSocketPath()
- Always return local socket path (.beads/bd.sock)
- Add migration warning if old global socket exists
- Update AGENTS.md to remove global daemon references
- Document breaking change in CHANGELOG.md
- Fix test isolation: tests now use temp .beads dir and chdir

Prevents cross-project daemon connections and database pollution.
Each project must use its own local daemon.

Amp-Thread-ID: https://ampcode.com/threads/T-c4454192-39c6-4c67-96a9-675cbfc4db92
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-21 20:35:28 -07:00
parent 3e44951f15
commit e1a445afd2
5 changed files with 205 additions and 43 deletions

View File

@@ -42,13 +42,10 @@ See `integrations/beads-mcp/README.md` for complete documentation.
### Multi-Repo Configuration (MCP Server)
**RECOMMENDED: Use a single MCP server with global daemon** for all beads repositories.
**RECOMMENDED: Use a single MCP server with per-project local daemons** for all beads repositories.
**Setup (one-time):**
```bash
# Start global daemon (or it will auto-start on first bd command)
bd daemon --global
# MCP config in ~/.config/amp/settings.json or Claude Desktop config:
{
"beads": {
@@ -61,15 +58,14 @@ bd daemon --global
**How it works:**
The single MCP server instance automatically:
1. Checks for local daemon socket (`.beads/bd.sock`) in your current workspace (Windows note: this file stores the loopback TCP endpoint used by the daemon)
2. Falls back to global daemon socket (`~/.beads/bd.sock`)
3. Routes requests to the correct database based on your current working directory
4. Auto-starts the daemon if it's not running (with exponential backoff on failures)
5. Auto-detects multiple repositories and prefers global daemon when 4+ repos are found
2. Routes requests to the correct database based on your current working directory
3. Auto-starts the local daemon if it's not running (with exponential backoff on failures)
4. Each project gets its own isolated daemon serving only its database
**Why this is better than multiple MCP servers:**
**Why this is better:**
- ✅ One config entry works for all your beads projects
- ✅ No risk of AI selecting wrong MCP server for workspace
-Better resource usage (one daemon instead of multiple)
-Complete database isolation per project
- ✅ Automatic workspace detection without BEADS_WORKING_DIR
**Note:** The daemon **auto-starts automatically** when you run any `bd` command (v0.9.11+). To disable auto-start, set `BEADS_AUTO_START_DAEMON=false`.
@@ -94,15 +90,6 @@ If you must use separate MCP servers (not recommended):
```
⚠️ **Problem**: AI may select the wrong MCP server for your workspace, causing commands to operate on the wrong database.
**Migration helper:**
```bash
# Migrate from local to global daemon
bd daemon --migrate-to-global
# Or set environment variable for permanent preference
export BEADS_PREFER_GLOBAL_DAEMON=1
```
### CLI Quick Reference
If you're not using the MCP server, here are the CLI commands:
@@ -160,13 +147,6 @@ bd restore <id> # View full history at time of compaction
# Import with collision detection
bd import -i .beads/issues.jsonl --dry-run # Preview only
bd import -i .beads/issues.jsonl --resolve-collisions # Auto-resolve
# Multi-repo management (requires global daemon)
bd repos list # List all cached repositories
bd repos ready # View ready work across all repos
bd repos ready --group # Group by repository
bd repos stats # Combined statistics
bd repos clear-cache # Clear repository cache
```
### Workflow

View File

@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **BREAKING**: Removed global daemon socket fallback (bd-231)
- Each project now must use its own local daemon (.beads/bd.sock)
- Prevents cross-project daemon connections and database pollution
- Migration: Stop any global daemon and restart with `bd daemon` in each project
- Warning displayed if old global socket (~/.beads/bd.sock) is found
## [0.10.0] - 2025-10-20
### Added

View File

@@ -187,12 +187,12 @@ func TestGetSocketPath(t *testing.T) {
}
})
t.Run("falls back to global socket", func(t *testing.T) {
t.Run("always returns local socket path", func(t *testing.T) {
// Ensure no local socket exists
localSocket := filepath.Join(beadsDir, "bd.sock")
os.Remove(localSocket)
// Create global socket
// Even with global socket present, should return local socket
home, err := os.UserHomeDir()
if err != nil {
t.Skip("Cannot get home directory")
@@ -208,9 +208,10 @@ func TestGetSocketPath(t *testing.T) {
}
defer os.Remove(globalSocket)
// Capture stderr to verify warning is displayed
socketPath := getSocketPath()
if socketPath != globalSocket {
t.Errorf("Expected global socket %s, got %s", globalSocket, socketPath)
if socketPath != localSocket {
t.Errorf("Expected local socket %s, got %s", localSocket, socketPath)
}
})

View File

@@ -170,6 +170,12 @@ var rootCmd = &cobra.Command{
// Attempt daemon connection
client, err := rpc.TryConnect(socketPath)
if err == nil && client != nil {
// Set expected database path for validation
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
// Perform health check
health, healthErr := client.Health()
if healthErr == nil && health.Status == "healthy" {
@@ -222,6 +228,12 @@ var rootCmd = &cobra.Command{
// Retry connection after auto-start
client, err := rpc.TryConnect(socketPath)
if err == nil && client != nil {
// Set expected database path for validation
if dbPath != "" {
absDBPath, _ := filepath.Abs(dbPath)
client.SetDatabasePath(absDBPath)
}
// Check health of auto-started daemon
health, healthErr := client.Health()
if healthErr == nil && health.Status == "healthy" {
@@ -737,23 +749,21 @@ func recordDaemonStartFailure() {
}
// getSocketPath returns the daemon socket path based on the database location
// If no local socket exists, check for global socket at ~/.beads/bd.sock
// Always returns local socket path (.beads/bd.sock relative to database)
func getSocketPath() string {
// First check local socket (same directory as database: .beads/bd.sock)
// Always use local socket (same directory as database: .beads/bd.sock)
localSocket := filepath.Join(filepath.Dir(dbPath), "bd.sock")
if _, err := os.Stat(localSocket); err == nil {
return localSocket
}
// Fall back to global socket at ~/.beads/bd.sock
// Warn if old global socket exists
if home, err := os.UserHomeDir(); err == nil {
globalSocket := filepath.Join(home, ".beads", "bd.sock")
if _, err := os.Stat(globalSocket); err == nil {
return globalSocket
fmt.Fprintf(os.Stderr, "Warning: Found old global daemon socket at %s\n", globalSocket)
fmt.Fprintf(os.Stderr, "Global sockets are deprecated. Each project now uses its own local daemon.\n")
fmt.Fprintf(os.Stderr, "To migrate: Stop the global daemon and restart with 'bd daemon' in each project.\n")
}
}
// Default to local socket even if it doesn't exist
return localSocket
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -18,8 +19,18 @@ func setupTestServer(t *testing.T) (*Server, *Client, func()) {
t.Fatalf("Failed to create temp dir: %v", err)
}
dbPath := filepath.Join(tmpDir, "test.db")
socketPath := filepath.Join(tmpDir, "bd.sock")
// Create .beads subdirectory so findDatabaseForCwd finds THIS database, not project's
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
os.RemoveAll(tmpDir)
t.Fatalf("Failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "test.db")
socketPath := filepath.Join(beadsDir, "bd.sock")
// Ensure socket doesn't exist from previous failed test
os.Remove(socketPath)
store, err := sqlitestorage.New(dbPath)
if err != nil {
@@ -36,7 +47,38 @@ func setupTestServer(t *testing.T) (*Server, *Client, func()) {
}
}()
time.Sleep(100 * time.Millisecond)
// Wait for server to be ready
maxWait := 50
for i := 0; i < maxWait; i++ {
time.Sleep(10 * time.Millisecond)
if _, err := os.Stat(socketPath); err == nil {
break
}
if i == maxWait-1 {
cancel()
server.Stop()
store.Close()
os.RemoveAll(tmpDir)
t.Fatalf("Server socket not created after waiting")
}
}
// Change to tmpDir so client's os.Getwd() finds the test database
originalWd, err := os.Getwd()
if err != nil {
cancel()
server.Stop()
store.Close()
os.RemoveAll(tmpDir)
t.Fatalf("Failed to get working directory: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
cancel()
server.Stop()
store.Close()
os.RemoveAll(tmpDir)
t.Fatalf("Failed to change directory: %v", err)
}
client, err := TryConnect(socketPath)
if err != nil {
@@ -46,12 +88,24 @@ func setupTestServer(t *testing.T) (*Server, *Client, func()) {
os.RemoveAll(tmpDir)
t.Fatalf("Failed to connect client: %v", err)
}
if client == nil {
cancel()
server.Stop()
store.Close()
os.RemoveAll(tmpDir)
t.Fatalf("Client is nil after connection")
}
// Set the client's dbPath to the test database so it doesn't route to the wrong DB
client.dbPath = dbPath
cleanup := func() {
client.Close()
cancel()
server.Stop()
store.Close()
os.Chdir(originalWd) // Restore original working directory
os.RemoveAll(tmpDir)
}
@@ -270,3 +324,113 @@ func TestConcurrentRequests(t *testing.T) {
}
}
}
func TestDatabaseHandshake(t *testing.T) {
// Save original directory and change to a temp directory for test isolation
origDir, _ := os.Getwd()
// Create two separate databases and daemons
tmpDir1, err := os.MkdirTemp("", "bd-test-db1-*")
if err != nil {
t.Fatalf("Failed to create temp dir 1: %v", err)
}
defer os.RemoveAll(tmpDir1)
tmpDir2, err := os.MkdirTemp("", "bd-test-db2-*")
if err != nil {
t.Fatalf("Failed to create temp dir 2: %v", err)
}
defer os.RemoveAll(tmpDir2)
// Setup first daemon (db1)
beadsDir1 := filepath.Join(tmpDir1, ".beads")
os.MkdirAll(beadsDir1, 0755)
dbPath1 := filepath.Join(beadsDir1, "db1.db")
socketPath1 := filepath.Join(beadsDir1, "bd.sock")
store1, err := sqlitestorage.New(dbPath1)
if err != nil {
t.Fatalf("Failed to create store 1: %v", err)
}
defer store1.Close()
server1 := NewServer(socketPath1, store1)
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
go server1.Start(ctx1)
defer server1.Stop()
time.Sleep(100 * time.Millisecond)
// Setup second daemon (db2)
beadsDir2 := filepath.Join(tmpDir2, ".beads")
os.MkdirAll(beadsDir2, 0755)
dbPath2 := filepath.Join(beadsDir2, "db2.db")
socketPath2 := filepath.Join(beadsDir2, "bd.sock")
store2, err := sqlitestorage.New(dbPath2)
if err != nil {
t.Fatalf("Failed to create store 2: %v", err)
}
defer store2.Close()
server2 := NewServer(socketPath2, store2)
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
go server2.Start(ctx2)
defer server2.Stop()
time.Sleep(100 * time.Millisecond)
// Test 1: Client with correct ExpectedDB should succeed
// Change to tmpDir1 so cwd resolution doesn't find other databases
os.Chdir(tmpDir1)
defer os.Chdir(origDir)
client1, err := TryConnect(socketPath1)
if err != nil {
t.Fatalf("Failed to connect to server 1: %v", err)
}
if client1 == nil {
t.Fatal("client1 is nil")
}
defer client1.Close()
client1.SetDatabasePath(dbPath1)
args := &CreateArgs{
Title: "Test Issue",
IssueType: "task",
Priority: 2,
}
_, err = client1.Create(args)
if err != nil {
t.Errorf("Create with correct database should succeed: %v", err)
}
// Test 2: Client with wrong ExpectedDB should fail
client2, err := TryConnect(socketPath1) // Connect to server1
if err != nil {
t.Fatalf("Failed to connect to server 1: %v", err)
}
defer client2.Close()
// But set ExpectedDB to db2 (mismatch!)
client2.SetDatabasePath(dbPath2)
_, err = client2.Create(args)
if err == nil {
t.Error("Create with wrong database should fail")
} else if !strings.Contains(err.Error(), "database mismatch:") {
t.Errorf("Expected 'database mismatch' error, got: %v", err)
}
// Test 3: Client without ExpectedDB should succeed (backward compat)
client3, err := TryConnect(socketPath1)
if err != nil {
t.Fatalf("Failed to connect to server 1: %v", err)
}
defer client3.Close()
// Don't set database path (old client behavior)
_, err = client3.Create(args)
if err != nil {
t.Errorf("Create without ExpectedDB should succeed (backward compat): %v", err)
}
}