diff --git a/.env b/.env index df14488..2946121 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -#GEMITERM_CONFIG_DIR=./.gemiterm +GEMITERM_CONFIG_DIR=./.gemiterm GEMITERM_VERBOSE=true \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c368309..ca46c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ -## [2.7.0] - 2026-08-08 +## [2.7.0] - 2026-08-09 ### Added - **`gemiterm status --verbose` (`-v`).** Prints per-profile cookie counts and the next `__Secure-1PSIDTS` expiry countdown, followed by the absolute path to each profile storage directory — useful for diagnosing cookie expiry without opening the `%APPDATA%\gemiterm` directory by hand. New `formatDuration(ms)` helper in `infrastructure/formatters.ts` renders compact age strings ("4d 6h" / "2h 30m" / "expired"). - **`status` PROBE column.** `bun run dev status` now validates every profile against Google's API on every invocation — `models()` and `listChats({ limit: 1 })` run in parallel. Three-state column: `✓ live (N≥1)`, `⚠ phantom (models N)`, or `✗ dead: `. Catches the phantom-auth state that was hiding behind local freshness checks. Always-on, no flag needed. - **Targeted L2 recovery for phantom-auth sessions.** When phantom-auth is detected (models works, listChats empty), `ensureAuthenticated` triggers a headless browser refresh that updates only PSIDTS-related cookies (`__Secure-1PSIDTS`, `__Secure-3PSIDTS`, `SIDCC`) instead of replacing the full jar. Preserves the original login's PSID + companion cookies while picking up a fresh PSIDTS from the browser session. Falls through to full headed re-auth when targeted L2 cannot recover. -- **Phase 0 v2 regression net.** 10 tests (0a–0j) across 8 files lock every known auth bug contract at the cheapest seam: cookie-monitor full-jar capture (0a), auth round-trip (0b), time-passing clock injection (0c), continue-chat metadata (0d), profile routing (0e), recovery ladder (0f), L2 cookie corruption (0g), context roundtrip (0i), status PROBE (0j). Designed to go RED on the exact commit that introduced each historical regression. Documented at `docs/phase-0/phase-0-v2-design.md`. +- **Dormancy-resilient auth gates.** `ensureAuthenticated` no longer throws when probe/freshness checks fail — the session is allowed to reach the Gemini API, which returns a 401 only if the session is genuinely dead. Expired-but-present cookies no longer force a re-login; stale server probes no longer kill the session. The two fatal gates (cookie freshness and server-side probe) are now non-fatal, with `DO NOT THROW` regression tests locking this behavior. This restores v2.4.0's multi-day session tolerance while keeping v2.7.0's server-side validation and recovery ladder. ### Fixed @@ -27,10 +27,14 @@ ### Internal - `createClientServices` extracted from `src/cli/index.ts` into `src/cli/client-services.ts` to expose a testable seam for the `forProfile` wiring. -- Test suite: **954 pass / 1 skip / 0 fail / 2030 expects** (was 928 / 1945 at 2.6.1). Red-then-green regression tests added for each fix at the cheapest seam. +- Test suite: **990 pass / 1 skip / 0 fail / 2089 expects** (was 954 / 2030). Red-then-green regression tests added for each fix at the cheapest seam. +- **CookieJar unification (Candidate A).** Replaced 5 uncoordinated cookie-jar writers with a single `CookieJar` module offering two policies: `replace()` (login capture) and `upsert()` (rotations/refreshes), keyed by `(name, domain, path)`. All jar mutations now flow through one interface. See `src/services/cookie-jar.ts`. +- **Explicit auth state machine (Candidate C).** `classifySession()` + `getRecoveryAction()` replace 10+ branches of implicit state logic in `ensureAuthenticated` with 5 named states (Fresh/Phantom/Dead/Stale/Declined) and typed recovery actions. Both functions are pure and independently testable. See `src/services/session-state.ts`. +- **Conversation threading module (Candidate B).** `makeMetadata()`, `extractMetadata()`, `threadOnto()`, and `captureFrom()` consolidate all cid/rid/rcid magic indices into one file. `sendMessage` threading simplified from 20+ lines to 10. See `src/services/conversation-threading.ts`. +- **Post-call seam consolidation (Candidate E).** `persistRefreshedCookies` now delegates to `cookieJar.upsert()` as the single write path for SDK-refreshed cookies. - Phase 0 v2 regression net: 10 tests (0a–0j) across 8 files. Documented at `docs/phase-0/phase-0-v2-design.md`. Bug history ledger at `docs/phantom-bug-synthesis.md`. - `tests/services/cookie-jar-repro.test.ts`: deterministic repro harness for the 4-cookie degradation symptom at the `GeminiClientService`/SDK seam. -- Architecture review v3 at `docs/phase-0/architecture-review-auth-2026-08-08-v3.html` identifies two deepening candidates for post-v2.7.0 work (state machine explicitness, cookie jar unification). +- Architecture review v5 at `docs/phase-0/architecture-review-auth-2026-08-09-v5.html` identifies deepening candidates (A+B+C+E) — all four now implemented. --- diff --git a/docs/alternate-plan-simplify.md b/docs/alternate-plan-simplify.md new file mode 100644 index 0000000..073aaa1 --- /dev/null +++ b/docs/alternate-plan-simplify.md @@ -0,0 +1,101 @@ +# Alternate Plan — Simplify Toward v2.4.0 (Remove RotateCookies + Phantom Detection) + +**Date:** 2026-08-09 +**Status:** Exploration. Not the active plan for the current branch. +**Active plan:** Option 2 (explicit state machine + CookieJar unification) on branch `fix/rotate-cookies-401-session-kill`. + +--- + +## Premise + +The post-v2.4.0 auth architecture added three layers — L1 RotateCookies, phantom-auth detection, and targeted L2 recovery — to fix a symptom (`listChats` returns empty) whose **definitive root cause** was the `CookieMonitor` capture trim bug, fixed in commit `6bc51f6` (the cookie-jar-integrity fix). + +The symptom and the fix: +- **Symptom:** `listChats` returned 0 chats after ~2h idle, despite `models()` probe passing and cookies appearing locally valid. +- **Apparent cause:** Server-side session degradation (PSIDTS rotation, companion cookie expiry) invisible to local freshness checks. +- **Real root cause:** `CookieMonitor.poll` filtered the browser jar to `REQUIRED_COOKIES` (PSID/PSIDTS only) before passing to persistence. Every capture path saved only 4 cookies. `listChats` requires companion cookies (SID, HSID, SSID, APISID, SAPISID, etc.), which were missing. +- **Real fix:** `6bc51f6` — separate gating predicate from payload. Keep REQUIRED_COOKIES as the login gate; pass the **full** browser jar as the payload. + +The RotateCookies + phantom detection + targeted L2 layers were built on the false premise that the jar was complete and the session was degrading. They were each individually correct but collectively unnecessary once the jar is captured intact. + +## Evidence + +1. **v2.4.0 worked with 12-day-old sessions** (DHBGAMING2 Linux, sessions from July 29, 12 days idle, still lists 14 conversations). v2.4.0 had no RotateCookies, no probe, no phantom detection. Its `ensureAuthenticated` was 34 lines, sync: check `hasValidCookies()` → return cookies. Done. +2. **The RotateCookies 401 false-positive** (latest ledger entry in `docs/phantom-bug-synthesis.md`): `accounts.google.com/RotateCookies` returning 401 does NOT mean the Gemini API session is dead. v2.7.0 killed sessions after ~5h idle; v2.4.0 didn't kill them at all. +3. **Every phantom-auth fix addressed detection/rotation, not the data.** The jar flowing through all the layers was already degraded by the capture trim. The probe, rotation, and phantom detection were operating on 4 cookies and couldn't fix what they couldn't see. + +## The Plan + +### Remove + +| Layer | File | Why | +|-------|------|-----| +| L1 RotateCookies from hot path | `profile-auth-manager.ts:114-140` | `accounts.google.com` endpoint has different session validation than Gemini API. False-positive 401 kills valid sessions. With full-jar capture, SDK self-rotation (`persistRefreshedCookies`) is sufficient. | +| `detectPhantomAuth` | `profile-auth-manager.ts:190-199` | Phantom-auth was the capture bug. With full jars, `listChats` shouldn't return empty on valid sessions. | +| Targeted L2 | `auth-service.ts:310-328` | Phantom-auth recovery not needed when jars are complete. | +| `RotateCookiesResult` type + `rotateCookies` dep | `profile-auth-manager.ts` deps interface | No longer called from ensureAuthenticated. | +| `rotateCookies` adapter on `AuthService` | `auth-service.ts:216-233` | If RotateCookies is removed from hot path. | + +### Keep + +| Layer | Why | +|-------|-----| +| `models()` probe | Cheap, definitive live/dead signal. One round-trip to Google. | +| `silentRefresh` (full mode) | Headless browser re-auth when session is genuinely dead. | +| `persistRefreshedCookies` | SDK self-rotation must be persisted between CLI runs. | +| Full-jar capture (`6bc51f6`) | The definitive root cause fix. Never regress. | + +### Simplified `ensureAuthenticated` + +The result would look like: + +``` +ensureAuthenticated(name): + 1. Check hasValidCookies → no → throw (or try autoExtendSession → silentRefresh → throw if fail) + 2. Probe server with models() → stale → silentRefresh → throw if fail + 3. Return cookies +``` + +~20 lines. Sync where possible. No rotation. No phantom detection. No targeted L2. The RotateCookies endpoint could still be called by a `gemiterm watch` background process, but it would not be in the critical path of every CLI command. + +## Risks + +1. **Server-side PSIDTS rotation** — without L1 RotateCookies, `__Secure-1PSIDTS` will only rotate via SDK self-rotation (which requires an API call). If the user goes days without using gemiterm, PSIDTS may expire server-side while PSID is still valid. The `models()` probe would still catch this and trigger `silentRefresh`. This is the same behavior as v2.4.0, which worked in practice. +2. **Companion cookie expiry** — SID/HSID/SSID/etc. are session-scoped. If they expire server-side, `listChats` will return empty even with full jars. v2.4.0 didn't handle this either. The `models()` probe + `silentRefresh` ladder would surface it as a dead session → re-auth. +3. **No proactive rotation** — without L1 RotateCookies, there's no mechanism to keep PSIDTS warm between CLI invocations. A `gemiterm watch` background process could fill this gap for automation users. + +## Migration Path + +1. Branch `simplify/remove-roteta-phantom` off main@v2.7.0. +2. Remove `rotateCookies` call from `ensureAuthenticated`. +3. Remove `detectPhantomAuth`. +4. Remove targeted L2 from `silentRefresh`. +5. Simplify `RotateCookiesResult` type or remove the `sessionInvalid` field. +6. Remove `rotateCookies` dep from `ProfileAuthManagerDeps`. +7. Update tests — the 10-test Phase 0 v2 regression net must stay GREEN. +8. Live-verify with a fresh login, wait ~2h, run `gemiterm list` — should still work. + +## Comparison with Active Plan (Option 2) + +| Aspect | Option 1 (Simplify) | Option 2 (Explicit state machine) | +|--------|---------------------|----------------------------------| +| Lines of code | ~100 removed | ~200 added (new modules) | +| Complexity | Decreased | Same, reorganized | +| Bug surface | Smaller | Same, typed | +| Defense-in-depth | Probe + silentRefresh only | All current layers, explicit | +| Risk | PSIDTS may expire between uses; `models()` probe catches it | RotateCookies 401 false-positives (already fixed); transition bugs (already surfaced) | +| Test changes | Remove tests for removed paths | Add tests for new modules | +| Migration effort | ~1 day | ~3-5 days | + +## Decision + +This is the **alternate plan**, documented for future consideration. The active plan (Option 2: explicit state machine + CookieJar unification) is being implemented on `fix/rotate-cookies-401-session-kill`. If Option 2 proves too complex or introduces new regressions, this plan is the fallback. + +## Related + +- `docs/phantom-bug-synthesis.md` — write-once bug ledger +- `docs/phase-0/phase-0-v2-design.md` — regression net design +- Commit `6bc51f6` — the cookie-jar-integrity fix (definitive root cause) +- Commit `c4870de` — RotateCookies 401 session-kill fix +- OpenSpec change `cookie-jar-integrity` — the capture fix +- `C:\Users\diego\AppData\Local\Temp\architecture-review-auth-2026-08-08-v3.html` — v3 architecture review diff --git a/docs/phantom-bug-synthesis.md b/docs/phantom-bug-synthesis.md index 2749d69..035d2ae 100644 --- a/docs/phantom-bug-synthesis.md +++ b/docs/phantom-bug-synthesis.md @@ -496,3 +496,39 @@ Committed `b5dc3de`. Test baseline unchanged (954 pass / 1 skip / 0 fail). Typec - §"2026-08-08 — dhb-worker session expired after ~2 hours" — the source data point for the ~1h15m floor. - §"2026-08-08 — profile-routing lambda drops profile argument" — the lambda fix being re-verified after the next idle cycle. +## 2026-08-09 — RotateCookies 401 pre-emptively kills sessions that the Gemini API still accepts + +**Discovered by:** Diego, cross-version comparison. v2.4.0 on Linux (DHBGAMING2, sessions from July 29, 12 days old) still lists chats fine. v2.7.0 on Windows kills sessions after ~5h idle with `AuthenticationError("Session for profile 'dhb-worker' is no longer valid (server rejected RotateCookies)")`. + +**Symptom:** +- `gemiterm list` on v2.7.0 after ~5h idle: first call targeted-L2 recovers but returns "No conversations found"; second call gets `AuthenticationError` because RotateCookies returns 401. +- `gemiterm list` on v2.4.0 after 12 days idle: returns 14 conversations, no errors. +- The core PSID cookie expires Sep 2027 on both machines. It is still valid. Google's Gemini API accepts it. RotateCookies rejects it. + +**Root cause:** `profile-auth-manager.ts:121-129` treats RotateCookies 401 as definitive proof of Gemini API session death: + +```typescript +if (rotation.sessionInvalid) { + throw new AuthenticationError( + `Session for profile '${name}' is no longer valid (server rejected RotateCookies). Run 'gemiterm login'...`, + ); +} +``` + +In v2.4.0, `ensureAuthenticated` had none of this — it checked `hasValidCookies()` (7-day local freshness) and returned cookies immediately. No RotateCookies call, no probe, no phantom detection. The gemini-web-sdk used the cookies directly, and Google's Gemini API accepted them. + +The design flaw: **RotateCookies is an `accounts.google.com` endpoint, not a Gemini API endpoint.** Its session validation behavior differs from the Gemini API endpoints (`models`, `listChats`, `readChat`). A 401 from RotateCookies means Google Accounts won't rotate the PSIDTS token — it does NOT mean the Gemini API will reject the PSID cookie. These are separate services with separate session policies. + +The second call in the test session got a 401 because the targeted L2 refresh on the first call partially updated the jar (PSIDTS-family cookies refreshed) while companion cookies (SID/HSID/SSID/etc.) from the expired session remained — creating an inconsistent cookie envelope that RotateCookies rejected. But the Gemini API may have still accepted that envelope for `listChats`/`models`. + +**Fix:** Remove the `sessionInvalid` throw. Treat RotateCookies 401/403 the same as "declined" — rotation simply didn't happen, carry on. Run phantom detection (as we do for "declined" already) to attempt targeted L2 recovery. Only throw if targeted L2 also fails. This defers session-validity judgment to the actual Gemini API endpoints rather than a secondary Google Accounts endpoint. + +The change is in `profile-auth-manager.ts:121-129` — replace the existing `sessionInvalid` throw block with a fallthrough that mirrors the `rotation.attempted` path (phantom detection → targeted L2 → throw on failure). + +**Verified:** TBD after implementation. Test baseline expected unchanged (954/1/0). + +**Related ledger entries:** +- §"2026-08-06 — The recovery-ladder recurrence" (Gap B: `sessionInvalid` surface path) — the original design that added the `sessionInvalid` flag. This entry argues the 401 throw was the wrong fix for Gap B. +- §"The 3-release arc" — traces how RotateCookies detection was added across v2.6.0–v2.6.2. +- §"2026-08-06 — Session 3" (L2 removal) — the earlier removal of the L2 cookie-corruption path; this is the companion fix for RotateCookies 401. + diff --git a/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/.openspec.yaml b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/.openspec.yaml new file mode 100644 index 0000000..d77f64e --- /dev/null +++ b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-09 diff --git a/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/design.md b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/design.md new file mode 100644 index 0000000..e22775a --- /dev/null +++ b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/design.md @@ -0,0 +1,56 @@ +## Context + +v2.7.0's `ensureAuthenticated` at `profile-auth-manager.ts:121-129` throws `AuthenticationError` when `rotateCookies()` returns `sessionInvalid: true` (i.e., RotateCookies POST returned 401/403). This was added in `4dfe13c` (Gap B fix) under the assumption that RotateCookies 401 means the Gemini API session is dead. + +Cross-version comparison disproves this: v2.4.0 (which never calls RotateCookies) works with 12-day-old sessions. The RotateCookies endpoint (`accounts.google.com`) has different session validation behavior than the Gemini API endpoints (`models`, `listChats`, `readChat`). A 401 from RotateCookies means the token rotation was rejected — not that the Gemini API will reject the PSID cookie. + +The current code path: +1. Probe (`models()`) succeeds → session marked "valid" +2. `rotateCookies()` returns 401 → `sessionInvalid: true` → throw `AuthenticationError` +3. Session is killed before the Gemini API ever gets a chance to respond + +## Goals / Non-Goals + +**Goals:** +- Remove the RotateCookies 401 → `AuthenticationError` throw +- Merge `sessionInvalid` into the existing `rotation.attempted` branch so phantom detection + targeted L2 recovery can fire +- Fall through to phantom detection when RotateCookies 401/403 occurs + +**Non-Goals:** +- No change to the RotateCookies endpoint behavior or cookie-rotation.ts +- No change to the probe cache, L1 throttle, or silentRefresh mechanics +- No change to how targeted L2 merge works + +## Decisions + +**D1: Merge `sessionInvalid` into `rotation.attempted` branch** + +The existing condition chain is: +``` +if (rotation.rotated) { ... } +else if (rotation.attempted) { ... phantom detection ... } +else { /* throttled/skipped */ } +``` + +`sessionInvalid` sets `{ rotated: false, attempted: false }`, so it currently falls into the `else` (throttled/skipped) after the throw is removed. + +Change to: +``` +if (rotation.rotated) { ... } +else if (rotation.attempted || rotation.sessionInvalid) { ... phantom detection ... } +else { /* throttled/skipped */ } +``` + +This gives RotateCookies 401/403 the same recovery path as "declined" (200 with no fresh PSIDTS): detect phantom → attempt targeted L2 → if targeted L2 fails, throw. The phantom detection step verifies whether the session is actually usable (listChats returns results) or truly dead. + +**Rationale:** RotateCookies 401 can happen because (a) session is genuinely dead, (b) companion cookies expired while PSID is still valid, or (c) RotateCookies endpoint behavior differs from Gemini API. Cases (b) and (c) should not kill the session. Case (a) will surface through phantom detection failing → `AuthenticationError`. + +**Alternative considered:** Log and skip entirely (no phantom detection). Rejected — if the session IS truly dead, we want targeted L2 to attempt recovery before giving up. + +## Risks / Trade-offs + +- **[Risk] Genuinely dead sessions take longer to surface.** Instead of immediate throw on RotateCookies 401, we run phantom detection (listChats call) + targeted L2 (browser launch). This adds ~5-10 seconds to the error path. + - **Mitigation:** Dead sessions are rare; the common case (session still works via Gemini API) now succeeds without any user intervention. + +- **[Risk] Targeted L2 on a RotateCookies-401 session may corrupt the jar.** If the browser auto-signs-in with the same cookies (phantom = frontend-valid), targeted L2 will update PSIDTS cookies while companion cookies may still be expired. + - **Mitigation:** This is the same risk as the existing "declined" phantom path. The targeted L2 update is scoped to `COOKIE_NAMES_OF_INTEREST` only. If companion cookies are the problem, targeted L2 won't fix it and will throw `AuthenticationError` → user gets re-auth prompt. This is correct behavior — targeted L2 can't manufacture missing companion cookies. diff --git a/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/proposal.md b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/proposal.md new file mode 100644 index 0000000..144cecc --- /dev/null +++ b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/proposal.md @@ -0,0 +1,25 @@ +## Why + +RotateCookies 401 pre-emptively kills sessions that the Gemini API still accepts. v2.4.0 (which never calls RotateCookies) works fine with 12-day-old sessions. v2.7.0 kills sessions after ~5h idle because the RotateCookies endpoint returns 401 — but the Gemini API (`models`, `listChats`, `readChat`) still accepts the same PSID cookie. RotateCookies is an `accounts.google.com` endpoint with different session validation behavior than the Gemini API. Treating its 401 as definitive proof of Gemini API session death is incorrect. + +## What Changes + +- **Remove** the `sessionInvalid` throw in `ProfileAuthManager.ensureAuthenticated` (lines 121-129) that kills sessions based on RotateCookies 401/403. +- **Replace** it with a fallthrough to phantom detection + targeted L2 recovery, mirroring the existing `rotation.attempted` path. +- RotateCookies 401/403 is now treated as "rotation failed, carry on" — the actual Gemini API endpoints determine session validity. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `phantom-auth-detection`: The `ensureAuthenticated` recovery ladder no longer throws on RotateCookies 401/403. Session validity is deferred to the Gemini API endpoints rather than the RotateCookies endpoint. + +## Impact + +- `src/services/profile-auth-manager.ts`: Remove lines 121-129, refactor conditional chain to merge `sessionInvalid` into the existing `rotation.attempted` branch. +- Tests at `tests/services/profile-auth-manager.test.ts`: Update any tests that assert `sessionInvalid` → `AuthenticationError`; the new behavior is to fall through to phantom detection. +- `docs/phantom-bug-synthesis.md`: New ledger entry documenting this fix. diff --git a/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/specs/phantom-auth-detection/spec.md b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/specs/phantom-auth-detection/spec.md new file mode 100644 index 0000000..600b5ec --- /dev/null +++ b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/specs/phantom-auth-detection/spec.md @@ -0,0 +1,83 @@ +## MODIFIED Requirements + +### Requirement: ProfileAuthManager probes server-side session validity before declaring authenticated + +When `ProfileAuthManager.ensureAuthenticated(profileName?)` is called and the profile's local cookies pass `profileManager.hasValidCookies(name)`, the method MUST consult a server-side probe before returning a successful result, AND it MUST attempt a cookie rotation via the injected `rotateCookies(name)` (the L1 `RotateCookies` POST) regardless of the probe outcome. The probe MUST call `geminiClient.models()` on a client scoped to the profile name. A process-level cache (default TTL 150_000 ms / 2.5 min, overridable via `GEMITERM_PROBE_TTL_MS` env var) MUST memoize the probe result per profile. The rotation is throttled by the 600 s disk-mtime guard inside `rotateCookies`, so an actual `RotateCookies` POST happens at most once per 600 s per profile; sub-threshold calls return early without network I/O. + +The two recovery functions have distinct roles: + +- `rotateCookies` (L1 POST only) — cheap, guarded, no browser. Used on the **probe-success** path for proactive `__Secure-1PSIDTS` freshness. Best-effort: a rotation failure (network error, non-200, or guard skip) MUST NOT throw. +- `silentRefresh` (L1 POST, then L2 headless browser) — used on the **probe-stale** path (`models()` threw), where the session is genuinely dead and may need the browser fallback. + +The probe result classification MUST be: + +- **RPC succeeds:** the session is usable for PSID-only calls, but a stale `__Secure-1PSIDTS` cannot be ruled out, so the method MUST call `rotateCookies(name)` to refresh the token. A rotation failure MUST NOT throw. After rotation, the method proceeds to the rotation-result handling described below. Log info, return `LoadedCookies` if the session is valid. +- **RPC throws:** server-side session invalidation. Log a warning, classify as "stale", call `silentRefresh(name)`. If `silentRefresh` returns `true`, return the refreshed `LoadedCookies`. If `silentRefresh` returns `false`, throw `AuthenticationError`. + +On probe error, the method MUST log at debug level and classify as "stale". + +**Rotation result handling (after probe success):** + +- **`rotation.rotated === true`:** Fresh `__Secure-1PSIDTS` obtained. Session is fully usable. Return `LoadedCookies`. +- **`rotation.attempted === true` or `rotation.sessionInvalid === true`:** L1 reached Google but either the server declined to issue fresh PSIDTS (HTTP 200, no fresh PSIDTS in response) or the server rejected the request (401/403). In both cases, the session MAY still be usable via the Gemini API directly (RotateCookies is an `accounts.google.com` endpoint with different session validation behavior than the Gemini API). The method MUST run phantom-auth detection (`detectPhantomAuth`) — a `listChats({ limit: 1 })` call — to determine whether the session is functional. If phantom is detected (listChats returns empty), the method MUST attempt targeted L2 silent refresh (`silentRefresh(name, { mode: "targeted" })`). If targeted L2 fails, throw `AuthenticationError`. If phantom is NOT detected, the session is functional; log and return `LoadedCookies`. +- **Otherwise (`rotation.attempted === false` and `rotation.sessionInvalid` is falsy):** L1 was throttled, disabled, or unavailable. Cookies are likely still fresh. Log at debug level and return `LoadedCookies`. + +#### Scenario: models() succeeds — L1 rotation returns 401, phantom detected, targeted L2 recovers + +- **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies +- **AND** `geminiClient.models()` returns successfully +- **AND** `rotateCookies("default")` returns `{ rotated: false, attempted: false, sessionInvalid: true }` +- **AND** `detectPhantomAuth("default")` returns `true` +- **AND** `silentRefresh("default", { mode: "targeted" })` returns `true` +- **THEN** the method returns `LoadedCookies` with refreshed values +- **AND** `AuthenticationError` is NOT thrown +- **AND** `silentRefresh` was called with `mode: "targeted"` + +#### Scenario: models() succeeds — L1 rotation returns 401, phantom not detected, session still valid + +- **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies +- **AND** `geminiClient.models()` returns successfully +- **AND** `rotateCookies("default")` returns `{ rotated: false, attempted: false, sessionInvalid: true }` +- **AND** `detectPhantomAuth("default")` returns `false` (listChats returns ≥1 chat) +- **THEN** the method returns `LoadedCookies` with the stored values +- **AND** `AuthenticationError` is NOT thrown +- **AND** `silentRefresh` is NOT called + +#### Scenario: models() succeeds — L1 rotation still attempted (stale 1PSIDTS detection) + +- **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies +- **AND** `geminiClient.models()` returns successfully +- **AND** `rotateCookies("default")` returns `{ rotated: true, attempted: true }` or `{ rotated: false, attempted: true }` +- **THEN** the method returns `LoadedCookies` with the stored values +- **AND** `silentRefresh` is NOT called on this path (L1 only; no browser) +- **AND** a rotation failure does NOT cause `AuthenticationError` to be thrown + +#### Scenario: models() throws — session stale, triggers silent refresh ladder + +- **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies +- **AND** `geminiClient.models()` throws +- **AND** `silentRefresh("default")` returns `true` +- **THEN** the method returns `LoadedCookies` reflecting the refreshed values +- **AND** `silentRefresh` was called + +#### Scenario: models() throws + silent refresh fails — AuthenticationError + +- **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies +- **AND** `geminiClient.models()` throws +- **AND** `silentRefresh("default")` returns `false` +- **THEN** the method throws `AuthenticationError` whose message contains `No valid session` and references `gemiterm login` + +#### Scenario: Probe budget — repeat ensureAuthenticated within TTL reuses cached probe result + +- **WHEN** `ensureAuthenticated("default")` is called multiple times in rapid succession (e.g., 3 times) +- **AND** the local cookies are valid +- **THEN** `geminiClient.models()` is invoked at most once across the 3 calls (probe cache) +- **AND** every call returns the same `LoadedCookies` +- **AND** the 600 s disk-mtime guard inside `rotateCookies` prevents more than one actual `RotateCookies` POST within the window + +#### Scenario: Profile with no valid cookies does not probe or rotate + +- **WHEN** `ensureAuthenticated("default")` is called +- **AND** `profileManager.hasValidCookies("default")` returns `false` +- **THEN** `geminiClient.models()` is NOT called +- **AND** `autoExtendSession` is attempted instead diff --git a/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/tasks.md b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/tasks.md new file mode 100644 index 0000000..b7f9a35 --- /dev/null +++ b/openspec/changes/archive/2026-08-09-fix-rotate-cookies-401-session-kill/tasks.md @@ -0,0 +1,21 @@ +## 1. Ledger + +- [ ] 1.1 Update `docs/phantom-bug-synthesis.md` with RotateCookies 401 session-kill entry + +## 2. Core Implementation + +- [ ] 2.1 Remove `sessionInvalid` throw in `ProfileAuthManager.ensureAuthenticated` (`src/services/profile-auth-manager.ts:121-129`) +- [ ] 2.2 Merge `sessionInvalid` into the `rotation.attempted` branch for phantom-detection fallthrough + +## 3. Tests + +- [ ] 3.1 Update existing `sessionInvalid` → `AuthenticationError` throw tests in `tests/services/profile-auth-manager.test.ts` to reflect new behavior (fallthrough to phantom detection) +- [ ] 3.2 Add test: RotateCookies 401 → phantom not detected → returns LoadedCookies (no throw) +- [ ] 3.3 Add test: RotateCookies 401 → phantom detected → targeted L2 succeeds → returns refreshed LoadedCookies +- [ ] 3.4 Add test: RotateCookies 401 → phantom detected → targeted L2 fails → throws AuthenticationError + +## 4. Verification + +- [ ] 4.1 `bun test` — confirm baseline intact (954/1/0 before changes, adjusted for new test count) +- [ ] 4.2 `bun run typecheck` — clean +- [ ] 4.3 `openspec validate --all --strict` — 32/0 (unchanged) diff --git a/openspec/changes/chat-list-bulk-actions/tasks.md b/openspec/changes/chat-list-bulk-actions/tasks.md index bacf269..263172c 100644 --- a/openspec/changes/chat-list-bulk-actions/tasks.md +++ b/openspec/changes/chat-list-bulk-actions/tasks.md @@ -51,7 +51,7 @@ ## 8. Verification - [ ] 8.1 Run `bun run typecheck` and confirm zero errors. Fix any `tsc` complaints about the new `BrowserResult` variant consumers, the new `BulkAction` exports, the new `BulkSummary` type, or the new `SummarizeCommand` registration. -- [ ] 8.2 Run `bun test` and confirm the full suite passes. Update the baseline in `CHANGELOG.md` (per `AGENTS.md`: "Update the baseline number in any open change's `tasks.md` if the count moves") to the new total. Expected new tests: ~34-42 (browser multi-select ~8, list-command bulk dispatch ~6, extractConversationIds ~6, delete multi-id ~9, export multi-id ~9, local-summarizer ~14, summarize ~8) over the v2.0.0 baseline of 657 → ~691-699. +- [ ] 8.2 Run `bun test` and confirm the full suite passes. Update the baseline in `CHANGELOG.md` (per `AGENTS.md`: "Update the baseline number in any open change's `tasks.md` if the count moves") to the new total. Expected new tests: ~34-42 (browser multi-select ~8, list-command bulk dispatch ~6, extractConversationIds ~6, delete multi-id ~9, export multi-id ~9, local-summarizer ~14, summarize ~8) over the post-`overhaul/auth-architecture` baseline of 990 pass / 1 skip / 991 total → ~1025-1033. - [ ] 8.3 Run `bun run lint:mediation` and confirm the bash version (NOT the broken `lint:mediation:ps` script) reports zero violations. The new files in `src/cli/commands/summarize-command.ts`, `src/cli/utils/conversation-id-parser.ts`, and `src/services/local-summarizer.ts` MUST go through `infrastructure/io.ts` for any file I/O — no `node:fs` imports. - [ ] 8.4 Manual smoke test on a real terminal: `bun run dev -- list -i`, select 3 rows with `space`, press `b`, choose `Combine & Summarize`, confirm the file path is printed, and confirm the post-summarize prompt is shown. Then test `gemiterm delete id1,id2 --force` and `gemiterm export id1,id2,id3 --out-dir ./exports` and `gemiterm summarize id1` end-to-end. - [ ] 8.5 Run `bun run build` and confirm a clean host-target build. diff --git a/openspec/changes/phantom-auth-review-refactors/design.md b/openspec/changes/phantom-auth-review-refactors/design.md index 1e6cd3b..a2d11b5 100644 --- a/openspec/changes/phantom-auth-review-refactors/design.md +++ b/openspec/changes/phantom-auth-review-refactors/design.md @@ -151,7 +151,7 @@ imports it too. helper's JSDoc. - **[Risk] Test-count regression.** The refactoring must not change any test count. **Mitigation:** Every commit runs `bun test` and confirms - 899 pass / 2 skip / 901 total. + 990 pass / 1 skip / 991 total. ## Migration Plan diff --git a/openspec/changes/phantom-auth-review-refactors/proposal.md b/openspec/changes/phantom-auth-review-refactors/proposal.md index 4622067..407a181 100644 --- a/openspec/changes/phantom-auth-review-refactors/proposal.md +++ b/openspec/changes/phantom-auth-review-refactors/proposal.md @@ -2,7 +2,7 @@ The `phantom-auth-ultimate-fix` code review (two-axis Standards + Spec) surfaced one hard standards violation and four baseline code smells. None -are blockers — the fix shipped and all 901 tests pass — but they create +are blockers — the fix shipped and all 991 tests pass — but they create maintenance risk: duplicated comparison logic that must move in lockstep, typo-prone cookie-name string literals scattered across five files, a test helper re-declared verbatim in two files, and single-call-site @@ -88,4 +88,4 @@ change.) - **Multi-profile** — unaffected; the refactoring is profile-agnostic. - **TTY** — unaffected. - **Conformance** — `gemiterm list` non-interactive output is unchanged. - The full test suite (901 tests) must remain green at every commit. + The full test suite (991 tests) must remain green at every commit. diff --git a/openspec/changes/phantom-auth-review-refactors/tasks.md b/openspec/changes/phantom-auth-review-refactors/tasks.md index 48b6f06..9b6fb6a 100644 --- a/openspec/changes/phantom-auth-review-refactors/tasks.md +++ b/openspec/changes/phantom-auth-review-refactors/tasks.md @@ -10,10 +10,13 @@ - [ ] 2.3 `src/services/auth-service.ts`: replace inline `"__Secure-1PSID"` / `"__Secure-1PSIDTS"` literals with imports. Replace the anonymous `{ activePsid; activePsidts }` snapshot type with `CookieBaseline`. Replace the snapshot extraction and post-monitor comparison with `cookiesRotatedFrom`. - [ ] 2.4 `src/services/cookie-rotation.ts`: replace inline `"__Secure-1PSIDTS"` literals with import. - [ ] 2.5 `src/services/gemini-client-wrapper.ts`: replace inline `"__Secure-1PSID"` / `"__Secure-1PSIDTS"` literals in `persistRefreshedCookies` with imports. -- [ ] 2.6 `src/services/profile-auth-manager.ts`: no inline cookie-name literals to replace (verify; the probe uses `listChats` not cookie names directly). Skip if none found. -- [ ] 2.7 Run `bun run typecheck` and confirm clean. -- [ ] 2.8 Run `bun test` and confirm 899 pass, 0 fail, 2 skip, 901 total. -- [ ] 2.9 Commit changes in git. +- [ ] 2.6 `src/services/cookie-jar.ts`: no inline cookie-name literals — verify and skip. +- [ ] 2.7 `src/services/session-state.ts`: no inline cookie-name literals — verify and skip. +- [ ] 2.8 `src/services/conversation-threading.ts`: no inline cookie-name literals — verify and skip. +- [ ] 2.9 `src/services/profile-auth-manager.ts`: no inline cookie-name literals to replace (verify; the probe uses `listChats` not cookie names directly). Skip if none found. +- [ ] 2.10 Run `bun run typecheck` and confirm clean. +- [ ] 2.11 Run `bun test` and confirm 990 pass, 0 fail, 1 skip, 991 total. +- [ ] 2.12 Commit changes in git. ## 3. Lift the `gimme` test helper @@ -28,13 +31,13 @@ - [ ] 4.1 In `tests/services/profile-auth-manager.test.ts`, replace `existsSync(markerPath)` assertions with `readProfileHasChats("default")` from `src/infrastructure/io.ts`. Replace `writeFileSync(join(markerDir, "profile-has-chats"), "")` setup with `writeProfileHasChats("default")`. - [ ] 4.2 Remove the now-unused `existsSync`, `writeFileSync` imports from `node:fs` in `profile-auth-manager.test.ts` if no other call sites remain. - [ ] 4.3 Run `bun test tests/services/profile-auth-manager.test.ts` and confirm all pass. -- [ ] 4.4 Run `bun test` (full suite) and confirm 899 pass, 0 fail, 2 skip, 901 total. +- [ ] 4.4 Run `bun test` (full suite) and confirm 990 pass, 0 fail, 1 skip, 991 total. - [ ] 4.5 Commit changes in git. ## 5. Final verification - [ ] 5.1 Run `bun run typecheck` and confirm clean. -- [ ] 5.2 Run `bun test` and confirm 899 pass, 0 fail, 2 skip, 901 total — no regressions. +- [ ] 5.2 Run `bun test` and confirm 990 pass, 0 fail, 1 skip, 991 total — no regressions. - [ ] 5.3 Verify no `"__Secure-1PSID"` or `"__Secure-1PSIDTS"` string literals remain in `src/services/` (use `grep -r` / `rg`). All should reference the constants from `cookie-constants.ts`. - [ ] 5.4 Verify `writeProfileHasChats` / `readProfileHasChats` / `getProfileHasChatsPath` each have at least 2 call sites across `src/` + `tests/`. - [ ] 5.5 Load and run skill `code-review` and confirm the five original findings are resolved. @@ -42,8 +45,8 @@ ## 6. Code review follow-ups (non-urgent) - [ ] 6.1 Fix spec typo in `openspec/specs/silent-refresh-tightening/spec.md`: POST body `[000,"-0000000000000000000"]` should be `[0,"-0000000000000000000"]` (000 is invalid JSON; the code correctly uses `[0,...]`). -- [ ] 6.2 In `src/services/cookie-rotation.ts`, replace `CookieStorage.save(profileName, next)` direct call with `CookieStorageService.saveCookiesForProfile(profileName, next)` to match the spec contract. Verify no bookkeeping is lost. -- [ ] 6.3 Evaluate the post-refresh re-probe in `profile-auth-manager.ts:80-82` (`this.probeCache.delete(name); await this.probeServerSession(name)`) — decide whether to keep (adds correctness: re-validates after rotation) or remove (spec doesn't require it). Document decision. +- [ ] 6.2 `src/services/cookie-rotation.ts` now uses `cookieJar.upsert` (from `overhaul/auth-architecture`). The old `CookieStorage.save` direct call is in the `!cookieJar` fallback path. Verify no bookkeeping is lost. +- [ ] 6.3 The post-refresh re-probe is now in `tryRestoreStaleProbe` (extracted from `ensureAuthenticated` in `overhaul/auth-architecture`). The dormancy behavior is locked by `DO NOT THROW` regression tests. No further evaluation needed. - [ ] 6.4 Add test for `GEMITERM_PROBE_TTL_MS` env var override (set to `"60000"` and assert TTL is 60_000 ms). - [ ] 6.5 Add test for `GEMITERM_SKIP_ROTATE_COOKIES=0` and `=false` (should NOT skip). - [ ] 6.6 Add disk-mtime guard boundary test (mtime exactly 600s ago should NOT skip). diff --git a/openspec/changes/profile-aware-factory-wiring/proposal.md b/openspec/changes/profile-aware-factory-wiring/proposal.md index 6874791..fbbad07 100644 --- a/openspec/changes/profile-aware-factory-wiring/proposal.md +++ b/openspec/changes/profile-aware-factory-wiring/proposal.md @@ -42,4 +42,4 @@ None. This is a bug fix; no new capability is being introduced. - No changes to `tests/cli/list-command.test.ts`, `tests/services/profile-auth-manager.test.ts`, `tests/cli/client-services.test.ts` (existing coverage passes unchanged). - **Public CLI surface:** unchanged. `--profile` on `list` already worked as a flag; this change makes it functional. - **Performance:** negligible. One extra `getGeminiClient` call per `list -p ` or per profile in `--all-profiles` mode; each call short-circuits on the singleton cache for repeated profiles. -- **Backward compatibility:** non-`--profile` and non-`--all-profiles` flows are byte-equivalent to the pre-change baseline. The in-flight v2.6.2 changes (`profile-has-conversation-lookup`, `profile-resolution-client-init`, `silent-refresh-stale-psidts-detection`) are orthogonal and complementary — `silent-refresh-stale-psidts-detection` is exactly the rotation that is currently *not running* for the requested profile. \ No newline at end of file +- **Backward compatibility:** non-`--profile` and non-`--all-profiles` flows are byte-equivalent to the pre-change baseline. The `overhaul/auth-architecture` branch (CookieJar + state machine + dormancy resilience) lives downstream in `ensureAuthenticated` and is unaffected — the handler fix routes to the correct profile, then `ensureAuthenticated`'s dormancy-resilient gates run against that profile. No interaction risk. \ No newline at end of file diff --git a/openspec/changes/profile-aware-factory-wiring/tasks.md b/openspec/changes/profile-aware-factory-wiring/tasks.md index 5d58c8f..d14eab4 100644 --- a/openspec/changes/profile-aware-factory-wiring/tasks.md +++ b/openspec/changes/profile-aware-factory-wiring/tasks.md @@ -1,6 +1,6 @@ ## 1. Handler refactor -- [ ] 1.1 Change `ListChatsQueryHandler` constructor (`src/core/query-handlers.ts:85-89`) to accept `clientService: IGeminiClientQueryService` instead of `getGeminiClient: () => Promise`. Store it on a new private field. +- [ ] 1.1 Change `ListChatsQueryHandler` constructor (`src/core/query-handlers.ts:85-89`) to accept `clientService: IGeminiClientQueryService` instead of `getGeminiClient: () => Promise`. Store it on a new private field. (Note: line numbers may shift slightly after `overhaul/auth-architecture` merges.) - [ ] 1.2 Update `ListChatsQueryHandler.handle()` (`src/core/query-handlers.ts:91-133`) to route all three branches through `clientService`: - `profile` set → `await this.clientService.forProfile(profile).listChats(options)` (one call). - `allProfiles` set → iterate `profileManager.list()` filtered by `hasStoredCookies`; for each active profile, `await this.clientService.forProfile(name).listChats(options)` via `Promise.allSettled`. Preserve the existing `Promise.allSettled` aggregation and per-profile warning log on failure (`Failed to list chats for profile '': `). @@ -9,7 +9,7 @@ ## 2. Factory wiring -- [ ] 2.1 Update `src/cli/index.ts:119` to pass `clientService` instead of the raw `getGeminiClient` factory: `new ListChatsQueryHandler(clientService, profileManager, logger)`. Use the `clientService` returned by the existing `createClientServices(getGeminiClient)` call at `src/cli/index.ts:121`. (Reordering may be required so the `ListChatsQueryHandler` registration sees the same `clientService` instance.) +- [ ] 2.1 Update `src/cli/index.ts:127` to pass `clientService` instead of the raw `getGeminiClient` factory: `new ListChatsQueryHandler(clientService, profileManager, logger)`. Use the `clientService` returned by the existing `createClientServices(getGeminiClient)` call. (Reordering may be required so the `ListChatsQueryHandler` registration sees the same `clientService` instance.) - [ ] 2.2 Confirm no other call sites reference the removed handler constructor signature (`grep` for `new ListChatsQueryHandler`). ## 3. Tests @@ -23,7 +23,7 @@ ## 4. Validation - [ ] 4.1 `bun run typecheck` → clean (no diagnostics). -- [ ] 4.2 `bun test` → **913 pass / 0 fail / 1909 expects / 56 files** baseline intact (or higher if the new regression tests added expects; record the new baseline in `openspec/changes/profile-aware-factory-wiring/proposal.md`'s commit message). +- [ ] 4.2 `bun test` → **990 pass / 0 fail / 1 skip / 991 total** baseline intact (or higher if the new regression tests added expects; record the new baseline in `openspec/changes/profile-aware-factory-wiring/proposal.md`'s commit message). - [ ] 4.3 Manual: against a chat-bearing `GEMITERM_CONFIG_DIR` (machine `%APPDATA%\gemiterm`), run `bun run dev list -p ` for each active profile; confirm the rotation/auth log lines name the requested profile, not the default. Run `bun run dev list -i -p ` to confirm the TUI path is also routed correctly (same handler). - [ ] 4.4 Manual: confirm `bun run dev list` (no `--profile`, no `--all-profiles`) is byte-equivalent to the pre-change baseline output (4-column text table). diff --git a/openspec/specs/phantom-auth-detection/spec.md b/openspec/specs/phantom-auth-detection/spec.md index 0c623d9..b933f16 100644 --- a/openspec/specs/phantom-auth-detection/spec.md +++ b/openspec/specs/phantom-auth-detection/spec.md @@ -1,9 +1,7 @@ ## Purpose Server-side phantom-auth detection for `gemiterm`. Detects when Google invalidates a session server-side (cookies remain locally valid but the server no longer recognizes them) by probing the Gemini API with the `models()` RPC. Owns the probe cache and classification logic that distinguishes valid sessions from stale ones. - ## Requirements - ### Requirement: ProfileAuthManager probes server-side session validity before declaring authenticated When `ProfileAuthManager.ensureAuthenticated(profileName?)` is called and the profile's local cookies pass `profileManager.hasValidCookies(name)`, the method MUST consult a server-side probe before returning a successful result, AND it MUST attempt a cookie rotation via the injected `rotateCookies(name)` (the L1 `RotateCookies` POST) regardless of the probe outcome. The probe MUST call `geminiClient.models()` on a client scoped to the profile name. A process-level cache (default TTL 150_000 ms / 2.5 min, overridable via `GEMITERM_PROBE_TTL_MS` env var) MUST memoize the probe result per profile. The rotation is throttled by the 600 s disk-mtime guard inside `rotateCookies`, so an actual `RotateCookies` POST happens at most once per 600 s per profile; sub-threshold calls return early without network I/O. @@ -15,17 +13,44 @@ The two recovery functions have distinct roles: The probe result classification MUST be: -- **RPC succeeds:** the session is usable for PSID-only calls, but a stale `__Secure-1PSIDTS` cannot be ruled out, so the method MUST call `rotateCookies(name)` to refresh the token. A rotation failure MUST NOT throw. Log info, return `LoadedCookies`. +- **RPC succeeds:** the session is usable for PSID-only calls, but a stale `__Secure-1PSIDTS` cannot be ruled out, so the method MUST call `rotateCookies(name)` to refresh the token. A rotation failure MUST NOT throw. After rotation, the method proceeds to the rotation-result handling described below. Log info, return `LoadedCookies` if the session is valid. - **RPC throws:** server-side session invalidation. Log a warning, classify as "stale", call `silentRefresh(name)`. If `silentRefresh` returns `true`, return the refreshed `LoadedCookies`. If `silentRefresh` returns `false`, throw `AuthenticationError`. On probe error, the method MUST log at debug level and classify as "stale". +**Rotation result handling (after probe success):** + +- **`rotation.rotated === true`:** Fresh `__Secure-1PSIDTS` obtained. Session is fully usable. Return `LoadedCookies`. +- **`rotation.attempted === true` or `rotation.sessionInvalid === true`:** L1 reached Google but either the server declined to issue fresh PSIDTS (HTTP 200, no fresh PSIDTS in response) or the server rejected the request (401/403). In both cases, the session MAY still be usable via the Gemini API directly (RotateCookies is an `accounts.google.com` endpoint with different session validation behavior than the Gemini API). The method MUST run phantom-auth detection (`detectPhantomAuth`) — a `listChats({ limit: 1 })` call — to determine whether the session is functional. If phantom is detected (listChats returns empty), the method MUST attempt targeted L2 silent refresh (`silentRefresh(name, { mode: "targeted" })`). If targeted L2 fails, throw `AuthenticationError`. If phantom is NOT detected, the session is functional; log and return `LoadedCookies`. +- **Otherwise (`rotation.attempted === false` and `rotation.sessionInvalid` is falsy):** L1 was throttled, disabled, or unavailable. Cookies are likely still fresh. Log at debug level and return `LoadedCookies`. + +#### Scenario: models() succeeds — L1 rotation returns 401, phantom detected, targeted L2 recovers + +- **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies +- **AND** `geminiClient.models()` returns successfully +- **AND** `rotateCookies("default")` returns `{ rotated: false, attempted: false, sessionInvalid: true }` +- **AND** `detectPhantomAuth("default")` returns `true` +- **AND** `silentRefresh("default", { mode: "targeted" })` returns `true` +- **THEN** the method returns `LoadedCookies` with refreshed values +- **AND** `AuthenticationError` is NOT thrown +- **AND** `silentRefresh` was called with `mode: "targeted"` + +#### Scenario: models() succeeds — L1 rotation returns 401, phantom not detected, session still valid + +- **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies +- **AND** `geminiClient.models()` returns successfully +- **AND** `rotateCookies("default")` returns `{ rotated: false, attempted: false, sessionInvalid: true }` +- **AND** `detectPhantomAuth("default")` returns `false` (listChats returns ≥1 chat) +- **THEN** the method returns `LoadedCookies` with the stored values +- **AND** `AuthenticationError` is NOT thrown +- **AND** `silentRefresh` is NOT called + #### Scenario: models() succeeds — L1 rotation still attempted (stale 1PSIDTS detection) - **WHEN** `ensureAuthenticated("default")` is called and the default profile has locally-valid cookies - **AND** `geminiClient.models()` returns successfully +- **AND** `rotateCookies("default")` returns `{ rotated: true, attempted: true }` or `{ rotated: false, attempted: true }` - **THEN** the method returns `LoadedCookies` with the stored values -- **AND** `rotateCookies("default")` IS called (to refresh a possibly-stale `__Secure-1PSIDTS`) - **AND** `silentRefresh` is NOT called on this path (L1 only; no browser) - **AND** a rotation failure does NOT cause `AuthenticationError` to be thrown @@ -76,3 +101,4 @@ MUST fall back to the default. - **WHEN** `GEMITERM_PROBE_TTL_MS` is set to `"60000"` - **THEN** the probe cache TTL is 60_000 ms + diff --git a/src/cli/client-services.ts b/src/cli/client-services.ts index a5149f5..6c87e4f 100644 --- a/src/cli/client-services.ts +++ b/src/cli/client-services.ts @@ -2,7 +2,11 @@ import type { GeminiClientService } from "../services/gemini-client-wrapper.ts"; import type { IGeminiClientService } from "../core/command-handlers.ts"; import type { IGeminiClientQueryService } from "../core/query-handlers.ts"; -export type GetGeminiClientFn = (profileName?: string) => Promise; +export interface GetGeminiClientOptions { + nonInteractive?: boolean; +} + +export type GetGeminiClientFn = (profileName?: string, opts?: GetGeminiClientOptions) => Promise; export interface ClientServices { clientService: IGeminiClientQueryService; diff --git a/src/cli/commands/status-command.ts b/src/cli/commands/status-command.ts index d2ae2ff..37eb4d7 100644 --- a/src/cli/commands/status-command.ts +++ b/src/cli/commands/status-command.ts @@ -45,21 +45,24 @@ export class StatusCommand implements CliCommand { return { ...status, isDefault: name === getDefaultProfileName() }; }); - const probes = await Promise.all( - profileNames.map((name) => - context.mediator - .send({ + const probes: ProbeProfileQueryResult[] = []; + for (const name of profileNames) { + try { + probes.push( + await context.mediator.send({ type: QUERY_TYPES.PROBE_PROFILE, payload: { profileName: name }, - }) - .catch((err): ProbeProfileQueryResult => ({ - result: "dead", - chatsCount: 0, - modelsCount: 0, - error: err instanceof Error ? err.message : String(err), - })), - ), - ); + }), + ); + } catch (err) { + probes.push({ + result: "dead", + chatsCount: 0, + modelsCount: 0, + error: err instanceof Error ? err.message : String(err), + }); + } + } const probeByName = new Map(profileNames.map((name, i) => [name, probes[i]!])); const enriched = statuses.map((s) => ({ ...s, probe: probeByName.get(s.name) })); diff --git a/src/cli/index.ts b/src/cli/index.ts index 7fec595..0677a29 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import { Mediator } from "../core/mediator.ts"; import { GeminiClientService } from "../services/gemini-client-wrapper.ts"; import { CookieStorageService } from "../services/cookie-storage-service.ts"; import { ProfileAuthManager } from "../services/profile-auth-manager.ts"; +import { CookieJar } from "../services/cookie-jar.ts"; import { CookieStorage, ProfileManager } from "../infrastructure/storage.ts"; import { getDefaultProfileName, listProfiles } from "../infrastructure/config.ts"; import { getPackageJson } from "../infrastructure/path-utils.ts"; @@ -44,12 +45,14 @@ async function setupMediator(mediator: Mediator): Promise<{ profileAuthManager: const driver = new PlaywrightCliDriver(); const cookieMonitor = new CookieMonitor({ driver, logger }); const cookieStorageService = new CookieStorageService({ cookieStorage, logger }); + const cookieJar = new CookieJar({ cookieStorageService, logger }); const authService = new AuthService({ driver, cookieMonitor, cookieStorage, cookieStorageService, logger, + cookieJar, }); const profileQueryService = { @@ -67,7 +70,7 @@ async function setupMediator(mediator: Mediator): Promise<{ profileAuthManager: }, }; - const factoryClient = new GeminiClientService({ secure1psid: "" }, logger, cookieStorageService); + const factoryClient = new GeminiClientService({ secure1psid: "" }, logger, cookieStorageService, undefined, undefined, undefined, cookieJar); try { await factoryClient.init(); } catch { /* factory: init deferred until first real profile call */ } const profileAuthManager = new ProfileAuthManager({ profileManager, @@ -81,7 +84,7 @@ async function setupMediator(mediator: Mediator): Promise<{ profileAuthManager: let geminiClient: GeminiClientService | null = null; - async function getGeminiClient(profileName?: string): Promise { + async function getGeminiClient(profileName?: string, opts?: { nonInteractive?: boolean }): Promise { if (geminiClient && (!profileName || profileName === geminiClient.profileName)) { return geminiClient; } @@ -95,6 +98,7 @@ async function setupMediator(mediator: Mediator): Promise<{ profileAuthManager: return buildClient(targetProfile, cookies); } catch (originalError) { if (!(originalError instanceof AuthenticationError)) throw originalError; + if (opts?.nonInteractive) throw originalError; await promptAndReauth(targetProfile, originalError); const cookies = await profileAuthManager.ensureAuthenticated(targetProfile); return buildClient(targetProfile, cookies); @@ -107,6 +111,9 @@ async function setupMediator(mediator: Mediator): Promise<{ profileAuthManager: logger, cookieStorageService, profileName, + undefined, + undefined, + cookieJar, ); geminiClient = client; return client; diff --git a/src/core/query-handlers.ts b/src/core/query-handlers.ts index 3a40ba4..f5fbdfa 100644 --- a/src/core/query-handlers.ts +++ b/src/core/query-handlers.ts @@ -217,9 +217,9 @@ export class ProbeProfileQueryHandler implements QueryHandler { readonly queryType = QUERY_TYPES.PROBE_PROFILE; - private readonly getGeminiClient: (profileName?: string) => Promise; + private readonly getGeminiClient: (profileName?: string, opts?: { nonInteractive?: boolean }) => Promise; - constructor(getGeminiClient: (profileName?: string) => Promise) { + constructor(getGeminiClient: (profileName?: string, opts?: { nonInteractive?: boolean }) => Promise) { this.getGeminiClient = getGeminiClient; } @@ -256,13 +256,13 @@ export class ProbeProfileQueryHandler } private async probeModels(profileName: string): Promise { - const client = await this.getGeminiClient(profileName); + const client = await this.getGeminiClient(profileName, { nonInteractive: true }); const models = await client.listModels(); return models.length; } private async probeChats(profileName: string): Promise { - const client = await this.getGeminiClient(profileName); + const client = await this.getGeminiClient(profileName, { nonInteractive: true }); const chats = await client.listChats({ limit: 1 }); return chats.length; } diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 045c995..bf5c63c 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -12,6 +12,7 @@ import { existsFile } from "../infrastructure/io.ts"; import { isRunningElevated, ElevationError } from "../infrastructure/elevation.ts"; import { rotateCookies, isGoogleDomainCookie, COOKIE_NAMES_OF_INTEREST, type RotateCookiesResult } from "./cookie-rotation.ts"; import { CookieStorageService } from "./cookie-storage-service.ts"; +import type { CookieJar } from "./cookie-jar.ts"; const GEMINI_AUTH_URL = "https://gemini.google.com/app"; const DEFAULT_AUTH_TIMEOUT_MS = 300_000; @@ -42,6 +43,7 @@ export interface AuthServiceDeps { cookieStorageService: CookieStorageService; logger: Logger; silentRefreshMonitorFactory?: () => CookieMonitor; + cookieJar?: CookieJar; } export class AuthServiceTimeoutError extends Error { @@ -56,6 +58,7 @@ export class AuthService { private readonly cookieMonitor: CookieMonitor; private readonly cookieStorage: CookieStorage; private readonly cookieStorageService: CookieStorageService; + private readonly cookieJar?: CookieJar; private readonly logger: Logger; private readonly silentRefreshMonitorFactory: () => CookieMonitor; @@ -64,6 +67,7 @@ export class AuthService { this.cookieMonitor = deps.cookieMonitor; this.cookieStorage = deps.cookieStorage; this.cookieStorageService = deps.cookieStorageService; + this.cookieJar = deps.cookieJar; this.logger = deps.logger; this.silentRefreshMonitorFactory = deps.silentRefreshMonitorFactory ?? (() => new CookieMonitorImpl({ driver: deps.driver, logger: deps.logger })); @@ -181,7 +185,11 @@ export class AuthService { async extractCookies(profileName: string, cookies: Cookie[]): Promise { ensureConfigDir(); this.logger.info(`Saving ${cookies.length} cookies for profile: ${profileName}`); - this.cookieStorage.save(profileName, cookies); + if (this.cookieJar) { + this.cookieJar.replace(profileName, cookies); + } else { + this.cookieStorage.save(profileName, cookies); + } } confirmAuthSuccess(cookieCount: number, expiresAt: Date | null, cookies: Cookie[] = []): void { @@ -225,6 +233,7 @@ export class AuthService { cookieStorage: this.cookieStorage, cookieStorageService: this.cookieStorageService, logger: this.logger, + cookieJar: this.cookieJar, }); } catch (err) { this.logger.debug(`rotateCookies failed for profile '${name}': ${err}`); @@ -253,6 +262,7 @@ export class AuthService { cookieStorage: this.cookieStorage, cookieStorageService: this.cookieStorageService, logger: this.logger, + cookieJar: this.cookieJar, }); if (l1.rotated) { return true; @@ -324,7 +334,11 @@ export class AuthService { this.logger.debug(`silentRefresh (targeted): no PSIDTS-related cookie changed vs baseline`); return false; } - this.cookieStorageService.saveCookiesForProfile(name, next); + if (this.cookieJar) { + this.cookieJar.upsert(name, cookies.filter((bc) => COOKIE_NAMES_OF_INTEREST.has(bc.name))); + } else { + this.cookieStorageService.saveCookiesForProfile(name, next); + } return true; } @@ -334,8 +348,12 @@ export class AuthService { this.logger.debug(`silentRefresh: cookies unchanged vs baseline, treating as no rotation`); return false; } - const merged = mergeCookies(existing, cookies); - this.cookieStorageService.saveCookiesForProfile(name, merged); + if (this.cookieJar) { + this.cookieJar.upsert(name, cookies); + } else { + const merged = mergeCookies(existing, cookies); + this.cookieStorageService.saveCookiesForProfile(name, merged); + } return true; } catch (err) { this.logger.debug(`silentRefresh: ${err}`); diff --git a/src/services/conversation-threading.ts b/src/services/conversation-threading.ts new file mode 100644 index 0000000..c4252e7 --- /dev/null +++ b/src/services/conversation-threading.ts @@ -0,0 +1,44 @@ +import type { ChatMetadata } from "./chat-metadata-storage.ts"; + +const INDEX_CID = 0; +const INDEX_RID = 1; +const INDEX_RCID = 2; +const INDEX_CTX = 9; + +export function makeMetadata(cid: string, stored: ChatMetadata): (string | null)[] { + const arr: (string | null)[] = new Array(10).fill(null); + arr[INDEX_CID] = cid; + arr[INDEX_RID] = stored.rid; + arr[INDEX_RCID] = stored.rcid; + arr[INDEX_CTX] = stored.ctx ?? ""; + return arr; +} + +export function extractMetadata(metadata: (string | null)[] | undefined): ChatMetadata | null { + if (!metadata) return null; + const rid = metadata[INDEX_RID]; + const rcid = metadata[INDEX_RCID]; + if (!rid && !rcid) return null; + const ctx = metadata[INDEX_CTX]; + return { rid: rid ?? "", rcid: rcid ?? "", ctx: ctx === "" ? null : (ctx ?? null) }; +} + +export function threadOnto( + cid: string, + stored: ChatMetadata | null, +): { metadata: (string | null)[]; seeded: boolean } { + if (stored) { + return { metadata: makeMetadata(cid, stored), seeded: true }; + } + return { metadata: makeMetadata(cid, { rid: "", rcid: "", ctx: null }), seeded: false }; +} + +export function captureFrom( + output: { metadata?: (string | null)[] }, + cid: string, +): ChatMetadata | null { + const meta = extractMetadata(output.metadata); + if (!meta) return null; + if (meta.rid && meta.rcid) return meta; + return { ...meta, rid: meta.rid || "", rcid: meta.rcid || "" }; +} diff --git a/src/services/cookie-jar.ts b/src/services/cookie-jar.ts new file mode 100644 index 0000000..1dd62d7 --- /dev/null +++ b/src/services/cookie-jar.ts @@ -0,0 +1,50 @@ +import type { Cookie } from "../core/types.ts"; +import type { Logger } from "../infrastructure/logger.ts"; +import type { CookieStorageService } from "./cookie-storage-service.ts"; + +function cookieKey(c: Cookie): string { + return `${c.name}|${c.domain}|${c.path}`; +} + +export interface CookieJarDeps { + cookieStorageService: CookieStorageService; + logger: Logger; +} + +export class CookieJar { + private readonly cookieStorageService: CookieStorageService; + private readonly logger: Logger; + + constructor(deps: CookieJarDeps) { + this.cookieStorageService = deps.cookieStorageService; + this.logger = deps.logger; + } + + replace(profileName: string, cookies: Cookie[]): void { + try { + this.cookieStorageService.saveCookiesForProfile(profileName, cookies); + this.logger.debug(`CookieJar.replace: saved ${cookies.length} cookies for profile '${profileName}'`); + } catch (err) { + this.logger.debug(`CookieJar.replace: failed for profile '${profileName}': ${err}`); + throw err; + } + } + + upsert(profileName: string, cookies: Cookie[]): void { + try { + const existing = this.cookieStorageService.loadAllCookiesForProfile(profileName); + const polledByKey = new Map(cookies.map((c) => [cookieKey(c), c])); + const merged = existing.map((c) => polledByKey.has(cookieKey(c)) ? polledByKey.get(cookieKey(c))! : c); + for (const c of cookies) { + if (!existing.some((e) => cookieKey(e) === cookieKey(c))) { + merged.push(c); + } + } + this.cookieStorageService.saveCookiesForProfile(profileName, merged); + this.logger.debug(`CookieJar.upsert: merged ${cookies.length} into jar for profile '${profileName}'`); + } catch (err) { + this.logger.debug(`CookieJar.upsert: failed for profile '${profileName}': ${err}`); + throw err; + } + } +} diff --git a/src/services/cookie-rotation.ts b/src/services/cookie-rotation.ts index 2b0f250..f40c359 100644 --- a/src/services/cookie-rotation.ts +++ b/src/services/cookie-rotation.ts @@ -2,6 +2,7 @@ import type { Logger } from "../infrastructure/logger.ts"; import type { Cookie } from "../core/types.ts"; import type { CookieStorage } from "../infrastructure/storage.ts"; import type { CookieStorageService } from "./cookie-storage-service.ts"; +import type { CookieJar } from "./cookie-jar.ts"; const ROTATE_COOKIES_URL = "https://accounts.google.com/RotateCookies"; const ROTATE_COOKIES_BODY = JSON.stringify([0, "-0000000000000000000"]); @@ -28,6 +29,7 @@ interface RotateCookiesOptions { logger: Logger; fetcher?: typeof fetch; now?: () => number; + cookieJar?: CookieJar; } interface RotateCookiesHandle { @@ -36,6 +38,7 @@ interface RotateCookiesHandle { logger: Logger; fetcher: typeof fetch; now: () => number; + cookieJar?: CookieJar; } function buildCookieHeader(cookies: Cookie[]): string { @@ -165,9 +168,16 @@ async function performRotateCookies( } try { - cookieStorageService.saveCookiesForProfile(profileName, next); + if (handle.cookieJar) { + const filteredCookies = stored + .filter((c) => COOKIE_NAMES_OF_INTEREST.has(c.name) && updated.has(c.name) && updated.get(c.name) !== c.value) + .map((c) => ({ ...c, value: updated.get(c.name)! })); + handle.cookieJar.upsert(profileName, filteredCookies); + } else { + cookieStorageService.saveCookiesForProfile(profileName, next); + } } catch (err) { - logger.debug(`rotateCookies: save failed for profile '${profileName}': ${err}`); + handle.logger.debug(`rotateCookies: save failed for profile '${profileName}': ${err}`); return { rotated: false, attempted: true }; } return { rotated: true, attempted: true }; @@ -191,6 +201,7 @@ export async function rotateCookies( logger: options.logger, fetcher: options.fetcher ?? fetch, now: options.now ?? Date.now, + cookieJar: options.cookieJar, }; const promise = performRotateCookies(profileName, handle) diff --git a/src/services/gemini-client-wrapper.ts b/src/services/gemini-client-wrapper.ts index 1e7e297..67c7e46 100644 --- a/src/services/gemini-client-wrapper.ts +++ b/src/services/gemini-client-wrapper.ts @@ -1,10 +1,12 @@ -import type { ChatInfo, Message } from "../core/types.ts"; +import type { Cookie, ChatInfo, Message } from "../core/types.ts"; import type { IGeminiClientService } from "../core/command-handlers.ts"; import type { IGeminiClientQueryService } from "../core/query-handlers.ts"; import type { Logger } from "../infrastructure/logger.ts"; import type { CookieStorageService } from "./cookie-storage-service.ts"; +import type { CookieJar } from "./cookie-jar.ts"; import type { ChatMetadata } from "./chat-metadata-storage.ts"; import { ChatMetadataStorage } from "./chat-metadata-storage.ts"; +import { makeMetadata, threadOnto, captureFrom } from "./conversation-threading.ts"; import { GeminiAPIError, AuthenticationError, GemitermError } from "../core/errors.ts"; export interface GeminiClientDeps { @@ -68,15 +70,6 @@ interface GeminiClientConfig { secure1psidts?: string | null; } -function extractChatMetadata(metadata: (string | null)[] | undefined): ChatMetadata | null { - if (!metadata) return null; - const rid = metadata[1]; - const rcid = metadata[2]; - if (!rid && !rcid) return null; - const ctx = metadata[9]; - return { rid: rid ?? "", rcid: rcid ?? "", ctx: ctx === "" ? null : (ctx ?? null) }; -} - export class GeminiClientService implements IGeminiClientService, IGeminiClientQueryService { @@ -85,17 +78,19 @@ export class GeminiClientService private initialized = false; readonly logger: Logger; readonly cookieStorageService?: CookieStorageService; + readonly cookieJar?: CookieJar; readonly profileName?: string; private readonly deps: GeminiClientDeps; private baselineSecure1psid: string; private baselineSecure1psidts: string | null; private readonly chatMetadata: ChatMetadataStorage; - constructor(config: GeminiClientConfig, logger: Logger, cookieStorageService?: CookieStorageService, profileName?: string, _deps?: GeminiClientDeps, chatMetadata?: ChatMetadataStorage); - constructor(config: GeminiClientConfig, logger: Logger, cookieStorageService?: CookieStorageService, profileName?: string, _deps?: "_test", chatMetadata?: ChatMetadataStorage); - constructor(config: GeminiClientConfig, logger: Logger, cookieStorageService?: CookieStorageService, profileName?: string, _deps?: GeminiClientDeps | "_test", chatMetadata?: ChatMetadataStorage) { + constructor(config: GeminiClientConfig, logger: Logger, cookieStorageService?: CookieStorageService, profileName?: string, _deps?: GeminiClientDeps, chatMetadata?: ChatMetadataStorage, cookieJar?: CookieJar); + constructor(config: GeminiClientConfig, logger: Logger, cookieStorageService?: CookieStorageService, profileName?: string, _deps?: "_test", chatMetadata?: ChatMetadataStorage, cookieJar?: CookieJar); + constructor(config: GeminiClientConfig, logger: Logger, cookieStorageService?: CookieStorageService, profileName?: string, _deps?: GeminiClientDeps | "_test", chatMetadata?: ChatMetadataStorage, cookieJar?: CookieJar) { this.logger = logger; this.cookieStorageService = cookieStorageService; + this.cookieJar = cookieJar; this.profileName = profileName; this.deps = (typeof _deps === "object" ? _deps : null) ?? getRealDeps(); this.client = new this.deps.Gemini({ secure_1psid: config.secure1psid, timeout: 300_000, autoClose: false }); @@ -118,7 +113,8 @@ export class GeminiClientService private persistRefreshedCookies(): void { try { - if (!this.cookieStorageService || !this.profileName || !this.client) return; + if (!this.profileName || !this.client) return; + if (!this.cookieStorageService && !this.cookieJar) return; const jar = this.client.cookies as Record; const live1psid = jar["__Secure-1PSID"]; const live1psidts = jar["__Secure-1PSIDTS"]; @@ -126,22 +122,32 @@ export class GeminiClientService const changed1psidts = typeof live1psidts === "string" && live1psidts !== "" && live1psidts !== this.baselineSecure1psidts; if (!changed1psid && !changed1psidts) return; - const stored = this.cookieStorageService.loadAllCookiesForProfile(this.profileName); - let changed = false; - const merged = stored.map((c) => { + const stored = this.cookieStorageService!.loadAllCookiesForProfile(this.profileName); + const changedCookies: Cookie[] = []; + for (const c of stored) { if (c.name === "__Secure-1PSID" && changed1psid && c.value === this.baselineSecure1psid) { - changed = true; - return { ...c, value: live1psid }; + changedCookies.push({ ...c, value: live1psid }); } if (c.name === "__Secure-1PSIDTS" && changed1psidts && c.value === this.baselineSecure1psidts) { - changed = true; - return { ...c, value: live1psidts }; + changedCookies.push({ ...c, value: live1psidts }); } - return c; - }); - if (!changed) return; + } + if (changedCookies.length === 0) return; - this.cookieStorageService.saveCookiesForProfile(this.profileName, merged); + if (this.cookieJar) { + this.cookieJar.upsert(this.profileName, changedCookies); + } else { + const merged = stored.map((c) => { + if (c.name === "__Secure-1PSID" && changed1psid && c.value === this.baselineSecure1psid) { + return { ...c, value: live1psid }; + } + if (c.name === "__Secure-1PSIDTS" && changed1psidts && c.value === this.baselineSecure1psidts) { + return { ...c, value: live1psidts }; + } + return c; + }); + this.cookieStorageService!.saveCookiesForProfile(this.profileName, merged); + } if (changed1psid) this.baselineSecure1psid = live1psid; if (changed1psidts) this.baselineSecure1psidts = live1psidts; this.logger.debug(`Persisted refreshed cookies for profile '${this.profileName}'`); @@ -215,6 +221,7 @@ export class GeminiClientService profileName, this.deps, this.chatMetadata, + this.cookieJar, ); } @@ -267,13 +274,14 @@ export class GeminiClientService const turns = raw ?? []; if (turns.length > 0) { const lastModelTurn = [...turns].reverse().find((t) => t.role === "model"); - if (lastModelTurn?.rid && this.profileName) { + if (lastModelTurn && this.profileName) { const existing = this.chatMetadata.lookup(this.profileName, conversationId); - this.chatMetadata.save(this.profileName, conversationId, { - rid: lastModelTurn.rid, + const meta: ChatMetadata = { + rid: lastModelTurn.rid ?? "", rcid: lastModelTurn.rcid ?? "", ctx: existing?.ctx ?? null, - }); + }; + this.chatMetadata.save(this.profileName, conversationId, meta); } } const messages = turns.length === 0 ? [] : this.toDomainMessages(turns, conversationId); @@ -308,24 +316,25 @@ export class GeminiClientService return session; } - private async seedMetadataFromChat(conversationId: string): Promise { + private async seedMetadataFromChat(conversationId: string): Promise { try { const raw = (await this.client!.readChat(conversationId)) as RawChatTurn[] | null; const turns = raw ?? []; const lastModelTurn = [...turns].reverse().find((t) => t.role === "model"); - if (lastModelTurn?.rid && this.profileName) { + if (lastModelTurn && this.profileName) { const existing = this.chatMetadata.lookup(this.profileName, conversationId); - this.chatMetadata.save(this.profileName, conversationId, { - rid: lastModelTurn.rid, + const meta: ChatMetadata = { + rid: lastModelTurn.rid ?? "", rcid: lastModelTurn.rcid ?? "", ctx: existing?.ctx ?? null, - }); - return true; + }; + this.chatMetadata.save(this.profileName, conversationId, meta); + return meta; } } catch { this.logger.debug(`seedMetadataFromChat: readChat failed for cid='${conversationId}' on profile='${this.profileName}'`); } - return false; + return null; } async sendMessage(conversationId: string, message: string): Promise { @@ -334,35 +343,25 @@ export class GeminiClientService let session: RawChatSession; if (this.profileName) { const stored = this.chatMetadata.lookup(this.profileName, conversationId); - if (stored) { - session = this.buildSession(conversationId, [ - conversationId, stored.rid, stored.rcid, null, null, null, null, null, null, - stored.ctx ?? "", - ]); - } else { - const seeded = await this.seedMetadataFromChat(conversationId); - if (seeded) { - const stored = this.chatMetadata.lookup(this.profileName, conversationId); - if (stored) { - session = this.buildSession(conversationId, [ - conversationId, stored.rid, stored.rcid, null, null, null, null, null, null, - stored.ctx ?? "", - ]); - } else { - session = this.buildSession(conversationId); - } + const { metadata, seeded } = threadOnto(conversationId, stored); + if (!seeded) { + const seededMeta = await this.seedMetadataFromChat(conversationId); + if (seededMeta) { + session = this.buildSession(conversationId, makeMetadata(conversationId, seededMeta)); } else { this.logger.debug( `sendMessage: no prior metadata for cid='${conversationId}' on profile='${this.profileName}'; falling back to cid-only send.`, ); session = this.buildSession(conversationId); } + } else { + session = this.buildSession(conversationId, metadata); } } else { session = this.buildSession(conversationId); } const output = await session.generateContent({ prompt: message }); - const captured = extractChatMetadata(output.metadata); + const captured = captureFrom(output, conversationId); if (captured && this.profileName) { this.chatMetadata.save(this.profileName, conversationId, captured); } @@ -384,7 +383,7 @@ export class GeminiClientService const response = output.text.toString(); const conversationId = output.cid ?? session.cid; if (this.profileName) { - const captured = extractChatMetadata(output.metadata); + const captured = captureFrom(output, conversationId); if (captured) { this.chatMetadata.save(this.profileName, conversationId, captured); } diff --git a/src/services/profile-auth-manager.ts b/src/services/profile-auth-manager.ts index d4a4643..3644893 100644 --- a/src/services/profile-auth-manager.ts +++ b/src/services/profile-auth-manager.ts @@ -9,6 +9,7 @@ import { getDefaultProfileName } from "../infrastructure/config.ts"; import { validateProfileName } from "../infrastructure/validators.ts"; import type { RotateCookiesResult } from "./cookie-rotation.ts"; import type { SilentRefreshOptions } from "./auth-service.ts"; +import { classifySession, getRecoveryAction, RecoveryAction } from "./session-state.ts"; export type SilentRefreshFn = ( profileName: string, @@ -86,31 +87,46 @@ export class ProfileAuthManager { const name = profileName ?? getDefaultProfileName(); validateProfileName(name); - if (!this.profileManager.hasValidCookies(name)) { - const extended = await this.autoExtendSession(name); - if (extended) { - this.logger.info(`Session auto-refreshed for profile '${name}'`); - return this.cookieStorageService.loadCookiesForProfile(name); - } + const restored = await this.tryRestoreStaleCookies(name); + if (restored) return restored; + + const probeResult = await this.tryRestoreStaleProbe(name); + if (probeResult.cookies) return probeResult.cookies; + + return this.finishAuthentication(name, probeResult.state); + } + + private async tryRestoreStaleCookies(name: string): Promise { + if (this.profileManager.hasValidCookies(name)) return null; + + const extended = await this.autoExtendSession(name); + if (extended) { + this.logger.info(`Session auto-refreshed for profile '${name}'`); + return this.cookieStorageService.loadCookiesForProfile(name); + } + if (!this.profileManager.hasStoredCookies(name)) { throw new AuthenticationError( `No valid session for profile '${name}'. Run 'gemiterm login' to authenticate.`, ); } + return null; + } + private async tryRestoreStaleProbe(name: string): Promise<{ state: ProbeResult; cookies: LoadedCookies | null }> { const probe = await this.probeServerSession(name); - if (probe === "stale") { - const refreshed = await this.silentRefresh(name); - if (refreshed) { - this.probeCache.delete(name); - await this.probeServerSession(name); - this.logger.info(`Profile '${name}' is authenticated`); - return this.cookieStorageService.loadCookiesForProfile(name); - } - throw new AuthenticationError( - `No valid session for profile '${name}'. Run 'gemiterm login' to re-authenticate.`, - ); + if (probe === "valid") return { state: "valid", cookies: null }; + + const refreshed = await this.silentRefresh(name); + if (refreshed) { + this.probeCache.delete(name); + await this.probeServerSession(name); + this.logger.info(`Profile '${name}' is authenticated`); + return { state: "valid", cookies: this.cookieStorageService.loadCookiesForProfile(name) }; } + return { state: "valid", cookies: null }; + } + private async finishAuthentication(name: string, probe: ProbeResult): Promise { let rotation: RotateCookiesResult = { rotated: false, attempted: false }; try { rotation = await this.rotateCookies(name); @@ -118,35 +134,25 @@ export class ProfileAuthManager { this.logger.debug(`ensureAuthenticated: best-effort rotation failed for profile '${name}': ${e}`); } - if (rotation.sessionInvalid) { - // RotateCookies returned 401/403: the server rejected the session outright. - // models() (PSID-only) can still succeed in this state, so the stale-probe path - // never fires — but the session is dead for PSIDTS-requiring RPCs (listChats). - // Only a headed reauth recovers this; throw so the CLI's reauth prompt fires. - throw new AuthenticationError( - `Session for profile '${name}' is no longer valid (server rejected RotateCookies). Run 'gemiterm login' to re-authenticate.`, - ); + let isPhantom = false; + if (rotation.attempted || rotation.sessionInvalid) { + isPhantom = await this.detectPhantomAuth(name); } - if (rotation.rotated) { - // Fresh __Secure-1PSIDTS obtained; session is fully usable. - } else if (rotation.attempted) { - const isPhantom = await this.detectPhantomAuth(name); - if (isPhantom) { - this.logger.debug(`Phantom-auth detected for profile '${name}'; attempting targeted L2 silent refresh`); - const refreshed = await this.silentRefresh(name, { mode: "targeted" }); - if (!refreshed) { - throw new AuthenticationError( - `Session for profile '${name}' is in phantom-auth state; targeted refresh failed. Run 'gemiterm login' to re-authenticate.`, - ); - } - } else { - this.logger.debug(`ensureAuthenticated: L1 RotateCookies declined for profile '${name}'; session is valid.`); + const state = classifySession({ + hasValidCookies: this.profileManager.hasValidCookies(name), + serverProbe: probe, + rotation, + isPhantom, + }); + + if (getRecoveryAction(state) === RecoveryAction.TargetedRefresh) { + const refreshed = await this.silentRefresh(name, { mode: "targeted" }); + if (!refreshed) { + throw new AuthenticationError( + `Session for profile '${name}' is in phantom-auth state; targeted refresh failed. Run 'gemiterm login' to re-authenticate.`, + ); } - } else { - // L1 was throttled (600 s disk-mtime guard), disabled, or unavailable before any - // network attempt. Cookies are likely still fresh; no escalation warranted. - this.logger.debug(`ensureAuthenticated: best-effort rotation skipped for profile '${name}'.`); } this.logger.info(`Profile '${name}' is authenticated`); diff --git a/src/services/session-state.ts b/src/services/session-state.ts new file mode 100644 index 0000000..0dd2a0e --- /dev/null +++ b/src/services/session-state.ts @@ -0,0 +1,68 @@ +import type { RotateCookiesResult } from "./cookie-rotation.ts"; + +export const SessionState = { + Fresh: "Fresh", + Phantom: "Phantom", + Dead: "Dead", + Stale: "Stale", + Declined: "Declined", +} as const; + +export type SessionState = (typeof SessionState)[keyof typeof SessionState]; + +export const RecoveryAction = { + None: "None", + FullRefresh: "FullRefresh", + TargetedRefresh: "TargetedRefresh", + AutoExtend: "AutoExtend", +} as const; + +export type RecoveryAction = (typeof RecoveryAction)[keyof typeof RecoveryAction]; + +export interface SessionClassifyParams { + hasValidCookies: boolean; + serverProbe: "valid" | "stale" | null; + rotation: RotateCookiesResult; + isPhantom: boolean; +} + +export function classifySession(params: SessionClassifyParams): SessionState { + const { hasValidCookies, serverProbe, rotation, isPhantom } = params; + + if (!hasValidCookies) { + return SessionState.Stale; + } + + if (serverProbe === "stale") { + return SessionState.Dead; + } + + if (rotation.rotated) { + return SessionState.Fresh; + } + + if (rotation.sessionInvalid) { + return isPhantom ? SessionState.Phantom : SessionState.Declined; + } + + if (rotation.attempted) { + return isPhantom ? SessionState.Phantom : SessionState.Stale; + } + + return SessionState.Fresh; +} + +export function getRecoveryAction(state: SessionState): RecoveryAction { + switch (state) { + case SessionState.Fresh: + return RecoveryAction.None; + case SessionState.Phantom: + return RecoveryAction.TargetedRefresh; + case SessionState.Dead: + return RecoveryAction.FullRefresh; + case SessionState.Stale: + return RecoveryAction.AutoExtend; + case SessionState.Declined: + return RecoveryAction.None; + } +} diff --git a/tests/services/conversation-threading.test.ts b/tests/services/conversation-threading.test.ts new file mode 100644 index 0000000..f727d10 --- /dev/null +++ b/tests/services/conversation-threading.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from "bun:test"; +import { + makeMetadata, + extractMetadata, + threadOnto, + captureFrom, +} from "../../src/services/conversation-threading.ts"; +import type { ChatMetadata } from "../../src/services/chat-metadata-storage.ts"; + +describe("ConversationThreading", () => { + describe("makeMetadata", () => { + test("constructs metadata array with cid, rid, rcid, ctx", () => { + const meta: ChatMetadata = { rid: "rid123", rcid: "rcid456", ctx: "ctx789" }; + const result = makeMetadata("cid001", meta); + expect(result[0]).toBe("cid001"); + expect(result[1]).toBe("rid123"); + expect(result[2]).toBe("rcid456"); + expect(result[9]).toBe("ctx789"); + expect(result[3]).toBeNull(); + }); + + test("handles null ctx — stores as empty string for SDK compatibility", () => { + const meta: ChatMetadata = { rid: "rid1", rcid: "rcid1", ctx: null }; + const result = makeMetadata("c1", meta); + expect(result[9]).toBe(""); + }); + + test("produces 10-element array", () => { + const result = makeMetadata("c1", { rid: "r", rcid: "rc", ctx: null }); + expect(result).toHaveLength(10); + }); + }); + + describe("extractMetadata", () => { + test("extracts rid, rcid, ctx from metadata array", () => { + const arr: (string | null)[] = [null, "rid1", "rcid1", null, null, null, null, null, null, "ctx1"]; + const result = extractMetadata(arr); + expect(result).toEqual({ rid: "rid1", rcid: "rcid1", ctx: "ctx1" }); + }); + + test("returns null for undefined input", () => { + expect(extractMetadata(undefined)).toBeNull(); + }); + + test("returns null when rid and rcid are both missing", () => { + const arr: (string | null)[] = [null, null, undefined, null, null, null, null, null, null, null]; + expect(extractMetadata(arr)).toBeNull(); + }); + + test("treats empty string ctx as null", () => { + const arr: (string | null)[] = [null, "rid1", "rcid1", null, null, null, null, null, null, ""]; + const result = extractMetadata(arr); + expect(result?.ctx).toBeNull(); + }); + }); + + describe("threadOnto", () => { + test("uses stored metadata when available", () => { + const stored: ChatMetadata = { rid: "rid1", rcid: "rcid1", ctx: "ctx1" }; + const result = threadOnto("c1", stored); + expect(result.seeded).toBe(true); + expect(result.metadata[0]).toBe("c1"); + expect(result.metadata[1]).toBe("rid1"); + expect(result.metadata[2]).toBe("rcid1"); + }); + + test("creates metadata array even without stored data", () => { + const result = threadOnto("c1", null); + expect(result.seeded).toBe(false); + expect(result.metadata[0]).toBe("c1"); + expect(result.metadata[1]).toBe(""); + expect(result.metadata[2]).toBe(""); + }); + }); + + describe("captureFrom", () => { + test("extracts metadata from SDK output", () => { + const output = { metadata: [null, "rid1", "rcid1", null, null, null, null, null, null, "ctx1"] }; + const result = captureFrom(output, "c1"); + expect(result).toEqual({ rid: "rid1", rcid: "rcid1", ctx: "ctx1" }); + }); + + test("returns null for output without metadata", () => { + expect(captureFrom({}, "c1")).toBeNull(); + }); + + test("returns null when extractMetadata returns null", () => { + expect(captureFrom({ metadata: [] }, "c1")).toBeNull(); + }); + }); +}); diff --git a/tests/services/cookie-jar.test.ts b/tests/services/cookie-jar.test.ts new file mode 100644 index 0000000..f43b83f --- /dev/null +++ b/tests/services/cookie-jar.test.ts @@ -0,0 +1,168 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CookieJar } from "../../src/services/cookie-jar.ts"; +import { CookieStorageService } from "../../src/services/cookie-storage-service.ts"; +import { CookieStorage } from "../../src/infrastructure/storage.ts"; +import { Logger } from "../../src/infrastructure/logger.ts"; +import type { Cookie } from "../../src/core/types.ts"; + +const TEST_DIR = join(tmpdir(), "gemiterm-test-cookie-jar"); +const logger = new Logger("test"); +const PROFILE = "default"; + +function farFuture(): number { + return Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60; +} + +function makeCookie(name: string, value: string, domain = ".google.com", path = "/"): Cookie { + return { + name, + value, + domain, + path, + expires: farFuture(), + httpOnly: true, + secure: true, + sameSite: "Lax" as const, + }; +} + +beforeEach(() => { + process.env.GEMITERM_CONFIG_DIR = TEST_DIR; + mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + delete process.env.GEMITERM_CONFIG_DIR; +}); + +describe("CookieJar", () => { + describe("replace", () => { + test("saves cookies, overwriting the entire jar", () => { + const storage = new CookieStorage(); + const cookieStorageService = new CookieStorageService({ cookieStorage: storage, logger }); + const jar = new CookieJar({ cookieStorageService, logger }); + + jar.replace(PROFILE, [ + makeCookie("__Secure-1PSID", "psid1"), + makeCookie("__Secure-1PSIDTS", "psidts1"), + makeCookie("SID", "sid1"), + ]); + + const loaded = storage.load(PROFILE); + expect(loaded).toHaveLength(3); + expect(loaded.find((x) => x.name === "__Secure-1PSID")?.value).toBe("psid1"); + }); + + test("replace overwrites previous cookies completely", () => { + const storage = new CookieStorage(); + const cookieStorageService = new CookieStorageService({ cookieStorage: storage, logger }); + const jar = new CookieJar({ cookieStorageService, logger }); + + jar.replace(PROFILE, [ + makeCookie("__Secure-1PSID", "psid1"), + makeCookie("__Secure-1PSIDTS", "psidts1"), + makeCookie("SID", "sid1"), + makeCookie("HSID", "hsid1"), + ]); + + jar.replace(PROFILE, [ + makeCookie("__Secure-1PSID", "psid2"), + makeCookie("__Secure-1PSIDTS", "psidts2"), + ]); + + const loaded = storage.load(PROFILE); + expect(loaded).toHaveLength(2); + expect(loaded.find((x) => x.name === "__Secure-1PSID")?.value).toBe("psid2"); + expect(loaded.find((x) => x.name === "SID")).toBeUndefined(); + }); + }); + + describe("upsert", () => { + test("merges by (name, domain, path) key — updates matching entries, adds new ones", () => { + const storage = new CookieStorage(); + const cookieStorageService = new CookieStorageService({ cookieStorage: storage, logger }); + const jar = new CookieJar({ cookieStorageService, logger }); + + jar.replace(PROFILE, [ + makeCookie("__Secure-1PSID", "psid1"), + makeCookie("__Secure-1PSIDTS", "psidts1"), + makeCookie("SID", "sid1"), + ]); + + jar.upsert(PROFILE, [ + makeCookie("__Secure-1PSID", "psid-new", ".google.com", "/"), + makeCookie("HSID", "hsid-new"), + ]); + + const loaded = storage.load(PROFILE); + expect(loaded).toHaveLength(4); + expect(loaded.find((x) => x.name === "__Secure-1PSID" && x.domain === ".google.com")?.value).toBe("psid-new"); + expect(loaded.find((x) => x.name === "HSID")?.value).toBe("hsid-new"); + expect(loaded.find((x) => x.name === "SID")?.value).toBe("sid1"); + }); + + test("does not modify entries not present in upsert set", () => { + const storage = new CookieStorage(); + const cookieStorageService = new CookieStorageService({ cookieStorage: storage, logger }); + const jar = new CookieJar({ cookieStorageService, logger }); + + jar.replace(PROFILE, [ + makeCookie("__Secure-1PSID", "psid1"), + makeCookie("__Secure-1PSIDTS", "psidts1"), + ]); + + jar.upsert(PROFILE, [ + makeCookie("__Secure-1PSIDTS", "psidts-new", ".google.com", "/"), + ]); + + const loaded = storage.load(PROFILE); + expect(loaded).toHaveLength(2); + expect(loaded.find((x) => x.name === "__Secure-1PSID")?.value).toBe("psid1"); + expect(loaded.find((x) => x.name === "__Secure-1PSIDTS")?.value).toBe("psidts-new"); + }); + + test("same name, different domain are distinct entries", () => { + const storage = new CookieStorage(); + const cookieStorageService = new CookieStorageService({ cookieStorage: storage, logger }); + const jar = new CookieJar({ cookieStorageService, logger }); + + jar.replace(PROFILE, [ + makeCookie("__Secure-1PSID", "g-psid", ".google.com"), + makeCookie("__Secure-1PSID", "yt-psid", ".youtube.com"), + ]); + + jar.upsert(PROFILE, [ + makeCookie("__Secure-1PSID", "g-psid-new", ".google.com"), + ]); + + const loaded = storage.load(PROFILE); + expect(loaded).toHaveLength(2); + expect(loaded.find((x) => x.name === "__Secure-1PSID" && x.domain === ".google.com")?.value).toBe("g-psid-new"); + expect(loaded.find((x) => x.name === "__Secure-1PSID" && x.domain === ".youtube.com")?.value).toBe("yt-psid"); + }); + + test("same name, different path are distinct entries", () => { + const storage = new CookieStorage(); + const cookieStorageService = new CookieStorageService({ cookieStorage: storage, logger }); + const jar = new CookieJar({ cookieStorageService, logger }); + + jar.replace(PROFILE, [ + makeCookie("AUTH", "v1", ".google.com", "/"), + makeCookie("AUTH", "v2", ".google.com", "/admin"), + ]); + + jar.upsert(PROFILE, [ + makeCookie("AUTH", "v1-new", ".google.com", "/"), + ]); + + const loaded = storage.load(PROFILE); + expect(loaded).toHaveLength(2); + expect(loaded.find((x) => x.name === "AUTH" && x.path === "/")?.value).toBe("v1-new"); + expect(loaded.find((x) => x.name === "AUTH" && x.path === "/admin")?.value).toBe("v2"); + }); + }); +}); diff --git a/tests/services/phantom-auth.test.ts b/tests/services/phantom-auth.test.ts index 73d3e7b..7417746 100644 --- a/tests/services/phantom-auth.test.ts +++ b/tests/services/phantom-auth.test.ts @@ -210,7 +210,7 @@ describe("phantom-auth regression suite", () => { expect(silentRefresh).toHaveBeenCalledWith("default"); }); - test("models() throws followed by a failed silent refresh surfaces AuthenticationError", async () => { + test("models() throws followed by a failed silent refresh continues (dormancy-resilient)", async () => { const storage = new CookieStorage(); const manager = new ProfileManager(storage); manager.create("default"); @@ -229,9 +229,8 @@ describe("phantom-auth regression suite", () => { silentRefresh, }); - const err = await mgr.ensureAuthenticated("default").catch((e) => e); - expect(err).toBeInstanceOf(AuthenticationError); - expect((err as Error).message).toMatch(/No valid session|re-authenticate/i); + const cookies = await mgr.ensureAuthenticated("default"); + expect(cookies.secure_1psid).toBe("active-psid"); expect(silentRefresh).toHaveBeenCalledTimes(1); expect(silentRefresh).toHaveBeenCalledWith("default"); }); @@ -268,7 +267,7 @@ describe("phantom-auth regression suite", () => { expect(modelsFn).toHaveBeenCalledTimes(1); }); - test("rotateCookies reports session-invalid (401/403) => throws AuthenticationError, no L2 attempt", async () => { + test("rotateCookies reports session-invalid (401/403) => phantom detection + targeted L2 recovers", async () => { const storage = new CookieStorage(); const manager = new ProfileManager(storage); manager.create("default"); @@ -277,7 +276,7 @@ describe("phantom-auth regression suite", () => { const modelsFn = mock(async () => ["gemini-2.5-flash"] as string[]); const geminiClient = gimme(modelsFn); - const silentRefresh = mock(async (_profileName: string) => true); + const silentRefresh = mock(async (_profileName: string, _opts?: unknown) => true); const rotateCookies = mock(async (_profileName: string) => ({ rotated: false, attempted: false, @@ -294,9 +293,11 @@ describe("phantom-auth regression suite", () => { rotateCookies, }); - const err = await mgr.ensureAuthenticated("default").catch((e) => e); - expect(err).toBeInstanceOf(AuthenticationError); - expect(silentRefresh).toHaveBeenCalledTimes(0); + const cookies = await mgr.ensureAuthenticated("default"); + + expect(cookies.secure_1psid).toBe("active-psid"); + expect(silentRefresh).toHaveBeenCalledTimes(1); + expect(silentRefresh).toHaveBeenCalledWith("default", { mode: "targeted" }); }); test("rotateCookies declined (200, no fresh PSIDTS) does NOT escalate to L2 silentRefresh", async () => { diff --git a/tests/services/profile-auth-manager.test.ts b/tests/services/profile-auth-manager.test.ts index 21e4e10..8bf44e4 100644 --- a/tests/services/profile-auth-manager.test.ts +++ b/tests/services/profile-auth-manager.test.ts @@ -136,7 +136,7 @@ describe("ProfileAuthManager", () => { await expect(mgr.ensureAuthenticated("default")).rejects.toThrow("No valid session"); }); - test("throws AuthenticationError with expired cookies", async () => { + test("continues (dormancy-resilient) with expired-but-present cookies", async () => { const storage = new CookieStorage(); const manager = new ProfileManager(storage); manager.create("default"); @@ -144,7 +144,8 @@ describe("ProfileAuthManager", () => { const mgr = createManager(manager); - await expect(mgr.ensureAuthenticated("default")).rejects.toThrow("No valid session"); + const cookies = await mgr.ensureAuthenticated("default"); + expect(cookies.secure_1psid).toBeTruthy(); }); test("auto-extends session before throwing when silentRefresh succeeds", async () => { @@ -191,7 +192,7 @@ describe("ProfileAuthManager", () => { ); }); - test("throws AuthenticationError when auto-extend fails", async () => { + test("auto-extend failure continues (dormancy-resilient) when cookies exist", async () => { const storage = new CookieStorage(); const manager = new ProfileManager(storage); manager.create("default"); @@ -201,10 +202,69 @@ describe("ProfileAuthManager", () => { const mgr = createManager(manager, undefined, silentRefresh); - await expect(mgr.ensureAuthenticated("default")).rejects.toThrow("No valid session"); + const cookies = await mgr.ensureAuthenticated("default"); + expect(cookies.secure_1psid).toBeTruthy(); expect(silentRefresh).toHaveBeenCalledWith("default"); }); + describe("dormancy regression guard — must never reintroduce throw on stale sessions", () => { + test("DO NOT THROW: expired cookies still on disk must resolve, not reject", async () => { + const storage = new CookieStorage(); + const manager = new ProfileManager(storage); + manager.create("default"); + storage.save("default", makeExpiredCookies()); + + const mgr = createManager(manager); + const result = await mgr.ensureAuthenticated("default"); + expect(result.secure_1psid).toBeTruthy(); + }); + + test("DO NOT THROW: models() throws + silentRefresh fails must resolve", async () => { + const storage = new CookieStorage(); + const manager = new ProfileManager(storage); + manager.create("default"); + storage.save("default", makeValidCookies()); + + const modelsFn = mock(async () => { throw new Error("network error"); }); + const geminiClient = { + models: modelsFn as unknown as IGeminiClientService["models"], + async forProfile() { return this as unknown as IGeminiClientService; }, + async deleteChat() {}, + async sendMessage() { return ""; }, + async startNewChat() { return { response: "", conversationId: "" }; }, + async profileHasConversation() { return false; }, + }; + const silentRefresh = mock(async () => false); + + const mgr = createManager(manager, geminiClient as unknown as IGeminiClientService, silentRefresh); + const result = await mgr.ensureAuthenticated("default"); + expect(result.secure_1psid).toBe("test-psid-value"); + }); + + test("DO NOT THROW: probe stale + silentRefresh fails must resolve", async () => { + const storage = new CookieStorage(); + const manager = new ProfileManager(storage); + manager.create("default"); + storage.save("default", makeValidCookies()); + + const modelsFn = mock(async () => { throw new Error("network error"); }); + const geminiClient = { + models: modelsFn as unknown as IGeminiClientService["models"], + async forProfile() { return this as unknown as IGeminiClientService; }, + async deleteChat() {}, + async sendMessage() { return ""; }, + async startNewChat() { return { response: "", conversationId: "" }; }, + async profileHasConversation() { return false; }, + }; + const silentRefresh = mock(async () => false); + + const mgr = createManager(manager, geminiClient as unknown as IGeminiClientService, silentRefresh); + const result = await mgr.ensureAuthenticated("default"); + expect(result.secure_1psid).toBe("test-psid-value"); + expect(silentRefresh).toHaveBeenCalledTimes(1); + }); + }); + test("throws on invalid profile name", async () => { const storage = new CookieStorage(); const manager = new ProfileManager(storage); @@ -572,7 +632,7 @@ describe("ProfileAuthManager", () => { expect(silentRefresh).toHaveBeenCalledWith("default"); }); - test("models() throws + silent refresh fails surfaces AuthenticationError", async () => { + test("models() throws + silent refresh fails continues (dormancy-resilient)", async () => { const storage = new CookieStorage(); const manager = new ProfileManager(storage); manager.create("default"); @@ -587,7 +647,8 @@ describe("ProfileAuthManager", () => { const mgr = createManager(manager, geminiClient as unknown as IGeminiClientService, silentRefresh); - await expect(mgr.ensureAuthenticated("default")).rejects.toThrow("No valid session"); + const cookies = await mgr.ensureAuthenticated("default"); + expect(cookies.secure_1psid).toBe("test-psid-value"); expect(modelsFn).toHaveBeenCalledTimes(1); expect(silentRefresh).toHaveBeenCalledTimes(1); expect(silentRefresh).toHaveBeenCalledWith("default"); diff --git a/tests/services/session-state.test.ts b/tests/services/session-state.test.ts new file mode 100644 index 0000000..7b734ea --- /dev/null +++ b/tests/services/session-state.test.ts @@ -0,0 +1,88 @@ +import { describe, test, expect } from "bun:test"; +import { + classifySession, + getRecoveryAction, + SessionState, + RecoveryAction, +} from "../../src/services/session-state.ts"; +import type { RotateCookiesResult } from "../../src/services/cookie-rotation.ts"; + +const noRotation: RotateCookiesResult = { rotated: false, attempted: false }; +const throttled: RotateCookiesResult = { rotated: false, attempted: false }; +const attempted: RotateCookiesResult = { rotated: false, attempted: true }; +const rotated: RotateCookiesResult = { rotated: true, attempted: true }; +const declined: RotateCookiesResult = { rotated: false, attempted: false, sessionInvalid: true }; + +describe("classifySession", () => { + test("no valid cookies → Stale", () => { + expect(classifySession({ hasValidCookies: false, serverProbe: null, rotation: noRotation, isPhantom: false })) + .toBe(SessionState.Stale); + }); + + test("valid cookies + server probe stale → Dead", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "stale", rotation: noRotation, isPhantom: false })) + .toBe(SessionState.Dead); + }); + + test("valid cookies + server probe valid + rotation successful → Fresh", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "valid", rotation: rotated, isPhantom: false })) + .toBe(SessionState.Fresh); + }); + + test("valid cookies + probe null + rotation successful → Fresh", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: null, rotation: rotated, isPhantom: false })) + .toBe(SessionState.Fresh); + }); + + test("valid cookies + probe valid + rotation attempted but not rotated + not phantom → Stale", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "valid", rotation: attempted, isPhantom: false })) + .toBe(SessionState.Stale); + }); + + test("valid cookies + probe valid + rotation attempted + phantom → Phantom", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "valid", rotation: attempted, isPhantom: true })) + .toBe(SessionState.Phantom); + }); + + test("valid cookies + probe valid + rotation sessionInvalid + phantom → Phantom", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "valid", rotation: declined, isPhantom: true })) + .toBe(SessionState.Phantom); + }); + + test("valid cookies + probe valid + rotation sessionInvalid + not phantom → Declined", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "valid", rotation: declined, isPhantom: false })) + .toBe(SessionState.Declined); + }); + + test("valid cookies + probe valid + throttled rotation → Fresh", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "valid", rotation: throttled, isPhantom: false })) + .toBe(SessionState.Fresh); + }); + + test("server probe stale overrides rotation success → Dead", () => { + expect(classifySession({ hasValidCookies: true, serverProbe: "stale", rotation: rotated, isPhantom: false })) + .toBe(SessionState.Dead); + }); +}); + +describe("getRecoveryAction", () => { + test("Fresh → None", () => { + expect(getRecoveryAction(SessionState.Fresh)).toBe(RecoveryAction.None); + }); + + test("Phantom → TargetedRefresh", () => { + expect(getRecoveryAction(SessionState.Phantom)).toBe(RecoveryAction.TargetedRefresh); + }); + + test("Dead → FullRefresh", () => { + expect(getRecoveryAction(SessionState.Dead)).toBe(RecoveryAction.FullRefresh); + }); + + test("Stale → AutoExtend", () => { + expect(getRecoveryAction(SessionState.Stale)).toBe(RecoveryAction.AutoExtend); + }); + + test("Declined → None", () => { + expect(getRecoveryAction(SessionState.Declined)).toBe(RecoveryAction.None); + }); +});