Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 101 additions & 30 deletions src/gflow_cli/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ def _is_target_closed(exc: BaseException) -> bool:
_AISANDBOX_HOST = "aisandbox-pa.googleapis.com"
_LABS_ORIGIN = "https://labs.google"
_SESSION_API_URL = "https://labs.google/fx/api/auth/session"
# issue #222: the NextAuth Flow session cookie + the URL used to seed it.
_FLOW_SESSION_COOKIE = "__Secure-next-auth.session-token"
_FLOW_COOKIE_URL = _LABS_ORIGIN


def _parse_iso_to_epoch(value: object) -> float:
Expand Down Expand Up @@ -286,6 +289,11 @@ def __init__(
# ``self._page``. T3 rewires them to ``_checkout_page()`` /
# ``_checkin_page()`` and this alias goes away.
self._page: Page | None = None
# issue #222: Flow cookies read from the profile BEFORE the headed
# generation context launches, used to seed that context if it cannot
# decrypt the on-disk cookie store (macOS Keychain vs basic-store
# mismatch). Populated by _preread_flow_session_cookies().
self._preread_flow_cookies: dict[str, str] = {}

# --- lifecycle --------------------------------------------------------

Expand Down Expand Up @@ -428,44 +436,100 @@ def _log_and_guard_launch(self, kwargs: dict[str, Any]) -> None:
raise ConfigurationError(msg)
logger.warning("client.chrome_strategy_downgraded", detail=msg)

async def _log_context_cookie_state(self) -> None:
"""Diagnostic (issue #222): log whether the launched persistent context
actually loaded the Flow session cookie.
async def _preread_flow_session_cookies(self) -> None:
"""#222: read the profile's Flow cookies BEFORE the headed generation
context launches, so _ensure_context_session_cookie can seed them if the
headed context fails to decrypt the on-disk store (macOS Keychain vs
basic-store mismatch).

Why pre-launch: ``get_chrome_cookie_snapshot``'s fallback opens a
*headless* ``--password-store=basic`` Chrome context on the same profile —
the one read path that decrypts on macOS — and a second persistent context
cannot be opened once the headed context holds the profile's singleton
lock.

Best-effort: any failure leaves the pre-read empty and the launch proceeds
unchanged. Cheap where browser_cookie3 succeeds (no browser is launched).
"""
try:
from gflow_cli.auth.cookies import get_chrome_cookie_snapshot

snapshot = await get_chrome_cookie_snapshot(self.profile_dir)
self._preread_flow_cookies = dict(snapshot.httpx_cookies)
logger.info(
"client.preread_flow_cookies",
preread_count=len(self._preread_flow_cookies),
preread_session=_FLOW_SESSION_COOKIE in self._preread_flow_cookies,
)
except Exception as exc: # noqa: BLE001 — best-effort; must never break launch
logger.warning("client.preread_flow_cookies_failed", error=type(exc).__name__)
self._preread_flow_cookies = {}

async def _ensure_context_session_cookie(self) -> None:
"""Diagnostic + seed (issue #222): log whether the launched persistent
context loaded the Flow session cookie and, if it did not, seed it from
the pre-launch snapshot.

The accepting path (auth ``verify_flow_profile``) reads cookies via
browser_cookie3 + httpx and never uses a persistent context; generation
uses THIS persistent Chrome context. Logging the context's own cookie jar
(presence / count / expiry only — never values, so it is safe to paste
into a public issue) splits a cookie-LOAD failure (the context could not
decrypt or find the Flow session cookie) from a server-side rejection
(cookie present yet createProject still 401s). Pure observability — it
must never raise and never break the launch.
uses THIS persistent Chrome context. On macOS that headed context can load
ZERO cookies — it cannot decrypt the profile's basic-store-sealed jar — so
the Flow session never loads and project.createProject 401s, even though
login + verification succeed (they read via the headless
``--password-store=basic`` path, which DOES decrypt). We seed from
``self._preread_flow_cookies`` (captured pre-launch via that same working
reader; see ``_preread_flow_session_cookies``) so the context carries the
session. No-op where the context loads the cookie itself (e.g. Windows).

The diagnostic logs presence / count / expiry only — never values, so it
is safe to paste into a public issue. Pure best-effort: never raises,
never breaks the launch.
"""
if self._context is None:
return
try:
cookies = await self._context.cookies()
now = time.time()
flow_cookie = next(
(c for c in cookies if c.get("name") == "__Secure-next-auth.session-token"),
None,
except Exception as exc: # noqa: BLE001 — must never break the launch
logger.warning("client.context_cookie_probe_error", error=type(exc).__name__)
return
now = time.time()
flow_cookie = next(
(c for c in cookies if c.get("name") == _FLOW_SESSION_COOKIE),
None,
)
flow_expires = flow_cookie.get("expires") if flow_cookie is not None else None
logger.info(
"client.context_cookie_state",
context_cookie_count=len(cookies),
flow_session_cookie_present=flow_cookie is not None,
# expires == -1 -> a session cookie (no persisted expiry); otherwise
# epoch seconds. Chromium prunes already-expired cookies from the
# jar, so the common "logged out" signal is present=False; this flag
# only catches a near-boundary expiry that survived into the jar.
flow_session_cookie_expired=(
flow_expires is not None and flow_expires != -1 and flow_expires < now
),
google_sapisid_present=any(c.get("name") == "SAPISID" for c in cookies),
)
if flow_cookie is not None:
return # context loaded the session cookie — nothing to seed
if not self._preread_flow_cookies:
logger.warning("client.context_cookie_seed_unavailable")
return
try:
await self._context.add_cookies(
[
{"name": name, "value": value, "url": _FLOW_COOKIE_URL}
for name, value in self._preread_flow_cookies.items()
]
)
flow_expires = flow_cookie.get("expires") if flow_cookie is not None else None
logger.info(
"client.context_cookie_state",
context_cookie_count=len(cookies),
flow_session_cookie_present=flow_cookie is not None,
# expires == -1 -> a session cookie (no persisted expiry); otherwise
# epoch seconds. Chromium prunes already-expired cookies from the
# jar, so the common "logged out" signal is present=False; this flag
# only catches a near-boundary expiry that survived into the jar.
flow_session_cookie_expired=(
flow_expires is not None and flow_expires != -1 and flow_expires < now
),
google_sapisid_present=any(c.get("name") == "SAPISID" for c in cookies),
"client.context_cookies_seeded",
seeded_count=len(self._preread_flow_cookies),
seeded_session=_FLOW_SESSION_COOKIE in self._preread_flow_cookies,
)
except Exception as exc: # noqa: BLE001 — a diagnostic must never break the launch
logger.warning("client.context_cookie_probe_error", error=type(exc).__name__)
except Exception as exc: # noqa: BLE001 — seeding must never break the launch
logger.warning("client.context_cookie_seed_error", error=type(exc).__name__)

async def _enter_setup(self) -> None:
"""Body of __aenter__ after the Playwright driver starts.
Expand All @@ -475,6 +539,12 @@ async def _enter_setup(self) -> None:
"""
# Invariant: __aenter__ sets self._pw immediately before calling us.
assert self._pw is not None
# issue #222 (macOS): pre-read the profile's Flow cookies BEFORE launching
# the headed context, so _ensure_context_session_cookie can seed them if the
# headed context can't decrypt the on-disk store. Must run pre-launch — the
# snapshot's fallback opens a headless context on the same profile, which
# would deadlock on the singleton lock once the headed context holds it.
await self._preread_flow_session_cookies()
kwargs = self._persistent_context_kwargs()
self._log_and_guard_launch(kwargs)
self._context = await self._pw.chromium.launch_persistent_context(**kwargs)
Expand All @@ -484,9 +554,10 @@ async def _enter_setup(self) -> None:
await self._context.add_init_script(
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined})",
)
# issue #222 diagnostic: confirm the persistent context actually loaded
# the Flow session cookie (the load-vs-send discriminator).
await self._log_context_cookie_state()
# issue #222: log whether the headed context loaded the Flow session
# cookie and, if not (macOS can't decrypt the on-disk store), seed it
# from the pre-launch snapshot.
await self._ensure_context_session_cookie()
# Open ``Settings.concurrency`` Pages inside the one persistent
# BrowserContext. ``launch_persistent_context`` opens one Page by
# default; reuse it as slot 0 to avoid an unused N+1 Page.
Expand Down
13 changes: 12 additions & 1 deletion src/gflow_cli/auth/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,19 @@ async def _get_chrome_cookies_playwright(profile_dir: Path) -> ChromeCookieSnaps
finally:
await ctx.close()

# #222: derive the flow cookies from the FULL jar (domain-filtered), NOT from
# ctx.cookies([_FLOW_COOKIE_URL]) — that URL filter only returns cookies whose
# path matches "/", silently dropping the NextAuth session token scoped to
# "/fx" (labs.google/fx is where the Flow app and project.createProject live).
# Missing that token is why a seeded context stayed logged-out -> 401.
logger.info(
"playwright_cookie_read",
all_cookies=len(all_cookies),
flow_path_root=len(flow_cookies),
flow_domain=sum(1 for c in all_cookies if _is_flow_cookie(c)),
)
return ChromeCookieSnapshot(
httpx_cookies=_name_value_cookies(flow_cookies, flow_only=False),
httpx_cookies=_name_value_cookies(all_cookies, flow_only=True),
google_session=_has_google_session_cookie(all_cookies),
)

Expand Down
58 changes: 56 additions & 2 deletions tests/api/test_client_launch_kwargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def _capture_cookie_state(client: FlowApiClient) -> dict:

cap = structlog.testing.LogCapture()
with patch("gflow_cli.api.client.logger", structlog.wrap_logger(None, processors=[cap])):
asyncio.run(client._log_context_cookie_state()) # noqa: SLF001
asyncio.run(client._ensure_context_session_cookie()) # noqa: SLF001
return dict(next(e for e in cap.entries if e["event"] == "client.context_cookie_state"))


Expand Down Expand Up @@ -250,7 +250,61 @@ def test_context_cookie_state_swallows_probe_error(tmp_path: Path) -> None:
client._context = ctx # noqa: SLF001
cap = structlog.testing.LogCapture()
with patch("gflow_cli.api.client.logger", structlog.wrap_logger(None, processors=[cap])):
asyncio.run(client._log_context_cookie_state()) # noqa: SLF001 (must not raise)
asyncio.run(client._ensure_context_session_cookie()) # noqa: SLF001 (must not raise)
events = [e["event"] for e in cap.entries]
assert "client.context_cookie_probe_error" in events
assert "client.context_cookie_state" not in events


# --- issue #222: pre-read seed of the session cookie (macOS decrypt failure) ----


def test_context_seeds_session_when_absent_and_preread_present(tmp_path: Path) -> None:
"""#222: when the headed context loaded NO session cookie (macOS can't decrypt
the on-disk store) but the pre-launch snapshot has it, seed it into the context."""
import asyncio

client = FlowApiClient(profile_dir=tmp_path, headless=True)
ctx = MagicMock()
ctx.cookies = AsyncMock(return_value=[]) # headed context loaded nothing
ctx.add_cookies = AsyncMock()
client._context = ctx # noqa: SLF001
client._preread_flow_cookies = { # noqa: SLF001
"__Secure-next-auth.session-token": "tok",
"__Host-next-auth.csrf-token": "csrf",
}
asyncio.run(client._ensure_context_session_cookie()) # noqa: SLF001
ctx.add_cookies.assert_awaited_once()
seeded = ctx.add_cookies.await_args.args[0]
assert any(c["name"] == "__Secure-next-auth.session-token" for c in seeded)
assert all(c["url"] == "https://labs.google" for c in seeded)


def test_context_no_seed_when_session_present(tmp_path: Path) -> None:
"""#222: if the context already loaded the session cookie, do NOT seed."""
import asyncio

client = FlowApiClient(profile_dir=tmp_path, headless=True)
ctx = MagicMock()
ctx.cookies = AsyncMock(
return_value=[{"name": "__Secure-next-auth.session-token", "expires": -1}]
)
ctx.add_cookies = AsyncMock()
client._context = ctx # noqa: SLF001
client._preread_flow_cookies = {"__Secure-next-auth.session-token": "tok"} # noqa: SLF001
asyncio.run(client._ensure_context_session_cookie()) # noqa: SLF001
ctx.add_cookies.assert_not_awaited()


def test_context_no_seed_when_preread_empty(tmp_path: Path) -> None:
"""#222: session absent AND no pre-read → nothing to seed, logs unavailable."""
import asyncio

client = FlowApiClient(profile_dir=tmp_path, headless=True)
ctx = MagicMock()
ctx.cookies = AsyncMock(return_value=[])
ctx.add_cookies = AsyncMock()
client._context = ctx # noqa: SLF001
# _preread_flow_cookies defaults to {} — nothing captured pre-launch.
asyncio.run(client._ensure_context_session_cookie()) # noqa: SLF001
ctx.add_cookies.assert_not_awaited()
Loading