// Package symlinks manages the shared-state symlinks that every Claude // account home must expose, so that account failover does not create state // divergence (duplicated JSONL transcripts, broken undo history, drifted // session env). // // Rationale // // Claude Code stores three directories whose content MUST be identical // across the two configured accounts for failover to be a no-op: // // - projects/ — session JSONL transcripts (used by `claude --resume`) // - session-env/ — per-session environment and working-dir metadata // - file-history/ — undo/redo history persistence // // If account A writes under `~/.claude-compte1/projects/...` while account // B later runs under `~/.claude-compte2/projects/...`, resume fails // silently with "session not found" and the operator loses every in-flight // conversation. // // Historically we fixed this by creating symlinks manually on the // operator's VM. Any fresh deployment that forgets those links silently // reintroduces the bug. This package encodes the convention in code: // EnsureForAccount creates missing links, ValidateAll fails fast at // startup when an account home is misconfigured. package symlinks import ( "errors" "fmt" "os" "path/filepath" "strings" ) // DefaultSharedRoot is the directory under which the three shared targets // live. All SharedSymlink.Target values default to a subdirectory of this // root so tests can override the root without rewriting the shared list. const DefaultSharedRoot = "/home/ubuntu" // SharedSymlink describes one required link inside a Claude account home. // // Target is the absolute path on disk that holds the real shared // directory. Name is the basename of the link that must exist inside each // account home (e.g. `session-env`, `file-history`, `projects`). type SharedSymlink struct { Target string Name string } // RequiredShared lists the three symlinks every Claude account home must // expose. The list is package-level so integration tests can read it, but // callers SHOULD prefer the EnsureForAccount / ValidateAll entry points // that accept an override list for isolation. var RequiredShared = []SharedSymlink{ {Target: "/home/ubuntu/.claude-session-env-shared", Name: "session-env"}, {Target: "/home/ubuntu/.claude-file-history-shared", Name: "file-history"}, {Target: "/home/ubuntu/.claude-projects-shared", Name: "projects"}, } // EnsureForAccount verifies (and creates if missing) every required shared // symlink for a single account home. Behaviour: // // - If accountHome does not exist, it is created (mode 0700). // - If Target (the shared destination) does not exist, it is created // (mode 0700). Both accounts pointing at a non-existent target would // produce two separate state trees on first write. // - If the link is absent, it is created. // - If the link is present and points at Target, nothing happens. // - If the link is present but points elsewhere, an error is returned. // We REFUSE to auto-correct a divergent link because fixing it blindly // could delete user data: the "wrong" target may contain the only copy // of the session transcripts. // - If a regular file or directory exists where the link should be, // an error is returned for the same reason. func EnsureForAccount(accountHome string, required []SharedSymlink) error { if accountHome == "" { return errors.New("symlinks: accountHome is empty") } if err := os.MkdirAll(accountHome, 0700); err != nil { return fmt.Errorf("symlinks: create account home %q: %w", accountHome, err) } for _, sl := range required { if err := ensureTarget(sl.Target); err != nil { return err } if err := ensureLink(accountHome, sl); err != nil { return err } } return nil } // ValidateAll runs EnsureForAccount on every account home. It aggregates // all errors and returns a single error with every failure inlined, so the // operator sees the full picture at startup rather than fixing one link, // restarting, hitting the next one, repeat. func ValidateAll(accountHomes []string, required []SharedSymlink) error { if len(accountHomes) == 0 { return errors.New("symlinks: no account homes provided") } var errs []string for _, home := range accountHomes { if err := EnsureForAccount(home, required); err != nil { errs = append(errs, err.Error()) } } if len(errs) > 0 { return fmt.Errorf("symlinks: validation failed for %d account home(s): %s", len(errs), strings.Join(errs, "; ")) } return nil } // ensureTarget creates Target as an empty directory when absent. // An existing file (non-directory, non-symlink) at Target is an operator // error we cannot resolve automatically. func ensureTarget(target string) error { info, err := os.Stat(target) if err != nil { if !os.IsNotExist(err) { return fmt.Errorf("symlinks: stat shared target %q: %w", target, err) } if mkErr := os.MkdirAll(target, 0700); mkErr != nil { return fmt.Errorf("symlinks: create shared target %q: %w", target, mkErr) } return nil } if !info.IsDir() { return fmt.Errorf("symlinks: shared target %q is not a directory", target) } return nil } // ensureLink reconciles one link entry inside accountHome. func ensureLink(accountHome string, sl SharedSymlink) error { linkPath := filepath.Join(accountHome, sl.Name) info, err := os.Lstat(linkPath) if err != nil { if os.IsNotExist(err) { if linkErr := os.Symlink(sl.Target, linkPath); linkErr != nil { return fmt.Errorf("symlinks: create %q → %q: %w", linkPath, sl.Target, linkErr) } return nil } return fmt.Errorf("symlinks: lstat %q: %w", linkPath, err) } // Path exists — must be a symlink pointing at Target. if info.Mode()&os.ModeSymlink == 0 { return fmt.Errorf("symlinks: %q exists but is not a symlink (expected → %q)", linkPath, sl.Target) } currentTarget, err := os.Readlink(linkPath) if err != nil { return fmt.Errorf("symlinks: readlink %q: %w", linkPath, err) } if currentTarget != sl.Target { return fmt.Errorf("symlinks: divergent link at %q: points to %q, expected %q (refusing to auto-correct to avoid data loss)", linkPath, currentTarget, sl.Target) } return nil }