diff --git a/cmd/google_supervisor.go b/cmd/google_supervisor.go index f1b9475..132c6f8 100644 --- a/cmd/google_supervisor.go +++ b/cmd/google_supervisor.go @@ -64,6 +64,7 @@ type googleCredentialRepairer struct { clearRepair func() minInterval time.Duration now func() time.Time + logger zerolog.Logger mu sync.Mutex paceGate chan struct{} @@ -77,7 +78,8 @@ func newGoogleCredentialRepairer( refresh func(context.Context, string) error, flagRepair func(), clearRepair func(), -) bridge.CredentialRepairer { + logger zerolog.Logger, +) *googleCredentialRepairer { return &googleCredentialRepairer{ sessionPath: sessionPath, canRepair: canRepair, @@ -85,6 +87,7 @@ func newGoogleCredentialRepairer( flagRepair: flagRepair, clearRepair: clearRepair, minInterval: googleCredentialRepairMinInterval(), + logger: logger, } } @@ -97,6 +100,11 @@ func googleCredentialRepairMinInterval() time.Duration { return interval } +// PacedRepairCount reports how many automatic Google credential repairs were +// delayed to honour the minimum repair interval. It stays 0 through the healthy +// ~15-minute heal cycle; a climbing count is the production signal that +// something is revoking the imported cookies within minutes. Also logged as it +// happens and surfaced as google.repairs_paced in /api/status. func (r *googleCredentialRepairer) PacedRepairCount() uint64 { return r.pacedCount.Load() } @@ -145,7 +153,13 @@ func (r *googleCredentialRepairer) waitForRepairCooldown(ctx context.Context) er elapsed := now.Sub(lastRepairAt) if r.minInterval > 0 && !lastRepairAt.IsZero() && elapsed < r.minInterval { wait := r.minInterval - elapsed - r.pacedCount.Add(1) + paced := r.pacedCount.Add(1) + r.logger.Warn(). + Dur("wait", wait). + Dur("since_last_repair", elapsed). + Dur("min_interval", r.minInterval). + Uint64("paced_total", paced). + Msg("Delaying Google credential repair — repairs requested faster than the minimum interval (cookies may be revoked within minutes)") select { case <-time.After(wait): diff --git a/cmd/google_supervisor_test.go b/cmd/google_supervisor_test.go index 67c9902..ae07e28 100644 --- a/cmd/google_supervisor_test.go +++ b/cmd/google_supervisor_test.go @@ -1,7 +1,9 @@ package cmd import ( + "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" @@ -10,6 +12,8 @@ import ( "testing" "time" + "github.com/rs/zerolog" + "github.com/maxghenis/openmessage/internal/bridge" ) @@ -64,6 +68,7 @@ func TestGoogleSupervisorCredentialRepairControlsGenerationStart(t *testing.T) { }, func() { flagCalls.Add(1) }, func() { clearCalls.Add(1) }, + zerolog.Nop(), ) supervisor, err := bridge.NewSupervisor( googleAccountID, @@ -187,6 +192,7 @@ func TestGoogleCredentialRepairCooldownPacesTooSoonRepair(t *testing.T) { clock := &googleCredentialRepairTestClock{} var refreshMu sync.Mutex var refreshTimes []time.Time + var logs bytes.Buffer repairer := &googleCredentialRepairer{ sessionPath: "session.json", canRepair: func() bool { return true }, @@ -198,6 +204,7 @@ func TestGoogleCredentialRepairCooldownPacesTooSoonRepair(t *testing.T) { }, minInterval: minInterval, now: clock.Now, + logger: zerolog.New(&logs), } if err := repairer.RepairCredentials(context.Background(), "", bridge.OpError{}); err != nil { @@ -222,6 +229,21 @@ func TestGoogleCredentialRepairCooldownPacesTooSoonRepair(t *testing.T) { if got := repairer.PacedRepairCount(); got != 1 { t.Fatalf("paced repair count = %d, want 1", got) } + // Pacing is the fast-cookie-revocation signal, so it must not be silent in + // production: a delayed repair logs the wait and the running total. + var logged map[string]any + if err := json.Unmarshal([]byte(logs.String()), &logged); err != nil { + t.Fatalf("parse paced repair log %q: %v", logs.String(), err) + } + if level, _ := logged["level"].(string); level != "warn" { + t.Fatalf("paced repair log level = %v, want warn", logged["level"]) + } + if total, _ := logged["paced_total"].(float64); total != 1 { + t.Fatalf("paced repair log paced_total = %v, want 1", logged["paced_total"]) + } + if _, ok := logged["wait"]; !ok { + t.Fatalf("paced repair log has no wait field: %v", logged) + } } func TestGoogleCredentialRepairCooldownAfterIntervalProceedsImmediately(t *testing.T) { diff --git a/cmd/serve.go b/cmd/serve.go index 9a2b8d9..8e189b1 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -243,7 +243,11 @@ func RunServe(logger zerolog.Logger, args ...string) error { refreshGoogleSessionCookies, a.FlagGoogleNeedsRepair, a.ClearGoogleRepairFlag, + logger, ) + // Paced repairs are the fast-cookie-revocation signal; publish the count + // so /api/status and get_status show churn instead of it being silent. + a.SetGoogleRepairPaceCounter(repairer.PacedRepairCount) newGoogleSupervisor := func() (*bridge.Supervisor, error) { supervisorOptions := []bridge.SupervisorOption{ bridge.WithCredentialRepairer(repairer), diff --git a/docs/agent-runbook.md b/docs/agent-runbook.md index 35a74dd..53abb75 100644 --- a/docs/agent-runbook.md +++ b/docs/agent-runbook.md @@ -211,7 +211,24 @@ session (measured 2026-07-20: 14 clean minutes of 200s, then pattern is therefore a **heal cycle**: connected → cookie 401 → watchdog refresh from Chrome → reconnect, repeating every ~15 min with sub-minute dips. Rotated cookies are also persisted to `session.json` (throttled, ~5 min) so a -restart resumes from fresh values instead of pair-time snapshots. +restart resumes from fresh values instead of pair-time snapshots. That write is +atomic (temp file + fsync + rename), so a crash mid-save can never truncate the +paired credentials; a failed save retries at a tenth of the interval instead of +waiting a full one. + +**Is revocation running fast?** Automatic repairs are paced to at least +`OPENMESSAGE_REPAIR_MIN_INTERVAL` (default 90s). Every delayed repair logs +`Delaying Google credential repair` (with `wait` and `paced_total`) and bumps +`google.repairs_paced` in `/api/status`: + +```bash +curl -s http://127.0.0.1:7007/api/status | jq '.google.repairs_paced' +``` + +Absent/0 is the healthy ~15-minute heal cycle. A climbing count means repairs +are being requested faster than the floor — i.e. something is revoking the +imported cookies within minutes, which pacing throttles but does not fix. Read +it before concluding a heal loop is "just slow". An `auth_expired` session with its device link intact revives by cookie rewrite alone — **do not re-pair** for `needs_repair`; that resets nothing the @@ -322,6 +339,7 @@ briefly show "reconnecting" before it settles (see throttling note above). - `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:7007/` → 200 - `/api/status` → `google/whatsapp/signal` connection + `google.needs_repair` + (and `google.repairs_paced`, which should stay 0/absent) - A real send shows `OUTGOING_DELIVERED` in `/api/conversations//messages`. Don't re-send a user's real message as a "test" (duplicate risk on `UNKNOWN`, which is ambiguous about whether it sent); diff --git a/internal/app/app.go b/internal/app/app.go index ed5c4f8..d71f37b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -166,6 +166,8 @@ type App struct { googlePhoneRespondingSeen atomic.Bool googleLifecycleMu sync.RWMutex googleLifecycleNotifier GoogleLifecycleNotifier + googleRepairPaceMu sync.RWMutex + googleRepairPaceCount func() uint64 signalLifecycleMu sync.RWMutex signalLifecycleNotifier SignalLifecycleNotifier tempDataDir string @@ -188,6 +190,11 @@ type GoogleStatusSnapshot struct { // PhoneResponding is false after libgm reports PhoneNotResponding. Before // such an event is observed, unknown is treated as healthy. PhoneResponding bool `json:"phone_responding"` + // RepairsPaced counts automatic credential repairs the supervisor delayed + // to honour the minimum repair interval. 0 through the healthy ~15-minute + // heal cycle; a climbing count means cookies are being revoked within + // minutes, which the pacing floor is throttling rather than hiding. + RepairsPaced uint64 `json:"repairs_paced,omitempty"` } // googleRepairThreshold is how many consecutive failed Google sends (with no @@ -751,9 +758,32 @@ func (a *App) GoogleStatus() GoogleStatusSnapshot { LastError: lastError, AuthExpired: a.googleAuthExpired.Load(), PhoneResponding: a.GooglePhoneResponding(), + RepairsPaced: a.GoogleRepairsPaced(), } } +// SetGoogleRepairPaceCounter installs the supervisor's paced-repair counter so +// status surfaces can report it. Only the transport-owning daemon builds a +// credential repairer; everyone else (client mode, demo) reports 0. +func (a *App) SetGoogleRepairPaceCounter(count func() uint64) { + a.googleRepairPaceMu.Lock() + a.googleRepairPaceCount = count + a.googleRepairPaceMu.Unlock() +} + +// GoogleRepairsPaced reports how many automatic Google credential repairs have +// been delayed by the minimum-repair-interval floor. See +// GoogleStatusSnapshot.RepairsPaced. +func (a *App) GoogleRepairsPaced() uint64 { + a.googleRepairPaceMu.RLock() + count := a.googleRepairPaceCount + a.googleRepairPaceMu.RUnlock() + if count == nil { + return 0 + } + return count() +} + func (a *App) AnyConnected() bool { if a.Connected.Load() { return true diff --git a/internal/app/app_test.go b/internal/app/app_test.go index fc5f537..b42a413 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -217,6 +217,35 @@ func TestGooglePhoneRespondingStatus(t *testing.T) { } } +func TestGoogleRepairsPacedStatus(t *testing.T) { + a := &App{} + a.SessionPath = filepath.Join(t.TempDir(), "session.json") + if err := os.WriteFile(a.SessionPath, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + + // Client mode and demo installs never build a repairer; they must report 0 + // rather than panic on the missing counter. + if got := a.GoogleStatus().RepairsPaced; got != 0 { + t.Fatalf("RepairsPaced without a counter = %d, want 0", got) + } + + var paced uint64 + a.SetGoogleRepairPaceCounter(func() uint64 { return paced }) + if got := a.GoogleStatus().RepairsPaced; got != 0 { + t.Fatalf("RepairsPaced with an idle counter = %d, want 0", got) + } + paced = 3 + if got := a.GoogleStatus().RepairsPaced; got != 3 { + t.Fatalf("RepairsPaced = %d, want 3", got) + } + + a.SetGoogleRepairPaceCounter(nil) + if got := a.GoogleStatus().RepairsPaced; got != 0 { + t.Fatalf("RepairsPaced after clearing the counter = %d, want 0", got) + } +} + func TestPhoneOfflineFailuresDoNotMarkGoogleForRepair(t *testing.T) { a := &App{} a.SessionPath = filepath.Join(t.TempDir(), "session.json") diff --git a/internal/client/events.go b/internal/client/events.go index 61b6e90..893c91d 100644 --- a/internal/client/events.go +++ b/internal/client/events.go @@ -46,10 +46,16 @@ type EventHandler struct { OnPhoneRespondingChange func(bool) cookieSaveMu sync.Mutex - lastCookieSaveAt time.Time + nextCookieSaveAt time.Time lastCookieHash [32]byte } +// cookieSaveRetryDivisor shortens the cookie-persist throttle after a failed +// save. A transient failure then costs a fraction of the interval instead of a +// full one, while a persistent failure still cannot turn every inbound event +// into a write attempt. +const cookieSaveRetryDivisor = 10 + func (h *EventHandler) Handle(rawEvt any) { switch evt := rawEvt.(type) { case *events.ClientReady: @@ -425,15 +431,24 @@ func (h *EventHandler) maybePersistRotatedCookies() { h.cookieSaveMu.Lock() defer h.cookieSaveMu.Unlock() - if !h.lastCookieSaveAt.IsZero() && now.Sub(h.lastCookieSaveAt) < interval { + if !h.nextCookieSaveAt.IsZero() && now.Before(h.nextCookieSaveAt) { return } - h.lastCookieSaveAt = now + // The throttle covers every outcome, not just a successful save: these + // events arrive per message, so an unthrottled failure path would marshal + // and rewrite the session on each one. Failures instead come back after + // interval/cookieSaveRetryDivisor and leave lastCookieHash untouched, so + // the retry still sees the pending rotation and a transient error does not + // cost a full interval of durability. + h.nextCookieSaveAt = now.Add(interval) + retryAt := now.Add(interval / cookieSaveRetryDivisor) authData := h.Client.GM.AuthData authData.CookiesLock.RLock() cookiesJSON, err := json.Marshal(authData.Cookies) if err != nil { + // Keeps the full interval on purpose: a marshal failure is deterministic + // for these cookies, so an early retry only repeats it. authData.CookiesLock.RUnlock() h.Logger.Warn().Err(err).Msg("Failed to marshal rotated Google cookies") return @@ -446,10 +461,12 @@ func (h *EventHandler) maybePersistRotatedCookies() { sessionData, err := h.Client.SessionData() authData.CookiesLock.RUnlock() if err != nil { + h.nextCookieSaveAt = retryAt h.Logger.Warn().Err(err).Msg("Failed to get session data for rotated cookie save") return } if err := SaveSession(h.SessionPath, sessionData); err != nil { + h.nextCookieSaveAt = retryAt h.Logger.Warn().Err(err).Msg("Failed to persist rotated Google cookies") return } diff --git a/internal/client/events_test.go b/internal/client/events_test.go index d40e520..91a834e 100644 --- a/internal/client/events_test.go +++ b/internal/client/events_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "time" @@ -299,6 +300,81 @@ func TestMaybePersistRotatedCookies(t *testing.T) { } } +// A failed cookie save must retry well before the full persist interval (a +// transient failure should not cost ~5 minutes of durability) while still +// being throttled, and must leave the change-detect hash alone so the pending +// rotation is not silently dropped. +func TestMaybePersistRotatedCookiesRetriesFailedSave(t *testing.T) { + authData := libgm.NewAuthData() + authData.SetCookies(map[string]string{"SID": "initial"}) + gmClient := libgm.NewClient(authData, nil, zerolog.Nop()) + now := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC) + + // A regular file where the session's parent directory belongs makes every + // save fail with ENOTDIR, deterministically and without depending on file + // permissions (which root would ignore). Removing it lets a retry succeed. + dir := t.TempDir() + blocker := filepath.Join(dir, "blocked") + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write blocker: %v", err) + } + sessionPath := filepath.Join(blocker, "session.json") + + const interval = 5 * time.Minute + retryAfter := interval / cookieSaveRetryDivisor + var logs strings.Builder + handler := &EventHandler{ + Logger: zerolog.New(&logs), + SessionPath: sessionPath, + Client: &Client{GM: gmClient, Logger: zerolog.Nop()}, + Now: func() time.Time { return now }, + PersistCookiesEvery: interval, + } + saveAttempts := func() int { + return strings.Count(logs.String(), "Failed to persist rotated Google cookies") + } + + handler.Handle(&events.ListenRecovered{}) + if got := saveAttempts(); got != 1 { + t.Fatalf("failed save attempts after first event = %d, want 1", got) + } + + // Still inside the shortened window: a retry must not fire per event. + now = now.Add(retryAfter / 2) + handler.Handle(&events.ListenRecovered{}) + if got := saveAttempts(); got != 1 { + t.Fatalf("failed save attempts inside retry window = %d, want 1", got) + } + + // The shortened window has elapsed, far short of the full interval. + now = now.Add(retryAfter / 2) + handler.Handle(&events.ListenRecovered{}) + if got := saveAttempts(); got != 2 { + t.Fatalf("failed save attempts after retry window = %d, want 2", got) + } + + if err := os.Remove(blocker); err != nil { + t.Fatalf("clear blocker: %v", err) + } + now = now.Add(retryAfter) + handler.Handle(&events.ListenRecovered{}) + sessionData, err := LoadSession(sessionPath) + if err != nil { + t.Fatalf("LoadSession() after recovered save: %v", err) + } + var savedAuth struct { + Cookies map[string]string `json:"cookies"` + } + if err := json.Unmarshal(sessionData.AuthDataJSON, &savedAuth); err != nil { + t.Fatalf("unmarshal saved auth data: %v", err) + } + // The failures must not have recorded the cookie hash; otherwise this save + // would have been skipped as "unchanged" and the rotation lost. + if savedAuth.Cookies["SID"] != "initial" { + t.Fatalf("saved SID = %q, want %q", savedAuth.Cookies["SID"], "initial") + } +} + func TestHandleMessage_NotifiesOnlyFreshIncomingMessages(t *testing.T) { store, err := db.New(":memory:") if err != nil { diff --git a/internal/client/session.go b/internal/client/session.go index 8e7f69c..daf0f16 100644 --- a/internal/client/session.go +++ b/internal/client/session.go @@ -12,6 +12,16 @@ type SessionData struct { PushKeysJSON json.RawMessage `json:"push_keys,omitempty"` } +// SaveSession writes the session file atomically: the JSON goes to a +// same-directory temp file that is fsynced and then renamed over the target, +// so a reader (or a crash) never sees a half-written file. +// +// This matters because session.json holds the live Google/WhatsApp/Signal +// credentials and is no longer a rarely-touched file: rotated Google cookies +// are persisted every few minutes (see EventHandler.maybePersistRotatedCookies), +// so an in-place rewrite would put a truncation window in front of the paired +// auth several hundred times a day. Losing it costs a manual re-pair. +// internal/googlecookies.UpdateSessionCookies writes the same file the same way. func SaveSession(path string, data *SessionData) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0700); err != nil { @@ -24,11 +34,34 @@ func SaveSession(path string, data *SessionData) error { if err != nil { return fmt.Errorf("marshal: %w", err) } - if err := os.WriteFile(path, b, 0600); err != nil { + // A unique temp name (not a fixed session.json.tmp) keeps two concurrent + // savers from renaming each other's partial writes into place. + tmp, err := os.CreateTemp(dir, ".session-*.json") + if err != nil { + return fmt.Errorf("create temp session: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) // no-op once the rename succeeds + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return fmt.Errorf("secure temp session: %w", err) + } + if _, err := tmp.Write(b); err != nil { + tmp.Close() return fmt.Errorf("write: %w", err) } - if err := os.Chmod(path, 0600); err != nil { - return fmt.Errorf("secure: %w", err) + // Sync before the rename. Without it a power loss can commit the rename + // while the contents are still only in the page cache, which lands exactly + // the empty/truncated session.json the rename is meant to prevent. + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync temp session: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp session: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("install session: %w", err) } return nil } diff --git a/internal/client/session_test.go b/internal/client/session_test.go index e4c84d0..a81e637 100644 --- a/internal/client/session_test.go +++ b/internal/client/session_test.go @@ -2,8 +2,13 @@ package client import ( "encoding/json" + "errors" + "fmt" + "io/fs" "os" "path/filepath" + "strings" + "sync" "testing" ) @@ -57,6 +62,125 @@ func TestLoadSessionNotFound(t *testing.T) { } } +// SaveSession rewrites the paired credentials every few minutes now that +// rotated cookies are persisted, so a reader must never observe a partial +// file and a rewrite must leave no debris or loosened permissions behind. +func TestSaveSessionRewritesAtomically(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.json") + + const writers = 8 + const rounds = 25 + failures := make(chan error, writers+1) + writersDone := make(chan struct{}) + readerDone := make(chan struct{}) + + go func() { + defer close(readerDone) + for { + select { + case <-writersDone: + return + default: + } + loaded, err := LoadSession(path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + // A torn write surfaces here as a JSON parse failure. + failures <- fmt.Errorf("read session: %w", err) + return + } + var auth map[string]any + if err := json.Unmarshal(loaded.AuthDataJSON, &auth); err != nil { + failures <- fmt.Errorf("parse auth data: %w", err) + return + } + } + }() + + var wg sync.WaitGroup + for writer := 0; writer < writers; writer++ { + wg.Add(1) + go func(writer int) { + defer wg.Done() + for round := 0; round < rounds; round++ { + data := &SessionData{ + AuthDataJSON: json.RawMessage( + fmt.Sprintf(`{"session_id":"writer-%d-round-%d"}`, writer, round), + ), + } + if err := SaveSession(path, data); err != nil { + failures <- fmt.Errorf("save session: %w", err) + return + } + } + }(writer) + } + wg.Wait() + close(writersDone) + <-readerDone + + select { + case err := <-failures: + t.Fatalf("concurrent SaveSession produced an unreadable session: %v", err) + default: + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read session dir: %v", err) + } + for _, entry := range entries { + if entry.Name() != "session.json" { + t.Fatalf("temp file left behind: %s", entry.Name()) + } + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat session: %v", err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Fatalf("session permissions = %04o, want 0600", got) + } +} + +// A failed save must not damage the session already on disk — that file is the +// only copy of the paired credentials. +func TestSaveSessionFailureKeepsPreviousSession(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.json") + if err := SaveSession(path, &SessionData{AuthDataJSON: []byte(`{"session_id":"good"}`)}); err != nil { + t.Fatalf("initial save: %v", err) + } + + // json.Marshal rejects invalid RawMessage, so the save fails after the + // target already exists. + err := SaveSession(path, &SessionData{AuthDataJSON: []byte(`{oops`)}) + if err == nil { + t.Fatal("SaveSession() with invalid auth data succeeded, want error") + } + + loaded, err := LoadSession(path) + if err != nil { + t.Fatalf("load after failed save: %v", err) + } + if got := string(loaded.AuthDataJSON); !strings.Contains(got, "good") { + t.Fatalf("session after failed save = %s, want the previous contents", got) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read session dir: %v", err) + } + for _, entry := range entries { + if entry.Name() != "session.json" { + t.Fatalf("temp file left behind after failed save: %s", entry.Name()) + } + } +} + func TestSaveSessionCreatesDir(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "subdir", "session.json") diff --git a/internal/tools/get_status.go b/internal/tools/get_status.go index 1ce9ecb..81933de 100644 --- a/internal/tools/get_status.go +++ b/internal/tools/get_status.go @@ -78,6 +78,9 @@ func getStatusHandler(a *app.App, configured ...Options) server.ToolHandlerFunc fmt.Fprintf(&sb, " Paired: %v\n", google.Paired) fmt.Fprintf(&sb, " Needs pairing: %v\n", google.NeedsPairing) fmt.Fprintf(&sb, " Phone responding: %v\n", google.PhoneResponding) + if google.RepairsPaced > 0 { + fmt.Fprintf(&sb, " Repairs paced: %d (cookie repairs delayed by the min-interval floor)\n", google.RepairsPaced) + } if google.LastError != "" { fmt.Fprintf(&sb, " Last error: %s\n", google.LastError) } diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index 6310a49..b1f0e20 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -572,8 +572,9 @@ func TestGetStatus(t *testing.T) { originalSignalStatus := signalStatus googleStatus = func(*app.App) app.GoogleStatusSnapshot { return app.GoogleStatusSnapshot{ - Connected: false, - Paired: true, + Connected: false, + Paired: true, + RepairsPaced: 4, } } whatsAppStatus = func(*app.App) whatsapplive.StatusSnapshot { @@ -618,12 +619,39 @@ func TestGetStatus(t *testing.T) { if !strings.Contains(text, "signal-cli unavailable") { t.Errorf("expected Signal last error, got: %s", text) } + // Paced repairs are the fast-cookie-revocation signal, so a non-zero count + // has to be visible without reading the structured payload. + if !strings.Contains(text, "Repairs paced: 4") { + t.Errorf("expected paced repair count, got: %s", text) + } payload := structuredMap(t, result) if payload["overall_connected"] != true { t.Fatalf("expected overall_connected=true, got %#v", payload["overall_connected"]) } } +func TestGetStatusOmitsPacedRepairsWhenIdle(t *testing.T) { + a := testApp(t) + a.DataDir = t.TempDir() + + originalGoogleStatus := googleStatus + googleStatus = func(*app.App) app.GoogleStatusSnapshot { + return app.GoogleStatusSnapshot{Connected: true, Paired: true} + } + t.Cleanup(func() { googleStatus = originalGoogleStatus }) + + handler := getStatusHandler(a) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{} + result, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if text := result.Content[0].(mcp.TextContent).Text; strings.Contains(text, "Repairs paced") { + t.Errorf("healthy status should not mention paced repairs, got: %s", text) + } +} + func TestResolveContactRoutesPrefersSMSThread(t *testing.T) { a := testApp(t) now := time.Now().UnixMilli() diff --git a/internal/web/api_test.go b/internal/web/api_test.go index f6b2e73..029dad8 100644 --- a/internal/web/api_test.go +++ b/internal/web/api_test.go @@ -1751,6 +1751,54 @@ func TestGetStatus(t *testing.T) { } } +// A paced credential repair means Google cookies are being revoked faster than +// the repair floor. /api/status is where support (and the runbook) look for it, +// and a healthy install must not carry the noise. +func TestGetStatusIncludesGooglePacedRepairs(t *testing.T) { + for _, tc := range []struct { + name string + paced uint64 + wantKey bool + }{ + {name: "paced repairs surface", paced: 7, wantKey: true}, + {name: "idle repairer stays quiet", paced: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + ts := newTestServerWithOptions(t, APIOptions{ + GoogleStatus: func() any { + return app.GoogleStatusSnapshot{ + Connected: true, + Paired: true, + RepairsPaced: tc.paced, + } + }, + }) + + resp, err := http.Get(ts.server.URL + "/api/status") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var status map[string]any + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + t.Fatal(err) + } + google, ok := status["google"].(map[string]any) + if !ok { + t.Fatalf("expected google status object, got %#v", status["google"]) + } + paced, present := google["repairs_paced"] + if present != tc.wantKey { + t.Fatalf("google.repairs_paced present = %v, want %v (payload %#v)", present, tc.wantKey, google) + } + if tc.wantKey && paced != float64(tc.paced) { + t.Fatalf("google.repairs_paced = %#v, want %d", paced, tc.paced) + } + }) + } +} + func TestGetStatusIncludesWhatsAppSnapshot(t *testing.T) { ts := newTestServerWithOptions(t, APIOptions{ WhatsAppStatus: func() any {