Skip to content

fix: clear expired websocket cookies - #144

Open
kocaemre wants to merge 1 commit into
agentclientprotocol:mainfrom
kocaemre:fix/ws-cookie-expiry-clears-store
Open

kocaemre wants to merge 1 commit into
agentclientprotocol:mainfrom
kocaemre:fix/ws-cookie-expiry-clears-store

Conversation

@kocaemre

Copy link
Copy Markdown

Summary

  • Clear stored WebSocket affinity cookies when a Set-Cookie response explicitly expires them with Max-Age=0 or an epoch Expires value.
  • Add regression coverage so reconnects do not keep sending a stale affinity cookie after logout/session rotation.

Test Plan

  • uv run pytest tests/http/test_cookies.py tests/http/test_ws_cookies.py -q — 10 passed
  • uv run ruff check src/acp/_cookies.py tests/http/test_cookies.py tests/http/test_ws_cookies.py — passed
  • uv run ruff format --check src/acp/_cookies.py tests/http/test_cookies.py tests/http/test_ws_cookies.py — 3 files already formatted
  • git diff --check — passed
  • make test — 258 passed, 1 skipped
  • make check — passed

@PerryLink PerryLink left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 4f1b98a. The direction is right, and I confirmed the underlying bug is real: the store replays a stale affinity cookie after the server has explicitly cleared it. Your two new tests pass here (tests/http/test_cookies.py → 8 passed). Three behavioural gaps, all inside _is_deletion_cookie, each measured by running the store against this PR's own src/acp/_cookies.py.

1. Max-Age is compared as the literal string "0"

if key == "max-age" and value == "0":

RFC 6265 §5.2.2 defines expiry as delta-seconds <= 0, not == 0:

Set-Cookie (2nd, after affinity=abc123) RFC 6265 this branch
affinity=; Max-Age=0 clear clear ✓
affinity=; Max-Age=-1 clear kept
affinity=; Max-Age=00 clear kept

Max-Age=-1 is not exotic — it is a common "expire now" idiom. (I deliberately left Max-Age=+0 out of the claim: the §5.2.2 parse algorithm does not describe a leading +, so I don't think it is fair to require it.)

2. Expires matches one exact string

if key == "expires" and value in {"thu, 01 jan 1970 00:00:00 gmt", "0"}:

Every other valid cookie-date in the past is missed — including one second later on the same day:

Expires= expected actual
Thu, 01 Jan 1970 00:00:00 GMT clear clear ✓
Thu, 01 Jan 1970 00:00:01 GMT clear kept
Wed, 31 Dec 1969 23:59:59 GMT clear kept
Thu, 01-Jan-1970 00:00:00 GMT clear kept
Mon, 01 Jan 2024 00:00:00 GMT clear kept

All five are well-formed cookie-dates per §5.1.1 and all are in the past, so all five must expire the cookie.

3. The two attributes are OR'ed, but §5.3 gives Max-Age precedence

RFC 6265 §5.3 step 3 reads "if … 'Max-Age' … Otherwise, if … 'Expires'". So Max-Age is consulted first and Expires only matters when Max-Age is absent. This header therefore describes a cookie the server intends to keep for an hour:

affinity=keepme; Max-Age=3600; Expires=Thu, 01 Jan 1970 00:00:00 GMT
expected actual
keep affinity=keepme cleared

This is the one direction of error that makes the change worse than the status quo, for any server that sets both.

Why the new tests don't catch any of it

Both added tests assert the exact spellings the implementation special-cases (Max-Age=0, Expires=Thu, 01 Jan 1970 00:00:00 GMT), so they pin the implementation rather than the requirement. A table-driven test over the rows above covers the same ground and goes red on any regression in them.

A shape that handles all three

Parse instead of string-matching, and consult Max-Age first so it wins outright:

def _expiry_decision(attributes: list[str]) -> bool | None:
    """True = expire now, False = persist, None = no usable expiry attribute."""
    verdict = None
    for attribute in attributes:
        key, separator, value = attribute.partition("=")
        if not separator:
            continue
        key = key.strip().lower()
        value = value.strip()
        if key == "max-age":
            try:
                return int(value) <= 0          # Max-Age wins, per 5.3
            except ValueError:
                continue                        # malformed: fall through to Expires
        if key == "expires" and verdict is None:
            try:
                when = parsedate_to_datetime(value)
            except (TypeError, ValueError):
                continue
            if when.tzinfo is None:
                when = when.replace(tzinfo=timezone.utc)
            verdict = when <= datetime.now(timezone.utc)
    return verdict

Caveat: I have not implemented or run this — it is a sketch, and int() here is more permissive than §5.2.2 (which only allows an optional leading - and digits). Happy to send it as a tested patch if reviewing a diff is easier than reviewing a snippet.

One scope question, which is genuinely yours to call

The module docstring says the store is "intentionally minimal … matching the affinity-only use case in the RFD". If the intent is really "handle exactly the two literals the known server sends", that is a defensible scope decision — but then the docstring should say that, because as written ("an epoch Expires value") it promises the general case that (2) does not deliver. Either resolution works; the mismatch between the promise and the behaviour is the part I would fix.

Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>
@kocaemre
kocaemre force-pushed the fix/ws-cookie-expiry-clears-store branch from 4f1b98a to 7cc85b4 Compare September 23, 2026 04:41
@kocaemre

Copy link
Copy Markdown
Author

Addressed the cookie expiry review gaps in 7cc85b450.

What changed:

  • parse Max-Age numerically so non-positive values such as -1 and 00 clear the stored cookie
  • parse Expires as a cookie date and clear any past expiry, not only the epoch literal
  • give a valid Max-Age precedence over Expires, matching RFC 6265 section 5.3
  • expanded the regression table to cover the review examples and the positive-Max-Age/past-Expires precedence case

Local verification:

uv run pytest tests/http/test_cookies.py -q
# 15 passed

uv run pytest tests/http/test_ws_cookies.py -q
# 2 passed

make check
# passed: pre-commit/ruff/format, ty, deptry

make test
# 265 passed, 1 skipped

git diff --check origin/main..HEAD
# passed

The pushed commit is signed off with the GitHub noreply identity.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants