diff --git a/internal/clauderig/account/identity_test.go b/internal/clauderig/account/identity_test.go new file mode 100644 index 0000000..5612465 --- /dev/null +++ b/internal/clauderig/account/identity_test.go @@ -0,0 +1,55 @@ +package account + +import ( + "os" + "path/filepath" + "testing" +) + +// LiveIdentity must return the three identity fields and nothing else — the +// same block holds the plan, the rate-limit tier and the org name, none of +// which are safe to write into a synced repo. +func TestIdentityFromFile_ReadsOnlyIdentity(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".claude.json") + if err := os.WriteFile(p, []byte(`{ + "userID": "deadbeef", + "oauthAccount": { + "accountUuid": "456fc32e-7579-49c7-bb2a-099657892c6a", + "organizationUuid": "f1eab509-9590-47cf-a4e8-33e5f45a5747", + "emailAddress": "john@example.com", + "seatTier": "max", + "organizationName": "Example Org", + "billingType": "stripe_subscription" + }, + "mcpServers": {"ctx": {"headers": {"API_KEY": "super-secret-value"}}} + }`), 0o600); err != nil { + t.Fatal(err) + } + + a, o, e, err := identityFromFile(p) + if err != nil { + t.Fatal(err) + } + if a != "456fc32e-7579-49c7-bb2a-099657892c6a" { + t.Errorf("accountUUID = %q", a) + } + if o != "f1eab509-9590-47cf-a4e8-33e5f45a5747" { + t.Errorf("orgUUID = %q", o) + } + if e != "john@example.com" { + t.Errorf("email = %q", e) + } +} + +// A machine that has never logged in has no identity — that is a normal state, +// not an error, and must not fail a sync. +func TestIdentityFromFile_AbsentFileIsNotAnError(t *testing.T) { + a, o, e, err := identityFromFile(filepath.Join(t.TempDir(), "nope.json")) + if err != nil { + t.Fatalf("absent file should not error: %v", err) + } + if a != "" || o != "" || e != "" { + t.Errorf("want all empty, got %q %q %q", a, o, e) + } +} diff --git a/internal/clauderig/account/livestore_darwin.go b/internal/clauderig/account/livestore_darwin.go index 52c3a52..52e5cd9 100644 --- a/internal/clauderig/account/livestore_darwin.go +++ b/internal/clauderig/account/livestore_darwin.go @@ -10,6 +10,7 @@ import ( "fmt" "os/exec" "os/user" + "regexp" "strings" ) @@ -46,7 +47,7 @@ func readKeychain(service string) (raw []byte, found bool, err error) { out, err := exec.Command(securityBin, "find-generic-password", "-a", accountName(), "-w", "-s", service).Output() if err == nil { - return bytes.TrimRight(out, "\n"), true, nil + return decodeSecurityOutput(bytes.TrimRight(out, "\n")), true, nil } var ee *exec.ExitError if errors.As(err, &ee) && ee.ExitCode() == errSecItemNotFound { @@ -55,6 +56,36 @@ func readKeychain(service string) (raw []byte, found bool, err error) { return nil, false, fmt.Errorf("read keychain: %w", err) } +// hexOnly matches security(1)'s hex fallback output — nothing but hex digits. +var hexOnly = regexp.MustCompile(`^[0-9a-fA-F]+$`) + +// decodeSecurityOutput undoes security(1)'s hex fallback. +// +// `find-generic-password -w` normally prints the password as text, but when the +// stored blob holds a byte it will not print inline — a newline is enough — it +// prints the WHOLE blob as hex digits instead, with no flag to say which form +// you got. Claude Code writes the credential as pretty-printed JSON, so the live +// item is multi-line and comes back hex; clauderig writes compact JSON, so its +// own per-profile items come back as text. Handing the hex form straight to a +// JSON parser fails on "{" (0x7b) read as the number 7 followed by 'b' — which +// is exactly the "credential unreadable" a machine sees once Claude Code has +// rewritten the credential. +// +// The two forms cannot be confused: a credential is JSON and always starts with +// '{', which is not a hex digit, so all-hex output is unambiguously the encoded +// form. Anything that does not decode is returned untouched — a reader that +// guessed wrong must not corrupt a blob that was fine. +func decodeSecurityOutput(out []byte) []byte { + if len(out) == 0 || len(out)%2 != 0 || !hexOnly.Match(out) { + return out + } + dec := make([]byte, hex.DecodedLen(len(out))) + if _, err := hex.Decode(dec, out); err != nil { + return out + } + return dec +} + // writeKeychain creates or updates one generic-password service. The secret is // passed as hex via `-X` (no escaping needed) through `security -i` stdin, so // it never appears in process argv. Only a payload large enough to overflow diff --git a/internal/clauderig/account/livestore_darwin_test.go b/internal/clauderig/account/livestore_darwin_test.go index cd4a0f2..4e71f01 100644 --- a/internal/clauderig/account/livestore_darwin_test.go +++ b/internal/clauderig/account/livestore_darwin_test.go @@ -4,6 +4,7 @@ package account import ( "encoding/hex" + "encoding/json" "testing" ) @@ -43,3 +44,52 @@ func TestSessionKeychainService(t *testing.T) { t.Fatalf("sessionKeychainService = %q, want Claude Code-credentials-c890e741", got) } } + +// security(1) prints the whole blob as hex whenever it holds a byte it won't +// print inline — a newline is enough. Claude Code writes the credential as +// pretty-printed JSON, so the live item comes back hex and the JSON parser +// failed on '{' (0x7b) read as 7 then 'b': "invalid character 'b' after +// top-level value". Observed live on 2026-08-25, Claude Code 2.1.237. +func TestDecodeSecurityOutput(t *testing.T) { + pretty := "{\n \"claudeAiOauth\": {\n \"accessToken\": \"tok\"\n },\n \"organizationUuid\": \"f1eab509\"\n}" + compact := `{"claudeAiOauth":{"accessToken":"tok"},"organizationUuid":"f1eab509"}` + + cases := []struct { + name string + in string + want string + }{ + {"hex fallback decodes", hex.EncodeToString([]byte(pretty)), pretty}, + {"compact json passes through", compact, compact}, + {"pretty json passes through", pretty, pretty}, + // Not the hex form: leave it exactly as it came, never half-decode it. + {"odd length is not hex", "abc", "abc"}, + {"non-hex letters pass through", "zzzz", "zzzz"}, + {"empty passes through", "", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := string(decodeSecurityOutput([]byte(c.in))); got != c.want { + t.Errorf("decodeSecurityOutput() = %q, want %q", got, c.want) + } + }) + } +} + +// The end-to-end shape of the bug: what security(1) hands back must parse. +func TestDecodeSecurityOutput_HexBlobParsesAsCredential(t *testing.T) { + pretty := "{\n \"claudeAiOauth\": {\n \"accessToken\": \"tok\",\n \"refreshToken\": \"ref\"\n },\n \"organizationUuid\": \"f1eab509\"\n}" + raw := decodeSecurityOutput([]byte(hex.EncodeToString([]byte(pretty)))) + var v struct { + ClaudeAiOauth struct { + AccessToken string `json:"accessToken"` + } `json:"claudeAiOauth"` + OrganizationUUID string `json:"organizationUuid"` + } + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decoded credential should parse: %v", err) + } + if v.ClaudeAiOauth.AccessToken != "tok" || v.OrganizationUUID != "f1eab509" { + t.Errorf("round trip lost fields: %+v", v) + } +} diff --git a/internal/clauderig/account/oauthaccount.go b/internal/clauderig/account/oauthaccount.go index e6254c5..b86f68b 100644 --- a/internal/clauderig/account/oauthaccount.go +++ b/internal/clauderig/account/oauthaccount.go @@ -185,3 +185,33 @@ func resolveLinkTarget(path string) string { } return path // cycle: write where we ended up rather than loop } + +// GlobalConfigPath is ~/.claude.json — where Claude Code keeps the oauthAccount +// block. Exported for callers that must act on the file itself (the pre-restore +// backup), not just its contents. +func GlobalConfigPath() (string, error) { return globalConfigPath() } + +// LiveIdentity reports which account ~/.claude.json currently names: the account +// uuid, its organization uuid, and the email address. +// +// Identity ONLY. Never the credential, the plan, the rate-limit tier, or +// anything else in the block — this is the slice that is safe to write into the +// synced repo, where it becomes the sole record of which account a machine's +// sessions were captured under. All three come back empty when Claude Code has +// never logged in here (file or key absent), which is not an error. +func LiveIdentity() (accountUUID, orgUUID, email string, err error) { + p, err := globalConfigPath() + if err != nil { + return "", "", "", err + } + return identityFromFile(p) +} + +func identityFromFile(path string) (accountUUID, orgUUID, email string, err error) { + raw, err := readOAuthAccountFrom(path) + if err != nil || len(raw) == 0 { + return "", "", "", err + } + m := parseOAuthMeta(raw) + return m.AccountUUID, m.OrganizationUUID, m.EmailAddress, nil +} diff --git a/internal/clauderig/allowlist/allowlist.go b/internal/clauderig/allowlist/allowlist.go index 86e6e42..154629e 100644 --- a/internal/clauderig/allowlist/allowlist.go +++ b/internal/clauderig/allowlist/allowlist.go @@ -165,6 +165,13 @@ func Walk(root string, l List) ([]string, []Link, error) { l.decide(rel) == Include && l.decide(target) == Include { links = append(links, Link{Rel: rel, Target: target}) } + // Either way this is a directory, so it is never a file to sync. + // This return is load-bearing, not a formality: without it the + // link path falls through to Match and is offered as a regular + // file, which reading can only ever fail on (EISDIR). Staging + // trees written before this guard existed still carry the 0-byte + // placeholders that came of exactly that, which is what + // reconcileStagedRoot retires. return nil } } diff --git a/internal/clauderig/commands/backup_links_test.go b/internal/clauderig/commands/backup_links_test.go new file mode 100644 index 0000000..f684452 --- /dev/null +++ b/internal/clauderig/commands/backup_links_test.go @@ -0,0 +1,165 @@ +package commands + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// ~/.claude is full of shared-memory symlinks (a worktree slug pointing memory/ +// at its main project). The pre-restore backup must recreate them as links — +// following one either duplicates the whole linked tree into the .bak or fails +// outright with EISDIR when it points at a directory. +func TestCopyTree_PreservesSymlinksInsteadOfFollowingThem(t *testing.T) { + src := t.TempDir() + mem := filepath.Join(src, "projects", "-main", "memory") + if err := os.MkdirAll(mem, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mem, "MEMORY.md"), []byte("facts"), 0o644); err != nil { + t.Fatal(err) + } + wt := filepath.Join(src, "projects", "-wt") + if err := os.MkdirAll(wt, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(mem, filepath.Join(wt, "memory")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + dst := filepath.Join(t.TempDir(), "claude.bak") + if err := copyTree(src, dst); err != nil { + t.Fatalf("backup failed on a shared-memory link: %v", err) + } + + backedUp := filepath.Join(dst, "projects", "-wt", "memory") + info, err := os.Lstat(backedUp) + if err != nil { + t.Fatalf("link missing from backup: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("link was followed into the backup, not recreated as a link") + } + if got, _ := os.Readlink(backedUp); got != mem { + t.Errorf("link text = %q, want %q", got, mem) + } + // the real directory still travels under its own path + if b, _ := os.ReadFile(filepath.Join(dst, "projects", "-main", "memory", "MEMORY.md")); string(b) != "facts" { + t.Errorf("memory content = %q, want %q", b, "facts") + } +} + +// ~/.claude holds 0600 transcripts, and the identity file beside it is 0600 +// too. os.Create would widen them to 0644 in the .bak, so the act of protecting +// the data would be what exposed it. +func TestCopyOne_PreservesSourcePermissions(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "private.jsonl") + if err := os.WriteFile(src, []byte("secret transcript"), 0o600); err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "bak", "private.jsonl") + if err := copyOne(src, dst); err != nil { + t.Fatal(err) + } + si, err := os.Stat(src) + if err != nil { + t.Fatal(err) + } + fi, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + // Assert the PROPERTY — the backup carries the source's mode — rather than a + // literal 0600. Windows has no Unix permission bits: Go reports 0666 and + // Chmod only toggles read-only, so a literal would fail there for a reason + // that has nothing to do with this code. + if got := fi.Mode().Perm(); got != si.Mode().Perm() { + t.Errorf("backup mode = %04o, want %04o (source mode)", got, si.Mode().Perm()) + } + // Where the bits are real, pin the case that matters: ~/.claude is full of + // 0600 transcripts, and os.Create would have widened them to 0644. + if runtime.GOOS != "windows" && fi.Mode().Perm() != 0o600 { + t.Errorf("backup mode = %04o, want 0600", fi.Mode().Perm()) + } + if b, _ := os.ReadFile(dst); string(b) != "secret transcript" { + t.Errorf("content = %q", b) + } +} + +// A DANGLING symlink at the backup path passes an os.Stat check as "not +// present". The copy would then follow it and write through the link — for +// ~/.claude.json that means an identity file, which can carry MCP credentials, +// landing wherever the link points. +func TestBackupPathIsFree_RejectsADanglingSymlink(t *testing.T) { + dir := t.TempDir() + elsewhere := filepath.Join(dir, "elsewhere.json") + bak := filepath.Join(dir, "claude.json.bak") + if err := os.Symlink(elsewhere, bak); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + // Stat agrees the target is absent — that is exactly the trap. + if _, err := os.Stat(bak); !os.IsNotExist(err) { + t.Fatalf("test setup: want a dangling link, Stat gave %v", err) + } + if err := backupPathIsFree(bak); err == nil { + t.Fatal("a dangling symlink must be refused, not treated as free") + } + if _, err := os.Stat(elsewhere); err == nil { + t.Error("nothing should have been written through the link") + } +} + +func TestBackupPathIsFree_AllowsAnAbsentPath(t *testing.T) { + if err := backupPathIsFree(filepath.Join(t.TempDir(), "nope.bak")); err != nil { + t.Errorf("an absent backup path is free: %v", err) + } +} + +// backupPathIsFree and the write are two moments. A symlink created in between +// would be followed by an O_CREATE|O_TRUNC open and its target overwritten, so +// the open itself must refuse an existing entry rather than trusting the +// earlier check. +func TestCopyOne_RefusesADestinationThatAppearedAfterTheCheck(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "identity.json") + if err := os.WriteFile(src, []byte(`{"secret":"value"}`), 0o600); err != nil { + t.Fatal(err) + } + victim := filepath.Join(dir, "victim.txt") + if err := os.WriteFile(victim, []byte("must survive"), 0o644); err != nil { + t.Fatal(err) + } + + // The race: something plants a symlink at dst after the path was checked. + dst := filepath.Join(dir, "identity.json.bak") + if err := os.Symlink(victim, dst); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if err := copyOne(src, dst); err == nil { + t.Error("copyOne must refuse a destination that already exists") + } + if b, _ := os.ReadFile(victim); string(b) != "must survive" { + t.Errorf("wrote through the planted symlink: victim = %q", b) + } +} + +// A plain existing file is refused too — never silently overwritten. +func TestCopyOne_RefusesAnExistingRegularFile(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "a") + dst := filepath.Join(dir, "b") + if err := os.WriteFile(src, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := copyOne(src, dst); err == nil { + t.Error("want a refusal") + } + if b, _ := os.ReadFile(dst); string(b) != "old" { + t.Errorf("destination was overwritten: %q", b) + } +} diff --git a/internal/clauderig/commands/restore.go b/internal/clauderig/commands/restore.go index 045b626..8ecbc11 100644 --- a/internal/clauderig/commands/restore.go +++ b/internal/clauderig/commands/restore.go @@ -1,6 +1,7 @@ package commands import ( + "errors" "fmt" "io" "io/fs" @@ -12,6 +13,7 @@ import ( "github.com/rigsmith/rigsmith/core/brand" "github.com/rigsmith/rigsmith/core/gitrepo" "github.com/rigsmith/rigsmith/core/pathmap" + "github.com/rigsmith/rigsmith/internal/clauderig/account" "github.com/rigsmith/rigsmith/internal/clauderig/config" "github.com/rigsmith/rigsmith/internal/clauderig/engine" "github.com/rigsmith/rigsmith/internal/clauderig/manifest" @@ -105,13 +107,16 @@ func NewRestoreCmd() *cobra.Command { } if backup { bak := cliTarget + ".bak" - if _, err := os.Stat(bak); err == nil { - return fmt.Errorf("backup %s already exists; move it away first", bak) + if err := backupPathIsFree(bak); err != nil { + return err } fmt.Fprintf(out, " backing up %s → %s\n", cliTarget, bak) if err := copyTree(cliTarget, bak); err != nil { return fmt.Errorf("backup: %w", err) } + if err := backupIdentityFile(out); err != nil { + return err + } } // Prune defaults to the config's AlwaysPrune; an explicit --prune @@ -233,6 +238,13 @@ func chooseRestoreSafety(target string) string { } // copyTree recursively copies src to dst (used for the pre-restore backup). +// +// Symlinks are recreated, never followed. ~/.claude is full of shared-memory +// links (worktree slugs pointing memory/ at their main project), and copying +// through one either duplicates the whole linked tree into the backup or fails +// outright when it points at a directory. Link text is reproduced verbatim, so +// an absolute link still resolves to the same place from inside the .bak — the +// same thing `cp -R` does. func copyTree(src, dst string) error { return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { if err != nil { @@ -240,6 +252,9 @@ func copyTree(src, dst string) error { } rel, _ := filepath.Rel(src, p) target := filepath.Join(dst, rel) + if d.Type()&fs.ModeSymlink != 0 { + return copyLink(p, target) + } if d.IsDir() { return os.MkdirAll(target, 0o755) } @@ -247,6 +262,75 @@ func copyTree(src, dst string) error { }) } +// copyLink recreates the symlink at src as an identical symlink at dst. +func copyLink(src, dst string) error { + link, err := os.Readlink(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.Symlink(link, dst) +} + +// backupIdentityFile copies ~/.claude.json alongside the tree backup. +// +// It holds the oauthAccount block — the ONLY record of which account this +// machine is logged in as — and it sits outside ~/.claude, so the tree copy +// above misses it entirely. That is the one file a bad account switch can ruin +// and nothing else can reconstruct. It can also carry MCP server credentials +// (mcpServers.*.headers / .env are free-form passthrough), which is exactly why +// this stays a local .bak and is never what gets synced: the repo records only +// the three identity fields, via devices.Account. +// +// An absent file is not an error — a machine that has never logged in has +// nothing to protect. +func backupIdentityFile(out io.Writer) error { + src, err := account.GlobalConfigPath() + if err != nil { + return nil // no home dir: nothing addressable to back up + } + if _, err := os.Stat(src); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // never logged in here: nothing to protect + } + // Anything else — unreadable, an I/O error — is not "no identity file". + // Skipping silently would drop the one file this backup exists for, at + // the moment its state is least certain. + return fmt.Errorf("backup identity: %w", err) + } + dst := src + ".bak" + if err := backupPathIsFree(dst); err != nil { + return err + } + fmt.Fprintf(out, " backing up %s → %s\n", src, dst) + if err := copyOne(src, dst); err != nil { + return fmt.Errorf("backup identity: %w", err) + } + return nil +} + +// backupPathIsFree reports that nothing occupies the backup path yet. +// +// Lstat, not Stat. A DANGLING symlink there passes a Stat check as "not +// present", and the copy would then follow it — writing the backup through the +// link to wherever it points. For the identity file that means ~/.claude.json, +// which can carry MCP server credentials, landing somewhere never intended. Any +// existing entry, link included, is a refusal, and a lookup that fails for any +// other reason stops the backup rather than guessing. +func backupPathIsFree(path string) error { + _, err := os.Lstat(path) + switch { + case err == nil: + return fmt.Errorf("backup %s already exists; move it away first", path) + case errors.Is(err, os.ErrNotExist): + return nil + default: + return fmt.Errorf("check backup path %s: %w", path, err) + } +} + func copyOne(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err @@ -256,11 +340,30 @@ func copyOne(src, dst string) error { return err } defer in.Close() - out, err := os.Create(dst) + // Copy the source's permissions, don't invent them. ~/.claude is full of + // 0600 transcripts and the identity file beside it is 0600 too; os.Create + // would widen every one of them to 0644 in the backup, so the act of + // protecting this data would be what exposed it. + mode := os.FileMode(0o600) + if fi, serr := in.Stat(); serr == nil { + mode = fi.Mode().Perm() + } + // O_EXCL, and no O_TRUNC. backupPathIsFree checked that nothing occupied + // this path, but that check and this open are two moments: a symlink + // created in between would otherwise be FOLLOWED here and its target + // overwritten — for the identity file, one that can carry MCP credentials. + // O_CREATE|O_EXCL fails on any existing entry, a dangling symlink included, + // which closes the window instead of narrowing it. Every destination is a + // path nothing should hold yet, so nothing legitimate is refused. + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { return err } defer out.Close() - _, err = io.Copy(out, in) - return err + if _, err := io.Copy(out, in); err != nil { + return err + } + // OpenFile's mode only applies when it creates the file, and umask can clip + // it even then — restate it so the backup matches the source exactly. + return os.Chmod(dst, mode) } diff --git a/internal/clauderig/commands/search.go b/internal/clauderig/commands/search.go index aa9ea32..dd405f9 100644 --- a/internal/clauderig/commands/search.go +++ b/internal/clauderig/commands/search.go @@ -42,6 +42,7 @@ func NewSearchCmd() *cobra.Command { since string until string cwdFilter string + accountFilter string ) cmd := &cobra.Command{ Use: "search ", @@ -57,6 +58,10 @@ func NewSearchCmd() *cobra.Command { " --since/--until narrow to when the session was last used (2026-08-17,\n" + " an RFC3339 timestamp, or an age like 7d/36h)\n" + " --cwd narrow to sessions whose project directory contains this text\n" + + " --account narrow to one account's sessions (alias, email, or an\n" + + " accountUuid prefix). Reads the synced ledger, so it cannot be\n" + + " combined with --live, and only sessions synced since attribution\n" + + " was recorded carry one\n" + " --raw grep-style line output instead of grouped sessions\n" + " --all search EVERY file (config, skills, file-history, Desktop dir),\n" + " not just transcripts; implies --raw (non-chat files aren't sessions)\n\n" + @@ -93,8 +98,12 @@ func NewSearchCmd() *cobra.Command { // The filters narrow SESSIONS — a date and a project directory are // properties of a session, not of a grep line — so refuse rather than // silently ignore them. - if (raw || all) && sc.filtering() { - return fmt.Errorf("--since/--until/--cwd narrow grouped sessions and can't be combined with --raw/--all") + // accountFilter, not sc.account: the flag is resolved further down, so + // sc.filtering() is still false here when --account is the only one + // set — and --account --raw would then sail past this check and + // return matches the account filter never touched. + if (raw || all) && (sc.filtering() || accountFilter != "") { + return fmt.Errorf("--since/--until/--cwd/--account narrow grouped sessions and can't be combined with --raw/--all") } cfg, err := config.LoadOrDefault() @@ -113,6 +122,21 @@ func NewSearchCmd() *cobra.Command { sc.devicesUnavailable = !ok sc.ledger = loadLedger() } + // Resolved after the ledger loads, because the ledger is what says + // which accounts exist to be named — and --account is meaningless + // under --live, where no ledger is in scope at all. + if accountFilter != "" { + if liveOnly { + return fmt.Errorf("--account reads the synced ledger, which --live takes out of scope") + } + staging, serr := config.StagingDir() + if serr != nil { + return serr + } + if sc.account, err = resolveAccountFilter(accountFilter, staging, sc.ledger); err != nil { + return err + } + } fmt.Fprintf(out, "%s %q\n", HeaderStyle.Render("clauderig search"), query) @@ -132,6 +156,7 @@ func NewSearchCmd() *cobra.Command { cmd.Flags().StringVar(&since, "since", "", "only sessions last used on/after this day, timestamp, or age (7d)") cmd.Flags().StringVar(&until, "until", "", "only sessions last used on/before this day, timestamp, or age (7d)") cmd.Flags().StringVar(&cwdFilter, "cwd", "", "only sessions whose project directory contains this text") + cmd.Flags().StringVar(&accountFilter, "account", "", "only sessions belonging to this account (alias, email, or accountUuid prefix); reads the synced ledger, so not with --live") return cmd } @@ -327,7 +352,7 @@ func searchSessions(out, errw io.Writer, me config.Machine, targets []search.Tar repoPaths := transcriptPaths(targets, repoTarget) results := make([]*sessResult, 0, len(hits)) - var hidden, undated int + var hidden, undated, unattributed int for _, r := range hits { // A title-only match has no recorded hit and therefore no path. Give it one // before anything reads a date, a cwd or a fallback title off it — live @@ -356,10 +381,13 @@ func searchSessions(out, errw io.Writer, me config.Machine, targets []search.Tar r.cwd = resolvePath(me, r.led.Cwd) } } - if keep, noDate := sc.keep(r); !keep { + if keep, why := sc.keep(r); !keep { hidden++ - if noDate { + switch why { + case droppedUndated: undated++ + case droppedUnattributed: + unattributed++ } continue } @@ -387,7 +415,11 @@ func searchSessions(out, errw io.Writer, me config.Machine, targets []search.Tar if len(results) == 0 { fmt.Fprintln(out, DimStyle.Render("no matching sessions")) if hidden > 0 { - fmt.Fprintln(out, DimStyle.Render("(every match was excluded by --since/--until/--cwd — widen them)")) + // Name the filters actually in play. Listing --since/--until/--cwd + // when --account did the excluding sends the user to widen the wrong + // flag, and --account is the one whose exclusions are least visible. + fmt.Fprintf(out, "%s\n", DimStyle.Render( + "(every match was excluded by "+strings.Join(sc.activeFilters(), "/")+" — widen them)")) } fmt.Fprintln(out, DimStyle.Render("(try --raw for line-level hits, or --all to include config/file-history)")) fmt.Fprintln(out, DimStyle.Render("(Desktop 'Chat' tab chats are server-side and never appear here — check claude.ai)")) @@ -401,6 +433,14 @@ func searchSessions(out, errw io.Writer, me config.Machine, targets []search.Tar // range; say so, or the count reads as a bug. msg += fmt.Sprintf(" (%d had no date to place)", undated) } + if unattributed > 0 { + // Attribution is recorded at sync time and cannot be backfilled, so + // these are permanently unmatchable by --account, not merely missing + // from this run. Saying "no recorded account" rather than letting them + // vanish is what keeps --account from reading as "you have no such + // sessions" when it means "I cannot tell which are yours". + msg += fmt.Sprintf(" (%d have no recorded account; only sessions synced since attribution was added carry one)", unattributed) + } fmt.Fprintf(out, "%s\n", DimStyle.Render(msg)) } fmt.Fprintf(out, "%s\n", DimStyle.Render(fmt.Sprintf( diff --git a/internal/clauderig/commands/search_account.go b/internal/clauderig/commands/search_account.go new file mode 100644 index 0000000..2e0a02b --- /dev/null +++ b/internal/clauderig/commands/search_account.go @@ -0,0 +1,149 @@ +package commands + +import ( + "fmt" + "sort" + "strings" + + "github.com/rigsmith/rigsmith/internal/clauderig/account" + "github.com/rigsmith/rigsmith/internal/clauderig/devices" + "github.com/rigsmith/rigsmith/internal/clauderig/ledger" +) + +// minUUIDPrefix is the shortest accountUuid prefix --account will accept. Eight +// hex characters is the first dash-delimited group, which is how these uuids are +// written when they are abbreviated anywhere else, and it is long enough that a +// collision between two of a person's own accounts is not a practical concern. +const minUUIDPrefix = 8 + +// resolveAccountFilter turns what someone typed into an accountUuid to match +// ledger rows against. +// +// Three inputs are accepted, in this order: an accountUuid (or a prefix of one), +// an account alias or email from clauderig's own store, and — because a machine +// can hold sessions from an account it has never had a login for — an email +// recorded in the synced device registry. +// +// The uuid is the join key rather than the email because that is what the two +// sources of attribution actually carry: Desktop names the account by uuid in +// its sidecar path, and ~/.claude.json names it by uuid in oauthAccount. An +// email is only ever a label people can type. +// +// A value that resolves to nothing is an ERROR, not an empty result set: "no +// sessions for that account" and "there is no such account" are opposite +// answers, and only one of them means the search worked. +func resolveAccountFilter(input string, stagingDir string, known map[string]ledger.Entry) (string, error) { + v := strings.TrimSpace(input) + if v == "" { + return "", nil + } + + byEmail := accountUUIDsByEmail(stagingDir) + + // uuid or uuid prefix — matched against every account that IS known, which + // is the ledger's attributions plus the device registry's. Registry-only + // accounts matter: one that has synced but has no attributed sessions yet + // is a real account with zero results, and answering "unknown account" + // there would collapse the very distinction this resolver exists to keep. + if isHexPrefix(v) && len(v) >= minUUIDPrefix { + candidates := map[string]bool{} + for _, e := range known { + if e.Account != "" { + candidates[e.Account] = true + } + } + for _, uuid := range byEmail { + candidates[uuid] = true + } + var hit string + for uuid := range candidates { + if !strings.HasPrefix(strings.ToLower(uuid), strings.ToLower(v)) { + continue + } + if hit != "" && !strings.EqualFold(hit, uuid) { + return "", fmt.Errorf("%q matches more than one account (%s, %s) — use more characters", v, hit, uuid) + } + hit = uuid + } + if hit != "" { + return hit, nil + } + } + + // alias / email / id from clauderig's account store, mapped to a uuid via + // the registry (the store keys accounts by email, not uuid). + if st, serr := account.DefaultStore(); serr == nil { + if a, rerr := st.Resolve(v); rerr == nil && a.Email != "" { + if uuid := byEmail[strings.ToLower(a.Email)]; uuid != "" { + return uuid, nil + } + return "", fmt.Errorf("account %s is known but no synced machine has recorded its accountUuid yet — "+ + "run `clauderig sync` on a machine logged in as it, or pass the uuid", a.Email) + } + } + + // an email straight from the registry, for an account this machine has no + // login for at all + if uuid := byEmail[strings.ToLower(v)]; uuid != "" { + return uuid, nil + } + + return "", fmt.Errorf("unknown account %q — %s", v, knownAccountsHint(byEmail, known)) +} + +// accountUUIDsByEmail maps a lowercased email to its accountUuid, from the +// synced device registry — which records both halves for every machine that has +// synced (see devices.Account). +func accountUUIDsByEmail(stagingDir string) map[string]string { + out := map[string]string{} + if stagingDir == "" { + return out + } + reg, err := devices.Load(stagingDir) + if err != nil { + return out + } + for _, d := range reg.Devices { + if d.Account == nil || d.Account.Email == "" || d.Account.AccountUUID == "" { + continue + } + out[strings.ToLower(d.Account.Email)] = d.Account.AccountUUID + } + return out +} + +// knownAccountsHint lists what --account could have matched, so a failed lookup +// is actionable. It names emails where they are known and falls back to the +// uuids the ledger carries, which is all there is for an account no machine has +// synced under. +func knownAccountsHint(byEmail map[string]string, known map[string]ledger.Entry) string { + labels := map[string]string{} + for _, e := range known { + if e.Account != "" { + labels[e.Account] = shortID(e.Account) + } + } + for email, uuid := range byEmail { + labels[uuid] = email + } + if len(labels) == 0 { + return "no session in the ledger records an account yet (attribution starts at the next sync)" + } + var out []string + for _, l := range labels { + out = append(out, l) + } + sort.Strings(out) + return "known: " + strings.Join(out, ", ") +} + +func isHexPrefix(s string) bool { + for _, r := range s { + switch { + case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F', r == '-': + default: + return false + } + } + return s != "" +} diff --git a/internal/clauderig/commands/search_account_test.go b/internal/clauderig/commands/search_account_test.go new file mode 100644 index 0000000..9f780bc --- /dev/null +++ b/internal/clauderig/commands/search_account_test.go @@ -0,0 +1,163 @@ +package commands + +import ( + "testing" + "time" + + "github.com/rigsmith/rigsmith/internal/clauderig/devices" + + "github.com/rigsmith/rigsmith/internal/clauderig/ledger" +) + +// --account matches the recorded attribution, and a session that has none is +// reported as unmatchable rather than silently dropped: "no such session" and +// "I cannot tell whose this is" are opposite answers. +func TestSessionScope_AccountFilter(t *testing.T) { + sc := sessionScope{account: "acct-a"} + + mine := &sessResult{id: "1", led: ledger.Entry{Account: "acct-a", AccountSource: ledger.AccountFromSync}} + if ok, _ := sc.keep(mine); !ok { + t.Error("a session attributed to the filtered account must survive") + } + + theirs := &sessResult{id: "2", led: ledger.Entry{Account: "acct-b", AccountSource: ledger.AccountFromDesktop}} + if ok, why := sc.keep(theirs); ok || why != droppedByFilter { + t.Errorf("other account: ok=%v why=%v, want false/droppedByFilter", ok, why) + } + + unknown := &sessResult{id: "3"} + if ok, why := sc.keep(unknown); ok || why != droppedUnattributed { + t.Errorf("unattributed: ok=%v why=%v, want false/droppedUnattributed", ok, why) + } +} + +// Attribution is compared case-insensitively — uuids are hex and get written +// both ways by different producers. +func TestSessionScope_AccountFilterIgnoresCase(t *testing.T) { + sc := sessionScope{account: "ACCT-A"} + r := &sessResult{id: "1", led: ledger.Entry{Account: "acct-a"}} + if ok, _ := sc.keep(r); !ok { + t.Error("uuid comparison should be case-insensitive") + } +} + +// --account counts as narrowing, so it is refused alongside --raw/--all like +// the other session filters. +func TestSessionScope_AccountCountsAsFiltering(t *testing.T) { + if !(sessionScope{account: "acct-a"}).filtering() { + t.Error("account filter should report as filtering") + } + if (sessionScope{}).filtering() { + t.Error("empty scope should not report as filtering") + } +} + +// An unresolvable name is an error, not an empty result: those mean opposite +// things and only one of them is a working search. +func TestResolveAccountFilter_UnknownIsAnError(t *testing.T) { + known := map[string]ledger.Entry{ + "s1": {ID: "s1", Account: "456fc32e-7579-49c7-bb2a-099657892c6a"}, + } + if _, err := resolveAccountFilter("no-such-account", t.TempDir(), known); err == nil { + t.Fatal("want an error for an unknown account") + } + // empty stays empty — no filter requested + if got, err := resolveAccountFilter(" ", t.TempDir(), known); err != nil || got != "" { + t.Errorf("blank filter = %q/%v, want \"\"/nil", got, err) + } +} + +// A uuid prefix resolves against the accounts the ledger actually names, so a +// typo fails at the flag instead of quietly matching nothing. +func TestResolveAccountFilter_UUIDPrefix(t *testing.T) { + full := "456fc32e-7579-49c7-bb2a-099657892c6a" + known := map[string]ledger.Entry{"s1": {ID: "s1", Account: full}} + + got, err := resolveAccountFilter("456fc32e", t.TempDir(), known) + if err != nil || got != full { + t.Errorf("prefix = %q/%v, want %q", got, err, full) + } + if _, err := resolveAccountFilter("deadbeef", t.TempDir(), known); err == nil { + t.Error("a prefix matching no known account must error") + } + // too short to be a uuid prefix, and not a known name + if _, err := resolveAccountFilter("456", t.TempDir(), known); err == nil { + t.Error("a sub-minimum prefix must not silently match") + } +} + +// Two accounts sharing a prefix must not be silently collapsed into one. +func TestResolveAccountFilter_AmbiguousPrefix(t *testing.T) { + known := map[string]ledger.Entry{ + "s1": {ID: "s1", Account: "abcd1234-0000-0000-0000-000000000001"}, + "s2": {ID: "s2", Account: "abcd1234-0000-0000-0000-000000000002"}, + } + if _, err := resolveAccountFilter("abcd1234", t.TempDir(), known); err == nil { + t.Fatal("an ambiguous prefix must error rather than pick one") + } +} + +// The "everything was excluded" hint must name the flag that did the excluding. +// A fixed list sends the user to widen --since when --account was responsible. +func TestSessionScope_ActiveFiltersNamesWhatIsSet(t *testing.T) { + if got := (sessionScope{}).activeFilters(); len(got) != 1 || got[0] != "the filters" { + t.Errorf("no filters set = %v, want a generic phrase", got) + } + got := (sessionScope{account: "acct-a"}).activeFilters() + if len(got) != 1 || got[0] != "--account" { + t.Errorf("account only = %v, want [--account]", got) + } + both := (sessionScope{account: "acct-a", cwd: "api"}).activeFilters() + if len(both) != 2 { + t.Errorf("account+cwd = %v, want both named", both) + } +} + +// An account the registry knows but the ledger has not attributed yet is a real +// account with zero results — not an unknown one. Matching only ledger +// attributions collapses exactly the distinction this resolver exists to keep. +func TestResolveAccountFilter_UUIDPrefixFindsARegistryOnlyAccount(t *testing.T) { + staging := t.TempDir() + reg, err := devices.Load(staging) + if err != nil { + t.Fatal(err) + } + reg.Touch("mbp", "macos", "2.1.237", &devices.Account{ + AccountUUID: "03d1c0c9-823d-464b-a468-a9bea2383338", Email: "other@example.com", + }, time.Now()) + if err := reg.Save(staging); err != nil { + t.Fatal(err) + } + + // The ledger attributes a DIFFERENT account; the registry one has no rows. + known := map[string]ledger.Entry{"s1": {ID: "s1", Account: "456fc32e-7579-49c7-bb2a-099657892c6a"}} + + got, err := resolveAccountFilter("03d1c0c9", staging, known) + if err != nil { + t.Fatalf("a registry-known account must resolve, not error: %v", err) + } + if got != "03d1c0c9-823d-464b-a468-a9bea2383338" { + t.Errorf("got %q", got) + } + // Its email resolves too, by the same registry. + if got, err := resolveAccountFilter("other@example.com", staging, known); err != nil || got == "" { + t.Errorf("email = %q/%v", got, err) + } +} + +// --account narrows SESSIONS, so it is refused with --raw/--all like the other +// session filters. The guard runs before the flag is resolved into the scope, +// so it has to test the flag itself — testing sc.account would let +// `--account X --raw` through and return matches the filter never touched. +func TestSessionScope_AccountAloneStillCountsAsNarrowing(t *testing.T) { + // sc.account is empty at guard time even when --account was given. + sc := sessionScope{} + accountFilter := "work" + if !(sc.filtering() || accountFilter != "") { + t.Error("--account alone must trip the raw/all guard") + } + // and with nothing set at all, it must not trip + if sc.filtering() || "" != "" { + t.Error("no filters must not trip the guard") + } +} diff --git a/internal/clauderig/commands/search_scope.go b/internal/clauderig/commands/search_scope.go index d8192c8..62397c6 100644 --- a/internal/clauderig/commands/search_scope.go +++ b/internal/clauderig/commands/search_scope.go @@ -38,6 +38,11 @@ type sessionScope struct { until time.Time // cwd is a case-insensitive substring of the session's project directory. cwd string + // account is a resolved accountUuid from --account; only sessions the ledger + // attributes to it survive. Attribution is recorded at sync time and cannot + // be reconstructed afterwards, so sessions synced before it existed have + // none and are reported as such rather than quietly assumed to match. + account string // devices is the synced registry; empty when there is no staging repo (or // when --live took it out of scope), which correctly prints no footer. devices []devices.Device @@ -62,35 +67,63 @@ type sessionScope struct { // filtering reports whether any narrowing flag was set. func (sc sessionScope) filtering() bool { - return !sc.since.IsZero() || !sc.until.IsZero() || sc.cwd != "" + return !sc.since.IsZero() || !sc.until.IsZero() || sc.cwd != "" || sc.account != "" } -// keep decides whether one session survives the filters. A session with no -// usable date cannot honestly be placed inside a time window, and one with no -// resolved cwd cannot be matched against --cwd, so each is dropped rather than -// waved through — the second return says which, so the caller can account for -// them instead of silently shrinking the result set. -func (sc sessionScope) keep(r *sessResult) (ok bool, undated bool) { +// dropped says WHY keep() rejected a session, when the reason is that the +// session lacks the information the filter needs rather than that it failed the +// filter. Those two look identical in a shrunken result set and mean opposite +// things: "no such session" versus "cannot tell". +type dropped int + +const ( + droppedByFilter dropped = iota // genuinely outside the filter + droppedUndated // no date to place in a time window + droppedUnattributed // no recorded account to match --account +) + +// keep decides whether one session survives the filters. A session that lacks +// what a filter needs is dropped rather than waved through: no usable date +// cannot honestly be placed in a time window, no resolved cwd cannot be matched +// against --cwd, and an attribution the ledger never recorded cannot be matched +// against --account. +// +// The second return distinguishes only the two that are worth reporting — +// undated and unattributed — because those are permanent properties of the +// session rather than a verdict on it, and a caller that stayed silent about +// them would shrink the result set for a reason the user cannot see. A missing +// cwd is reported as an ordinary filter miss: unlike the other two, it is +// almost always a transcript this run simply could not read a path from, not a +// standing fact about the session. +func (sc sessionScope) keep(r *sessResult) (ok bool, why dropped) { if !sc.since.IsZero() || !sc.until.IsZero() { if r.when.IsZero() { - return false, true + return false, droppedUndated } if !sc.since.IsZero() && r.when.Before(sc.since) { - return false, false + return false, droppedByFilter } if !sc.until.IsZero() && r.when.After(sc.until) { - return false, false + return false, droppedByFilter } } if sc.cwd != "" { if r.cwd == "" { - return false, false + return false, droppedByFilter } if !strings.Contains(strings.ToLower(r.cwd), sc.cwd) { - return false, false + return false, droppedByFilter } } - return true, false + if sc.account != "" { + if r.led.Account == "" { + return false, droppedUnattributed + } + if !strings.EqualFold(r.led.Account, sc.account) { + return false, droppedByFilter + } + } + return true, droppedByFilter } // parseWhen reads a --since/--until value in any of three shapes: a calendar day @@ -230,3 +263,25 @@ func loadDevices() (list []devices.Device, ok bool) { } return reg.List(), true } + +// activeFilters names the narrowing flags actually set, so a "everything was +// excluded" hint points at the flag that did it rather than a fixed list. +func (sc sessionScope) activeFilters() []string { + var f []string + if !sc.since.IsZero() { + f = append(f, "--since") + } + if !sc.until.IsZero() { + f = append(f, "--until") + } + if sc.cwd != "" { + f = append(f, "--cwd") + } + if sc.account != "" { + f = append(f, "--account") + } + if len(f) == 0 { + return []string{"the filters"} + } + return f +} diff --git a/internal/clauderig/commands/sync.go b/internal/clauderig/commands/sync.go index 31d41bd..09b54f7 100644 --- a/internal/clauderig/commands/sync.go +++ b/internal/clauderig/commands/sync.go @@ -8,6 +8,7 @@ import ( "github.com/rigsmith/rigsmith/core/gitrepo" "github.com/rigsmith/rigsmith/core/pathmap" + "github.com/rigsmith/rigsmith/internal/clauderig/account" "github.com/rigsmith/rigsmith/internal/clauderig/config" "github.com/rigsmith/rigsmith/internal/clauderig/devices" "github.com/rigsmith/rigsmith/internal/clauderig/engine" @@ -58,11 +59,22 @@ func NewSyncCmd() *cobra.Command { if cliLoc, st := cfg.RootLocation("cli", me); st == pathmap.StatusResolved { claudeVer = config.DetectClaudeVersion(cliLoc) } + // Attribution for ledger rows no Desktop sidecar covers. Read once, + // before the walk, so every row this sync records is stamped with the + // same account rather than one that could change mid-run. + // Read ONCE, and reuse for both writes below. Reading again for the + // device registry would let a login change (or one transiently + // failing read) mid-sync stamp ledger rows with one uuid and the + // registry with another — and the registry is what resolves an + // alias or email back to that uuid, so the two disagreeing breaks + // `search --account` for exactly those rows. + liveAcct, liveOrg, liveEmail, _ := account.LiveIdentity() rep, serr := engine.Sync(engine.Options{ StagingDir: staging, Config: cfg, Machine: me, ClaudeVersion: claudeVer, - RetentionDays: cfg.Retention.HistoryDays, - MaxFileBytes: cfg.Retention.MaxFileBytes, - Profiles: engine.LocalProfileNames(), + RetentionDays: cfg.Retention.HistoryDays, + MaxFileBytes: cfg.Retention.MaxFileBytes, + Profiles: engine.LocalProfileNames(), + LiveAccountUUID: liveAcct, }) if rep != nil { w := 0 @@ -124,9 +136,17 @@ func NewSyncCmd() *cobra.Command { return nil } - // Record this machine in the synced device registry. + // Record this machine in the synced device registry, together with the + // account it synced as — identity only (see devices.Account), and the + // only account provenance anything in the repo carries. Best-effort: + // an unreadable identity leaves the previous record standing and never + // costs anyone a sync. if reg, err := devices.Load(staging); err == nil { - reg.Touch(me.Name, me.OS, claudeVer, time.Now()) + var acct *devices.Account + if liveAcct != "" || liveOrg != "" || liveEmail != "" { + acct = &devices.Account{AccountUUID: liveAcct, OrganizationUUID: liveOrg, Email: liveEmail} + } + reg.Touch(me.Name, me.OS, claudeVer, acct, time.Now()) _ = reg.Save(staging) } diff --git a/internal/clauderig/devices/devices.go b/internal/clauderig/devices/devices.go index 27c4f66..5e98f6b 100644 --- a/internal/clauderig/devices/devices.go +++ b/internal/clauderig/devices/devices.go @@ -22,6 +22,27 @@ type Device struct { OS string `json:"os"` LastSync time.Time `json:"lastSync"` ClaudeVersion string `json:"claudeVersion,omitempty"` + // Account is which Claude Code login this device's last sync ran as. See + // Account — it is the only account provenance anything in the repo carries. + Account *Account `json:"account,omitempty"` +} + +// Account identifies a Claude Code login, and nothing more: the two uuids that +// tell accounts apart plus the email that makes them legible to a person. Read +// from ~/.claude.json's oauthAccount, which also holds the plan, the rate-limit +// tier and (in the wider file) MCP credentials — none of which belong in a +// synced repo, and none of which are recorded here. +// +// This exists because account identity is otherwise invisible downstream: CLI +// transcripts carry no account field at all, so a synced tree — and any restore +// taken from it — cannot otherwise say whose sessions it holds. Desktop's own +// sidecars are already account-partitioned by path +// (claude-code-sessions///), and these uuids are +// the same ones, so the two agree by construction. +type Account struct { + AccountUUID string `json:"accountUuid,omitempty"` + OrganizationUUID string `json:"organizationUuid,omitempty"` + Email string `json:"email,omitempty"` } // Registry is the synced device list. @@ -50,12 +71,20 @@ func Load(dir string) (*Registry, error) { return &r, nil } -// Touch records this machine's sync. -func (r *Registry) Touch(name, os, claudeVersion string, when time.Time) { +// Touch records this machine's sync, under the account it ran as. +// +// A nil acct keeps whatever account the device last recorded instead of erasing +// it: a sync that could not read the identity (not logged in, unreadable +// ~/.claude.json) is not evidence that the machine changed accounts, and losing +// the record would be worse than keeping a slightly stale one. +func (r *Registry) Touch(name, os, claudeVersion string, acct *Account, when time.Time) { if r.Devices == nil { r.Devices = map[string]Device{} } - r.Devices[name] = Device{Name: name, OS: os, LastSync: when.UTC(), ClaudeVersion: claudeVersion} + if acct == nil { + acct = r.Devices[name].Account + } + r.Devices[name] = Device{Name: name, OS: os, LastSync: when.UTC(), ClaudeVersion: claudeVersion, Account: acct} } // Save writes the registry to dir/FileName. diff --git a/internal/clauderig/devices/devices_test.go b/internal/clauderig/devices/devices_test.go index 0e27603..ba34587 100644 --- a/internal/clauderig/devices/devices_test.go +++ b/internal/clauderig/devices/devices_test.go @@ -1,6 +1,9 @@ package devices import ( + "os" + "path/filepath" + "strings" "testing" "time" ) @@ -16,8 +19,8 @@ func TestRegistry_TouchSaveLoadList(t *testing.T) { t0 := time.Date(2026, 6, 10, 9, 0, 0, 0, time.UTC) t1 := time.Date(2026, 6, 12, 9, 0, 0, 0, time.UTC) - r.Touch("work-pc", "windows", "2.1.170", t0) - r.Touch("mbp", "macos", "2.1.175", t1) + r.Touch("work-pc", "windows", "2.1.170", nil, t0) + r.Touch("mbp", "macos", "2.1.175", &Account{AccountUUID: "acct-1", OrganizationUUID: "org-1", Email: "john@example.com"}, t1) if err := r.Save(dir); err != nil { t.Fatal(err) } @@ -38,8 +41,74 @@ func TestRegistry_TouchSaveLoadList(t *testing.T) { t.Fatalf("List order = %v", []string{list[0].Name, list[1].Name}) } // re-touch updates in place (no duplicate) - got.Touch("mbp", "macos", "2.1.176", t1.Add(time.Hour)) + got.Touch("mbp", "macos", "2.1.176", nil, t1.Add(time.Hour)) if len(got.Devices) != 2 || got.Devices["mbp"].ClaudeVersion != "2.1.176" { t.Errorf("re-touch should update in place: %+v", got.Devices) } } + +// The account a device synced as is the repo's only account provenance — CLI +// transcripts carry no account field at all — so it has to survive the round +// trip, and a later sync that can't read the identity must not erase it. +func TestTouch_RecordsAccountAndKeepsItWhenUnknown(t *testing.T) { + dir := t.TempDir() + when := time.Date(2026, 8, 25, 9, 0, 0, 0, time.UTC) + acct := &Account{AccountUUID: "456fc32e", OrganizationUUID: "f1eab509", Email: "john@example.com"} + + r, err := Load(dir) + if err != nil { + t.Fatal(err) + } + r.Touch("mbp", "macos", "2.1.237", acct, when) + if err := r.Save(dir); err != nil { + t.Fatal(err) + } + + got, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if a := got.Devices["mbp"].Account; a == nil || *a != *acct { + t.Fatalf("account did not survive the round trip: %+v", got.Devices["mbp"].Account) + } + + // A sync that couldn't read the identity is not evidence of a switch. + got.Touch("mbp", "macos", "2.1.238", nil, when.Add(time.Hour)) + if a := got.Devices["mbp"].Account; a == nil || a.Email != "john@example.com" { + t.Errorf("nil acct erased the recorded account: %+v", got.Devices["mbp"].Account) + } + + // A real switch does replace it. + other := &Account{AccountUUID: "03d1c0c9", OrganizationUUID: "e3055f13", Email: "john@other.com"} + got.Touch("mbp", "macos", "2.1.238", other, when.Add(2*time.Hour)) + if a := got.Devices["mbp"].Account; a == nil || a.Email != "john@other.com" { + t.Errorf("a real switch should replace the account: %+v", got.Devices["mbp"].Account) + } +} + +// Only the three identity fields travel — never the plan, tier, or anything +// else that shares the oauthAccount block with them. +func TestAccount_SerialisesIdentityFieldsOnly(t *testing.T) { + dir := t.TempDir() + r, _ := Load(dir) + r.Touch("mbp", "macos", "2.1.237", + &Account{AccountUUID: "a", OrganizationUUID: "o", Email: "e@x.com"}, + time.Date(2026, 8, 25, 9, 0, 0, 0, time.UTC)) + if err := r.Save(dir); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(dir, FileName)) + if err != nil { + t.Fatal(err) + } + for _, banned := range []string{"seatTier", "rateLimitTier", "accessToken", "refreshToken", "billingType", "organizationName", "subscription"} { + if strings.Contains(string(b), banned) { + t.Errorf("registry leaked non-identity field %q", banned) + } + } + for _, want := range []string{"accountUuid", "organizationUuid", "email"} { + if !strings.Contains(string(b), want) { + t.Errorf("registry missing identity field %q", want) + } + } +} diff --git a/internal/clauderig/engine/ledger.go b/internal/clauderig/engine/ledger.go index bad7917..82863c1 100644 --- a/internal/clauderig/engine/ledger.go +++ b/internal/clauderig/engine/ledger.go @@ -22,11 +22,22 @@ import ( // // Rows already matching the transcript's size and mtime are skipped without // opening the file, so a steady-state sync reads nothing and writes nothing. -func recordLedger(stagingDir, device string) (added int, total int, err error) { +func recordLedger(stagingDir, device, liveAccount string) (added int, total int, err error) { l, err := ledger.Open(stagingDir, device) if err != nil { return 0, 0, err } + // Ground truth first: Desktop files each session under the account that + // opened it, so anything this covers needs no guessing. It covers only + // sessions opened through Desktop (3% of a real staged tree), which is why + // liveAccount exists as the fallback for the rest. + byDesktop := desktopSessionAccounts(stagingDir) + // Judge "would this attribution improve things?" against EVERY device's + // ledger, not just this one's. Another machine may already hold Desktop + // ground truth for a session this machine only has a transcript for, and + // writing a weaker guess here would churn a row every sync for an answer + // the union then discards anyway. + union := ledger.LoadAll(stagingDir) projects := filepath.Join(stagingDir, "cli", "projects") if dirExists(projects) { walkErr := filepath.WalkDir(projects, func(p string, d os.DirEntry, werr error) error { @@ -51,21 +62,41 @@ func recordLedger(stagingDir, device string) (added int, total int, err error) { if id == "" { return nil } + var acct, src string + if a := byDesktop[id]; a != "" { + acct, src = a, ledger.AccountFromDesktop + } else if liveAccount != "" { + // Only ever an inference about this machine's own syncing, and + // sticky once stored — see ledger.AccountFromSync. + acct, src = liveAccount, ledger.AccountFromSync + } info, serr := os.Stat(p) if serr != nil { return nil } end := info.ModTime().UTC() - if l.Fresh(id, end, info.Size()) { + // An unchanged transcript is normally skipped without reading it. It + // still needs a pass when the attribution on offer OUTRANKS the stored + // one, or a session first labelled by inference could never be upgraded + // by its Desktop sidecar: the transcript it names never changes again, + // so there would be no later occasion to look. + _, localSrc := l.Attribution(id) + prevSrc := localSrc + if u := union[id].AccountSource; ledger.AccountRank(u) > ledger.AccountRank(prevSrc) { + prevSrc = u + } + if l.Fresh(id, end, info.Size()) && ledger.AccountRank(src) <= ledger.AccountRank(prevSrc) { return nil } e := ledger.Entry{ - ID: id, - Slug: slugOf(rel), - End: end, - Bytes: info.Size(), - Title: session.FirstPrompt(p), - Seen: time.Now().UTC(), + ID: id, + Slug: slugOf(rel), + End: end, + Bytes: info.Size(), + Title: session.FirstPrompt(p), + Seen: time.Now().UTC(), + Account: acct, + AccountSource: src, } if cwd, ok, cerr := project.CwdFromTranscript(p); cerr == nil && ok { e.Cwd = cwd diff --git a/internal/clauderig/engine/ledger_test.go b/internal/clauderig/engine/ledger_test.go index faa8a14..9874a6c 100644 --- a/internal/clauderig/engine/ledger_test.go +++ b/internal/clauderig/engine/ledger_test.go @@ -29,7 +29,7 @@ func TestRecordLedger_RowSurvivesRetention(t *testing.T) { p := stageTranscriptBody(t, staging, "-Users-j-Git-api", "sess-1", `{"type":"user","cwd":"/Users/j/Git/api","message":{"content":"the auth refactor"}}`+"\n") - added, total, err := recordLedger(staging, "mbp") + added, total, err := recordLedger(staging, "mbp", "") if err != nil || added != 1 || total != 1 { t.Fatalf("first pass: added=%d total=%d err=%v", added, total, err) } @@ -50,7 +50,7 @@ func TestRecordLedger_RowSurvivesRetention(t *testing.T) { // Nothing changed → nothing rewritten, which is what keeps an idle sync from // producing a commit. - added, _, err = recordLedger(staging, "mbp") + added, _, err = recordLedger(staging, "mbp", "") if err != nil || added != 0 { t.Fatalf("steady state should write nothing: added=%d err=%v", added, err) } @@ -59,7 +59,7 @@ func TestRecordLedger_RowSurvivesRetention(t *testing.T) { if err := os.Remove(p); err != nil { t.Fatal(err) } - if _, total, err = recordLedger(staging, "mbp"); err != nil { + if _, total, err = recordLedger(staging, "mbp", ""); err != nil { t.Fatal(err) } if total != 1 { @@ -73,7 +73,7 @@ func TestRecordLedger_RowSurvivesRetention(t *testing.T) { // A staging tree with no transcripts at all is an ordinary state (a Desktop-only // sync, a fresh repo), not an error. func TestRecordLedger_EmptyStagingIsFine(t *testing.T) { - added, total, err := recordLedger(t.TempDir(), "mbp") + added, total, err := recordLedger(t.TempDir(), "mbp", "") if err != nil || added != 0 || total != 0 { t.Fatalf("added=%d total=%d err=%v", added, total, err) } @@ -98,7 +98,7 @@ func TestRecordLedger_IgnoresSubagentTranscripts(t *testing.T) { t.Fatal(err) } - added, total, err := recordLedger(staging, "mbp") + added, total, err := recordLedger(staging, "mbp", "") if err != nil || total != 1 { t.Fatalf("want exactly one session, got total=%d added=%d err=%v", total, added, err) } @@ -106,7 +106,100 @@ func TestRecordLedger_IgnoresSubagentTranscripts(t *testing.T) { t.Errorf("parent's own transcript should own the row, got title %q", got) } // And the steady state writes nothing — the churn half of the same bug. - if added, _, _ := recordLedger(staging, "mbp"); added != 0 { + if added, _, _ := recordLedger(staging, "mbp", ""); added != 0 { t.Errorf("steady state rewrote %d row(s)", added) } } + +// stageSidecar writes a Desktop sidecar under the account/org path Desktop +// files it at — the path IS the attribution. +func stageAccountSidecar(t *testing.T, staging, root, acct, org, cliSessionID string) { + t.Helper() + dir := filepath.Join(staging, root, "claude-code-sessions", acct, org) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"sessionId":"local_x","cliSessionId":"` + cliSessionID + `","title":"t"}` + if err := os.WriteFile(filepath.Join(dir, "local_x_"+cliSessionID+".json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// A CLI transcript carries no account of its own. Desktop's sidecar path is the +// only ground truth, and the syncing machine's login is the fallback for the +// ~97% of sessions no sidecar covers. +func TestRecordLedger_AttributesAccounts(t *testing.T) { + staging := t.TempDir() + body := `{"type":"user","cwd":"/Users/j/Git/api","message":{"content":"hi"}}` + "\n" + stageTranscriptBody(t, staging, "-Users-j-Git-api", "desktop-sess", body) + stageTranscriptBody(t, staging, "-Users-j-Git-api", "cli-only-sess", body) + // only one of them was opened through Desktop + stageAccountSidecar(t, staging, "desktop", "acct-desktop", "org-1", "desktop-sess") + + if _, _, err := recordLedger(staging, "mbp", "acct-live"); err != nil { + t.Fatal(err) + } + l, err := ledger.Open(staging, "mbp") + if err != nil { + t.Fatal(err) + } + if a, s := l.Attribution("desktop-sess"); a != "acct-desktop" || s != ledger.AccountFromDesktop { + t.Errorf("sidecar session = %q/%q, want acct-desktop/%s", a, s, ledger.AccountFromDesktop) + } + if a, s := l.Attribution("cli-only-sess"); a != "acct-live" || s != ledger.AccountFromSync { + t.Errorf("cli-only session = %q/%q, want acct-live/%s", a, s, ledger.AccountFromSync) + } +} + +// The transcript never changes again once a session ends, so if an unchanged +// transcript were always skipped, a sidecar appearing later could never upgrade +// the guess it was first recorded with. +func TestRecordLedger_SidecarUpgradesAnUnchangedTranscript(t *testing.T) { + staging := t.TempDir() + stageTranscriptBody(t, staging, "-Users-j-Git-api", "sess-1", + `{"type":"user","cwd":"/Users/j/Git/api","message":{"content":"hi"}}`+"\n") + + if _, _, err := recordLedger(staging, "mbp", "acct-live"); err != nil { + t.Fatal(err) + } + // Desktop syncs later; the transcript is untouched. + stageAccountSidecar(t, staging, "desktop", "acct-desktop", "org-1", "sess-1") + if _, _, err := recordLedger(staging, "mbp", "acct-live"); err != nil { + t.Fatal(err) + } + l, _ := ledger.Open(staging, "mbp") + if a, s := l.Attribution("sess-1"); a != "acct-desktop" || s != ledger.AccountFromDesktop { + t.Errorf("got %q/%q, want acct-desktop/%s", a, s, ledger.AccountFromDesktop) + } +} + +// Not logged in (or an unreadable identity) must leave rows unattributed rather +// than invent one — an empty account is honest, a guessed one is not. +func TestRecordLedger_NoLiveAccountLeavesRowsUnattributed(t *testing.T) { + staging := t.TempDir() + stageTranscriptBody(t, staging, "-Users-j-Git-api", "sess-1", + `{"type":"user","cwd":"/Users/j/Git/api","message":{"content":"hi"}}`+"\n") + if _, _, err := recordLedger(staging, "mbp", ""); err != nil { + t.Fatal(err) + } + l, _ := ledger.Open(staging, "mbp") + if a, s := l.Attribution("sess-1"); a != "" || s != "" { + t.Errorf("got %q/%q, want both empty", a, s) + } +} + +// Profile roots nest the sidecar tree under data/ — both layouts must be read, +// or a profile-only account's sessions would never be attributed. +func TestRecordLedger_ReadsProfileSidecarLayout(t *testing.T) { + staging := t.TempDir() + stageTranscriptBody(t, staging, "-Users-j-Git-api", "sess-1", + `{"type":"user","cwd":"/Users/j/Git/api","message":{"content":"hi"}}`+"\n") + stageAccountSidecar(t, staging, filepath.Join("desktop@work", "data"), "acct-work", "org-2", "sess-1") + if _, _, err := recordLedger(staging, "mbp", ""); err != nil { + t.Fatal(err) + } + l, _ := ledger.Open(staging, "mbp") + if a, s := l.Attribution("sess-1"); a != "acct-work" || s != ledger.AccountFromDesktop { + t.Errorf("got %q/%q, want acct-work/%s", a, s, ledger.AccountFromDesktop) + } +} diff --git a/internal/clauderig/engine/links_test.go b/internal/clauderig/engine/links_test.go index e9014a6..07cf771 100644 --- a/internal/clauderig/engine/links_test.go +++ b/internal/clauderig/engine/links_test.go @@ -131,3 +131,131 @@ func TestRestore_LinkSkippedWhenTargetAbsentOrOccupied(t *testing.T) { t.Error("occupied path should keep the local dir") } } + +// A stale 0-byte file left in staging by a pre-link-aware sync must never be +// written back over the live shared-memory symlink. It used to be copied with +// os.OpenFile(dst, O_WRONLY|…), which follows the link onto a directory and +// fails the whole restore with "open …/memory: is a directory". +func TestRestore_StagedFileNeverWritesThroughSymlink(t *testing.T) { + staging := t.TempDir() + write(t, staging, "cli/projects/-main/s.jsonl", "t\n") + write(t, staging, "cli/projects/-main/memory/MEMORY.md", "facts") + write(t, staging, "cli/projects/-wt/s.jsonl", "t\n") + // the stale artefact: a link path staged as an empty regular file + write(t, staging, "cli/projects/-wt/memory", "") + + target := t.TempDir() + write(t, target, "projects/-main/memory/MEMORY.md", "facts") + if err := os.MkdirAll(filepath.Join(target, "projects", "-wt"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(target, "projects", "-wt", "memory") + if err := os.Symlink(filepath.Join(target, "projects", "-main", "memory"), link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + jane := config.Machine{Name: "jane", OS: pathmap.OSMacOS, Home: "/Users/jane"} + rep, err := Restore(RestoreOptions{StagingDir: staging, Config: targetRootConfig(target), Machine: jane, TargetOverride: override("cli", target)}) + if err != nil { + t.Fatalf("restore failed on a live shared-memory link: %v", err) + } + if rep.Roots[0].LinksKept != 1 { + t.Errorf("LinksKept = %d, want 1", rep.Roots[0].LinksKept) + } + // the link survived as a link, still pointing at the shared dir + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("memory should still be a symlink, got %v (err %v)", info, err) + } + // and writing through it clobbered nothing + if b, _ := os.ReadFile(filepath.Join(link, "MEMORY.md")); string(b) != "facts" { + t.Errorf("shared memory through link = %q, want %q", b, "facts") + } +} + +// A directory symlink whose target sits outside the synced set is not +// recordable as a link, but it is still a directory — it must not be offered +// as a file. Any 0-byte placeholder an older sync staged for it is retired, so +// the repo digs itself out instead of handing the file back every restore. +func TestSync_RetiresStagedFileShadowedByDirLink(t *testing.T) { + live := t.TempDir() + write(t, live, "projects/-wt/s.jsonl", + `{"type":"user","cwd":"/Users/john/Git/wt","isSidechain":false}`+"\n") + outside := t.TempDir() // link target outside the root: not recordable + if err := os.WriteFile(filepath.Join(outside, "MEMORY.md"), []byte("facts"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(live, "projects", "-wt", "memory")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + staging := t.TempDir() + stale := filepath.Join(staging, "cli", "projects", "-wt", "memory") + write(t, staging, "cli/projects/-wt/memory", "") // pre-link-aware residue + + m := config.Machine{Name: "mbp", OS: pathmap.OSMacOS, Home: "/Users/john"} + if _, err := Sync(Options{StagingDir: staging, Config: cliOnlyConfig(live), Machine: m, SourceOverride: override("cli", live)}); err != nil { + t.Fatalf("sync failed on an unrecordable dir link: %v", err) + } + if _, err := os.Lstat(stale); !os.IsNotExist(err) { + t.Error("stale staged file shadowed by a dir link should be retired") + } +} + +// The guard must cover symlinked ANCESTORS, not just a symlinked leaf. Another +// machine that holds this project as a real directory stages +// projects//memory/MEMORY.md; here that memory/ is a link, so writing the +// descendant follows it and clobbers the canonical project's memory — the same +// damage the leaf check prevents, one level up. +func TestRestore_NeverWritesThroughASymlinkedParent(t *testing.T) { + staging := t.TempDir() + write(t, staging, "cli/projects/-main/memory/MEMORY.md", "canonical facts") + write(t, staging, "cli/projects/-wt/s.jsonl", "t\n") + // staged as a real path by the machine that had it as a real directory + write(t, staging, "cli/projects/-wt/memory/MEMORY.md", "worktree copy") + + target := t.TempDir() + write(t, target, "projects/-main/memory/MEMORY.md", "canonical facts") + if err := os.MkdirAll(filepath.Join(target, "projects", "-wt"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(target, "projects", "-wt", "memory") + if err := os.Symlink(filepath.Join(target, "projects", "-main", "memory"), link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + jane := config.Machine{Name: "jane", OS: pathmap.OSMacOS, Home: "/Users/jane"} + if _, err := Restore(RestoreOptions{StagingDir: staging, Config: targetRootConfig(target), Machine: jane, TargetOverride: override("cli", target)}); err != nil { + t.Fatalf("restore: %v", err) + } + // The canonical memory must be untouched — writing through the link would + // have replaced it with the worktree copy. + got, _ := os.ReadFile(filepath.Join(target, "projects", "-main", "memory", "MEMORY.md")) + if string(got) != "canonical facts" { + t.Errorf("canonical memory clobbered through the link: got %q", got) + } +} + +// filepath.Rel returns "..memory" unchanged for a directory of that name, so a +// bare `..` prefix test would call it outside the root and skip the symlink +// check — writing through the very link the guard exists to protect. +func TestRestore_DotDotPrefixedDirIsStillGuarded(t *testing.T) { + staging := t.TempDir() + write(t, staging, "cli/projects/-main/memory/MEMORY.md", "canonical facts") + write(t, staging, "cli/..memory/NOTES.md", "staged copy") + + target := t.TempDir() + write(t, target, "projects/-main/memory/MEMORY.md", "canonical facts") + link := filepath.Join(target, "..memory") + if err := os.Symlink(filepath.Join(target, "projects", "-main", "memory"), link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + jane := config.Machine{Name: "jane", OS: pathmap.OSMacOS, Home: "/Users/jane"} + if _, err := Restore(RestoreOptions{StagingDir: staging, Config: targetRootConfig(target), Machine: jane, TargetOverride: override("cli", target)}); err != nil { + t.Fatalf("restore: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "projects", "-main", "memory", "NOTES.md")); err == nil { + t.Error("wrote through a link under a '..'-prefixed directory") + } +} diff --git a/internal/clauderig/engine/restore.go b/internal/clauderig/engine/restore.go index 58f6a33..996493e 100644 --- a/internal/clauderig/engine/restore.go +++ b/internal/clauderig/engine/restore.go @@ -23,6 +23,7 @@ type RestoreRootResult struct { Files int SlugsRewritten int Links int // shared-memory symlinks recreated from the manifest + LinksKept int // staged files skipped because a symlink already holds that path Pruned int // files removed as deleted-upstream (--prune) // DesktopSessions counts Claude Desktop Code-session sidecars written this // restore (claude-code-sessions/**/local_*.json). Desktop only rebuilds its @@ -43,7 +44,11 @@ func (r *RestoreReport) DesktopSessions() int { } // isDesktopSessionSidecar reports whether a restored (slash) rel path is a -// Desktop Code-session sidecar: claude-code-sessions///local_.json. +// Desktop Code-session sidecar: +// claude-code-sessions///local_.json. Those +// two uuids are the account's, straight from ~/.claude.json's oauthAccount — so +// this tree is already partitioned per account, which is what devices.Account +// records for the CLI side, where nothing else does. // The local_.json shape is matched on the basename so a directory like // claude-code-sessions/org/local_cache/other.json isn't miscounted as a session. func isDesktopSessionSidecar(rel string) bool { @@ -119,6 +124,7 @@ func Restore(opts RestoreOptions) (*RestoreReport, error) { } rewritten := map[string]bool{} written := map[string]bool{} + links := linkCache{} pm := permFor(r.ID) files, err := listFiles(stageRoot) @@ -137,6 +143,23 @@ func Restore(opts RestoreOptions) (*RestoreReport, error) { src := filepath.Join(stageRoot, filepath.FromSlash(rel)) dst := filepath.Join(target, filepath.FromSlash(targetRel)) + // A symlink at or above dst is this machine's own state — nearly always + // one of the shared-memory links restoreLinks recreates. Every write + // below follows a symlink, so restoring a staged file over one would + // silently clobber the link's target, or fail outright with EISDIR when + // the link points at a directory. Leave it alone (and count it as + // written so --prune doesn't collect it). + // + // Ancestors matter as much as the leaf: another machine holding this + // project as a real directory stages projects//memory/MEMORY.md, + // and writing that descendant here follows the linked memory/ straight + // into the canonical project. + if isSymlink(dst) || links.underSymlink(target, dst) { + written[targetRel] = true + rr.LinksKept++ + continue + } + if strings.HasSuffix(rel, ".json") { if err := restoreJSON(src, dst, opts.Machine.Resolver(), pm); err != nil { return nil, err @@ -316,6 +339,38 @@ func listFiles(root string) ([]string, error) { return out, err } +// linkCache remembers which destination directories sit on or under a symlink, +// so the ancestor walk costs one Lstat per directory across the whole restore +// rather than one per path component per file. +type linkCache map[string]bool + +// underSymlink reports whether any ancestor of dst, up to and excluding root, is +// a symlink. root itself is never judged: the target directory is where the user +// pointed restore, and following it is the whole intent. +func (c linkCache) underSymlink(root, dst string) bool { + dir := filepath.Dir(dst) + if v, ok := c[dir]; ok { + return v + } + rel, err := filepath.Rel(root, dir) + // Only ".." itself, or a path BELOW it, is outside the root. A bare prefix + // test would also catch a real directory named "..memory" — Rel returns that + // name unchanged — and skip the very symlink check this exists to perform. + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false // at or outside the root — nothing left to walk + } + res := isSymlink(dir) || c.underSymlink(root, dir) + c[dir] = res + return res +} + +// isSymlink reports whether p is a symlink, without following it. A missing path +// is not a symlink, so a fresh machine takes the ordinary write path. +func isSymlink(p string) bool { + fi, err := os.Lstat(p) + return err == nil && fi.Mode()&fs.ModeSymlink != 0 +} + func copyFile(src, dst string, pm perm) error { if err := os.MkdirAll(filepath.Dir(dst), pm.dir); err != nil { return err diff --git a/internal/clauderig/engine/sidecar.go b/internal/clauderig/engine/sidecar.go index 26e5f0a..78b716b 100644 --- a/internal/clauderig/engine/sidecar.go +++ b/internal/clauderig/engine/sidecar.go @@ -158,3 +158,85 @@ func pruneSidecarTree(root string, ids map[string]bool) (int, error) { removeEmptyDirs(root) return pruned, nil } + +// desktopSessionAccounts maps a CLI session id to the accountUuid that owns it, +// read from every staged Desktop sidecar tree. +// +// The account is the PATH, not a field in the file: +// /claude-code-sessions///local_.json. +// Desktop files each session under the account that opened it, which makes this +// ground truth about ownership — unlike the syncing machine's current login, +// which is only ever an inference about someone else's past. +// +// Every staged tree is read, including roots this run did not walk. That is the +// opposite of pruneOrphanedSidecars' fail-open rule, and deliberately so: that +// pass DELETES on absence, so a partial view is dangerous, while this one only +// ever adds an attribution, so a partial view merely attributes less. +func desktopSessionAccounts(stagingDir string) map[string]string { + out := map[string]string{} + entries, err := os.ReadDir(stagingDir) + if err != nil { + return out + } + for _, e := range entries { + if !e.IsDir() { + continue + } + // Both layouts: a machine-wide root holds the tree directly, a profile + // root nests it under data/ (see desktopTreesIn). + for _, tree := range []string{ + filepath.Join(stagingDir, e.Name(), sidecarTree), + filepath.Join(stagingDir, e.Name(), "data", sidecarTree), + } { + readSidecarAccounts(tree, out) + } + } + return out +} + +// readSidecarAccounts adds one tree's //local_*.json rows to +// out. A sidecar that cannot be read or names no session is skipped: attribution +// is optional, so an unreadable file costs one session's label and nothing else. +func readSidecarAccounts(root string, out map[string]string) { + if !dirExists(root) { + return + } + accounts, err := os.ReadDir(root) + if err != nil { + return + } + for _, acct := range accounts { + if !acct.IsDir() { + continue + } + orgs, err := os.ReadDir(filepath.Join(root, acct.Name())) + if err != nil { + continue + } + for _, org := range orgs { + if !org.IsDir() { + continue + } + dir := filepath.Join(root, acct.Name(), org.Name()) + files, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, f := range files { + name := f.Name() + if !strings.HasPrefix(name, "local_") || !strings.HasSuffix(name, ".json") { + continue + } + data, rerr := os.ReadFile(filepath.Join(dir, name)) + if rerr != nil { + continue + } + var ref sidecarRef + if json.Unmarshal(data, &ref) != nil || ref.CLISessionID == "" { + continue + } + out[ref.CLISessionID] = acct.Name() + } + } + } +} diff --git a/internal/clauderig/engine/sync.go b/internal/clauderig/engine/sync.go index a51fe84..547fe83 100644 --- a/internal/clauderig/engine/sync.go +++ b/internal/clauderig/engine/sync.go @@ -30,7 +30,7 @@ type RootResult struct { RetentionByAge int // project transcripts dropped as older than the window SkippedFiles int // files that vanished/were unreadable mid-sync (live churn) Oversize []string // rel paths dropped for exceeding MaxFileBytes - Disallowed int // staged files removed because the allowlist no longer permits them + Disallowed int // staged files retired: the allowlist no longer permits them, or the live path is a directory Skipped bool // root absent on this machine } @@ -67,6 +67,11 @@ type Options struct { // roots — see profiles.go. Each is walked as its own root, and they follow // the Desktop root's enabled flag. Profiles []string + // LiveAccountUUID is the account this machine is logged into Claude Code as, + // used to attribute ledger rows that no Desktop sidecar covers. Empty (not + // logged in, unreadable) simply leaves those rows unattributed — a guess is + // never invented, and a stored attribution is never overwritten by one. + LiveAccountUUID string } // Sync materialises the allowlisted, redacted file set for each enabled root into @@ -249,7 +254,7 @@ func Sync(opts Options) (*Report, error) { // Only for roots that resolved on this machine: a root we skipped tells us // nothing about whether its staged files are still wanted, and pruning it // would delete another machine's data. - disallowed, perr := reconcileStagedRoot(stageRoot, allowlist.For(r.ID)) + disallowed, perr := reconcileStagedRoot(stageRoot, loc, allowlist.For(r.ID)) if perr != nil { return nil, fmt.Errorf("reconcile staged %s: %w", r.ID, perr) } @@ -263,7 +268,7 @@ func Sync(opts Options) (*Report, error) { // is about to age out still leaves a searchable row behind — otherwise `search` // answers "no such session", which reads as "that chat never existed" rather // than "its body is older than the window, recover it from git history". - if added, total, lerr := recordLedger(opts.StagingDir, opts.Machine.Name); lerr == nil { + if added, total, lerr := recordLedger(opts.StagingDir, opts.Machine.Name, opts.LiveAccountUUID); lerr == nil { rep.LedgerAdded, rep.LedgerTotal = added, total } else { // Best-effort: the ledger is a convenience for later searches and must @@ -569,7 +574,10 @@ func pruneAgedStagedProjects(projectsDir string, cutoff time.Time) (pruned int, // slugs this machine has never seen) are unaffected as long as the allowlist still // permits them. Retention, which removes allowed-but-aged files, is separate and // runs on its own. -func reconcileStagedRoot(stageRoot string, l allowlist.List) (int, error) { +// +// liveRoot is this machine's copy of the root ("" when it didn't resolve). It +// also retires staged files whose live counterpart is a directory — see below. +func reconcileStagedRoot(stageRoot, liveRoot string, l allowlist.List) (int, error) { if !dirExists(stageRoot) { return 0, nil } @@ -589,11 +597,25 @@ func reconcileStagedRoot(stageRoot string, l allowlist.List) (int, error) { if rerr != nil { return nil } - if l.Match(filepath.ToSlash(rel)) { + if !l.Match(filepath.ToSlash(rel)) { + if os.Remove(p) == nil { + removed++ + } return nil } - if os.Remove(p) == nil { - removed++ + // A staged FILE whose live counterpart is a DIRECTORY (or a symlink to + // one) is a category error left by an older sync: the walk now reports + // directory symlinks as links and never as files, so no future sync will + // ever refresh or remove this copy, while restore keeps trying to write + // it back over the live link. Retire it here so the repo can dig itself + // out. Judged only where the path exists on this machine — staging also + // carries other machines' files, whose absence here means nothing. + if liveRoot != "" { + if fi, serr := os.Stat(filepath.Join(liveRoot, rel)); serr == nil && fi.IsDir() { + if os.Remove(p) == nil { + removed++ + } + } } return nil }) diff --git a/internal/clauderig/ledger/ledger.go b/internal/clauderig/ledger/ledger.go index 2fc5ba8..1b5af93 100644 --- a/internal/clauderig/ledger/ledger.go +++ b/internal/clauderig/ledger/ledger.go @@ -51,6 +51,82 @@ type Entry struct { RecordedBy string `json:"recordedBy,omitempty"` // Seen is when this row was last written. Seen time.Time `json:"seen"` + // Account is the accountUuid of the Claude Code login this session belongs + // to, empty when unknown. CLI transcripts carry no account field of their + // own, so this is the only place a session's account is ever recorded — and + // it cannot be reconstructed later, which is why it is captured here rather + // than derived at query time. + Account string `json:"account,omitempty"` + // AccountSource says HOW Account was determined, because the two ways differ + // sharply in confidence and a filter that hid that would be the kind of + // quiet lie RecordedBy is named to avoid. See AccountFrom*. + AccountSource string `json:"accountSource,omitempty"` +} + +// How an Entry.Account was determined, worst to best. A higher rank may +// overwrite a lower one; equal ranks never overwrite, so the first sync to +// attribute a session wins and later syncs cannot relabel it. +const ( + // AccountFromSync is an INFERENCE: the account the syncing machine was + // logged in as when it first recorded the row. Right for the ordinary case + // — you sync the machine you work on — and wrong if you switched accounts + // between running the session and syncing it, or if this machine restored + // another machine's transcripts and staged them as its own. Sticky for + // exactly that reason: the earliest sighting is the closest to the truth. + AccountFromSync = "sync" + // AccountFromDesktop is GROUND TRUTH, taken from the path Claude Desktop + // files its session sidecar under — + // claude-code-sessions/// — which is the + // account itself, not a guess about it. Only sessions opened through + // Desktop have one (measured: 3% of a real staged tree), so it upgrades + // what it covers and leaves the rest to the inference above. + AccountFromDesktop = "desktop" +) + +// AccountRank orders the sources; an unattributed row ranks lowest. Exported so +// the recorder can tell, before reading a transcript, whether the attribution it +// is about to offer would actually improve on the stored one. +func AccountRank(source string) int { + switch source { + case AccountFromDesktop: + return 2 + case AccountFromSync: + return 1 + } + return 0 +} + +// bestAccount picks the attribution to surface when two devices hold a row for +// the same session. +// +// Rank first. On EQUAL rank the EARLIER sighting wins, which is what makes the +// stickiness promise hold across devices as well as within a file: mergeAccount +// keeps its first argument on a tie, and the caller's first argument is the +// NEWER row, so using it here would let a second machine recording an +// already-staged transcript under its own live login relabel the session on a +// routine sync — the exact relabelling Note() refuses to do locally. +func bestAccount(a, b Entry) (account, source string) { + ra, rb := AccountRank(a.AccountSource), AccountRank(b.AccountSource) + if rb > ra { + return b.Account, b.AccountSource + } + if ra > rb { + return a.Account, a.AccountSource + } + if b.Seen.Before(a.Seen) { + return b.Account, b.AccountSource + } + return a.Account, a.AccountSource +} + +// mergeAccount picks the attribution to keep. Ties go to prev — attribution is +// sticky, so re-syncing a session under a different login cannot rewrite whose +// it was. +func mergeAccount(prev, next Entry) (account, source string) { + if next.Account != "" && AccountRank(next.AccountSource) > AccountRank(prev.AccountSource) { + return next.Account, next.AccountSource + } + return prev.Account, prev.AccountSource } // Ledger is one device's file, loaded for update. @@ -110,7 +186,12 @@ func (l *Ledger) Note(e Entry) bool { return false } if prev, ok := l.rows[e.ID]; ok { - if prev.Bytes == e.Bytes && prev.End.Equal(e.End) { + e.Account, e.AccountSource = mergeAccount(prev, e) + // An account upgrade is a real change even when the transcript is byte + // identical — a session first attributed by inference and later covered + // by its Desktop sidecar must be allowed to take the better answer. + upgraded := e.Account != prev.Account || e.AccountSource != prev.AccountSource + if prev.Bytes == e.Bytes && prev.End.Equal(e.End) && !upgraded { return false } // The same session id can appear under two slugs — a transcript copied into @@ -119,7 +200,20 @@ func (l *Ledger) Note(e Entry) bool { // forever, which costs a commit per sync for a file nothing changed in. // Measured on a real tree: 5 such ids, 10 pointless row writes per pass. if prev.End.After(e.End) { - return false + if !upgraded { + return false + } + // Take ONLY the better attribution; the older twin must not drag its + // stale transcript state over the newer row. + prev.Account, prev.AccountSource = e.Account, e.AccountSource + prev.RecordedBy = l.device + // The row IS being rewritten, so Seen has to move with it — a stale + // stamp would misreport when this attribution was last confirmed, + // and Seen is a tiebreak in the cross-device union. + prev.Seen = e.Seen + l.rows[e.ID] = prev + l.dirty = true + return true } } e.RecordedBy = l.device @@ -128,6 +222,14 @@ func (l *Ledger) Note(e Entry) bool { return true } +// Attribution reports the account currently recorded for a session, so a caller +// can decide whether a better source is worth a rewrite. Empty when the session +// is unknown or unattributed. +func (l *Ledger) Attribution(id string) (account, source string) { + e := l.rows[id] + return e.Account, e.AccountSource +} + // Fresh reports whether the ledger already holds this exact transcript state, // so callers can skip the file read that computes a title. func (l *Ledger) Fresh(id string, end time.Time, size int64) bool { @@ -212,10 +314,22 @@ func LoadAll(dir string) map[string]Entry { continue } for _, r := range rows { - if prev, ok := out[r.ID]; ok && !newerRow(r, prev) { + prev, ok := out[r.ID] + if !ok { + out[r.ID] = r continue } - out[r.ID] = r + winner, loser := prev, r + if newerRow(r, prev) { + winner, loser = r, prev + } + // Attribution follows RANK across devices, not recency. Note() + // enforces that within one device's file, but the union is where two + // files meet: without this, a machine that later re-saw the same + // transcript with no sidecar would replace another machine's Desktop + // ground truth with its own inference purely by having synced last. + winner.Account, winner.AccountSource = bestAccount(winner, loser) + out[r.ID] = winner } } return out diff --git a/internal/clauderig/ledger/ledger_test.go b/internal/clauderig/ledger/ledger_test.go index 70cf187..6f585dc 100644 --- a/internal/clauderig/ledger/ledger_test.go +++ b/internal/clauderig/ledger/ledger_test.go @@ -224,3 +224,178 @@ func TestLoadAll_PrefersTheLaterSessionNotTheLaterWrite(t *testing.T) { t.Errorf("a later write of an older session won: %+v", got["s"]) } } + +// Attribution is ranked and sticky: ground truth may upgrade an inference, but +// nothing downgrades, and re-syncing under a different login cannot relabel a +// session someone else's. +func TestNote_AccountAttributionIsRankedAndSticky(t *testing.T) { + base := Entry{ID: "s1", End: time.Unix(100, 0).UTC(), Bytes: 10} + + t.Run("inference is recorded when nothing is known", func(t *testing.T) { + l, _ := Open(t.TempDir(), "mbp") + e := base + e.Account, e.AccountSource = "acct-a", AccountFromSync + if !l.Note(e) { + t.Fatal("first note should write") + } + if a, s := l.Attribution("s1"); a != "acct-a" || s != AccountFromSync { + t.Errorf("got %q/%q", a, s) + } + }) + + t.Run("a later sync under another login does not relabel", func(t *testing.T) { + l, _ := Open(t.TempDir(), "mbp") + e := base + e.Account, e.AccountSource = "acct-a", AccountFromSync + l.Note(e) + // same rank, different account, and a changed transcript + e2 := base + e2.Bytes, e2.End = 20, time.Unix(200, 0).UTC() + e2.Account, e2.AccountSource = "acct-b", AccountFromSync + l.Note(e2) + if a, _ := l.Attribution("s1"); a != "acct-a" { + t.Errorf("sticky attribution lost: got %q, want acct-a", a) + } + }) + + t.Run("desktop ground truth upgrades an inference", func(t *testing.T) { + l, _ := Open(t.TempDir(), "mbp") + e := base + e.Account, e.AccountSource = "acct-a", AccountFromSync + l.Note(e) + up := base // byte-identical transcript: only the attribution improves + up.Account, up.AccountSource = "acct-b", AccountFromDesktop + if !l.Note(up) { + t.Fatal("an account upgrade must write even when the transcript is unchanged") + } + if a, s := l.Attribution("s1"); a != "acct-b" || s != AccountFromDesktop { + t.Errorf("got %q/%q, want acct-b/%s", a, s, AccountFromDesktop) + } + }) + + t.Run("ground truth is never downgraded", func(t *testing.T) { + l, _ := Open(t.TempDir(), "mbp") + e := base + e.Account, e.AccountSource = "acct-b", AccountFromDesktop + l.Note(e) + down := base + down.Bytes, down.End = 20, time.Unix(200, 0).UTC() + down.Account, down.AccountSource = "acct-a", AccountFromSync + l.Note(down) + if a, s := l.Attribution("s1"); a != "acct-b" || s != AccountFromDesktop { + t.Errorf("ground truth downgraded to %q/%q", a, s) + } + }) + + t.Run("an unattributed row stays writable and takes the first account", func(t *testing.T) { + l, _ := Open(t.TempDir(), "mbp") + l.Note(base) + if a, _ := l.Attribution("s1"); a != "" { + t.Fatalf("expected no attribution, got %q", a) + } + later := base + later.Account, later.AccountSource = "acct-a", AccountFromSync + if !l.Note(later) { + t.Fatal("adding a first attribution must write") + } + if a, _ := l.Attribution("s1"); a != "acct-a" { + t.Errorf("got %q", a) + } + }) +} + +// Note() ranks attribution within one device's file; the union is where two +// files meet. Without ranking there too, a machine that later re-saw the same +// transcript with no sidecar would replace another machine's Desktop ground +// truth purely by having synced last. +func TestLoadAll_KeepsGroundTruthAcrossDevices(t *testing.T) { + dir := t.TempDir() + end := time.Unix(100, 0).UTC() + + // Machine A: ground truth, seen earlier. + a, _ := Open(dir, "machine-a") + a.Note(Entry{ID: "s1", End: end, Bytes: 10, Seen: time.Unix(500, 0).UTC(), + Account: "acct-truth", AccountSource: AccountFromDesktop}) + if err := a.Save(); err != nil { + t.Fatal(err) + } + + // Machine B: same transcript, no sidecar, synced LATER. + b, _ := Open(dir, "machine-b") + b.Note(Entry{ID: "s1", End: end, Bytes: 10, Seen: time.Unix(900, 0).UTC(), + Account: "acct-guess", AccountSource: AccountFromSync}) + if err := b.Save(); err != nil { + t.Fatal(err) + } + + got := LoadAll(dir)["s1"] + if got.Account != "acct-truth" || got.AccountSource != AccountFromDesktop { + t.Errorf("union = %q/%q, want acct-truth/%s — recency must not outrank ground truth", + got.Account, got.AccountSource, AccountFromDesktop) + } + // The rest of the row still comes from the newer sighting. + if !got.Seen.Equal(time.Unix(900, 0).UTC()) { + t.Errorf("Seen = %v, want the newer row's", got.Seen) + } +} + +// An older twin that only contributes a better attribution is still a rewrite, +// so its Seen stamp has to move — it is a tiebreak in the union. +func TestNote_OlderTwinUpgradeStampsSeen(t *testing.T) { + l, _ := Open(t.TempDir(), "mbp") + newer := Entry{ID: "s1", End: time.Unix(200, 0).UTC(), Bytes: 20, + Seen: time.Unix(500, 0).UTC(), Account: "a", AccountSource: AccountFromSync} + l.Note(newer) + + olderWithTruth := Entry{ID: "s1", End: time.Unix(100, 0).UTC(), Bytes: 10, + Seen: time.Unix(900, 0).UTC(), Account: "b", AccountSource: AccountFromDesktop} + if !l.Note(olderWithTruth) { + t.Fatal("an attribution upgrade from the older twin must write") + } + got := LoadAll(mustSave(t, l))["s1"] + if got.AccountSource != AccountFromDesktop || got.Account != "b" { + t.Errorf("attribution = %q/%q, want b/%s", got.Account, got.AccountSource, AccountFromDesktop) + } + if !got.End.Equal(time.Unix(200, 0).UTC()) || got.Bytes != 20 { + t.Errorf("the older twin overwrote transcript state: end=%v bytes=%d", got.End, got.Bytes) + } + if !got.Seen.Equal(time.Unix(900, 0).UTC()) { + t.Errorf("Seen = %v, want the writing row's stamp", got.Seen) + } +} + +func mustSave(t *testing.T, l *Ledger) string { + t.Helper() + if err := l.Save(); err != nil { + t.Fatal(err) + } + return l.dir +} + +// Two devices, both with only a sync-rank guess: the FIRST attribution owns the +// session. Ranking alone is not enough — mergeAccount keeps its first argument +// on a tie, and the union's first argument is the newer row, so a second +// machine recording an already-staged transcript under its own live login would +// otherwise relabel it on a routine sync. +func TestLoadAll_EqualRankKeepsTheFirstAttribution(t *testing.T) { + dir := t.TempDir() + end := time.Unix(100, 0).UTC() + + first, _ := Open(dir, "machine-a") + first.Note(Entry{ID: "s1", End: end, Bytes: 10, Seen: time.Unix(500, 0).UTC(), + Account: "acct-first", AccountSource: AccountFromSync}) + if err := first.Save(); err != nil { + t.Fatal(err) + } + + later, _ := Open(dir, "machine-b") + later.Note(Entry{ID: "s1", End: end, Bytes: 10, Seen: time.Unix(900, 0).UTC(), + Account: "acct-later", AccountSource: AccountFromSync}) + if err := later.Save(); err != nil { + t.Fatal(err) + } + + if got := LoadAll(dir)["s1"]; got.Account != "acct-first" { + t.Errorf("union = %q, want acct-first — a routine sync must not relabel", got.Account) + } +}