Skip to content

feat(protocol): add write_cookies, the one verb that can repair a rotated session - #211

Merged
chrischall merged 3 commits into
mainfrom
feat/write-cookies
Aug 5, 2026
Merged

feat(protocol): add write_cookies, the one verb that can repair a rotated session#211
chrischall merged 3 commits into
mainfrom
feat/write-cookies

Conversation

@chrischall

@chrischall chrischall commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #202.

Why a write verb at all

Every verb until now reads, and that leaves a failure class consuming MCPs cannot fix locally. Sites that rotate a credential cookie hand back a new value on each refresh; when an MCP refreshes and keeps the result to itself, the copy in the browser's cookie jar is dead. The user is signed out of a tab they never touched, and told it was "inactivity".

Measured on creditkarma.com: two MCP-side refreshes logged out an untouched, freshly signed-in tab, and reloading doesn't recover it — only a full sign-in does. That MCP shipped the only mitigation available to it (refresh only when the token is genuinely spent, chrischall/creditkarma-mcp#121) and it still can't help once the browser has been idle past the access token's ~15-minute life — the scheduled-sync case, i.e. the common one.

The design decision I'd most like reviewed

Writes reuse the declared cookieKeys rather than getting their own list.

I flagged both options in #202 and picked the narrower one. It keeps the blast radius identical to the read scope the user already saw and approved, and avoids threading a second declaration array through the trust store, scope diff, popup, and CLI. The cost: you cannot grant write on a cookie you can't read. That seemed right for a first write verb, and a separate cookieWriteKeys can be added later without a wire break — say the word if you'd rather have it now.

Constraints, all enforced extension-side

  1. Capabilitywrite_cookies, declared in the hello, approved at pair time as its own line, stored in the trust record. Adding it later forces a re-pair with the diff UI like any other capability change.
  2. Read scope — every name must already be in declared cookieKeys. Granting writes cannot widen which cookies are in play, only what may be done to ones already listed.
  3. Domain — gated against declared domains, decided on the bare origin before any path, exactly as the read path does.
  4. Existence — the cookie must already exist. This refreshes a value in place and cannot author cookies, which is what keeps it from being a cookie-injection primitive: an MCP can't mint a session cookie for a domain, only replace a value the browser already holds.

Any violation refuses the whole request rather than applying part of it — a half-written rotation leaves the session in a state neither side expects.

The trap this would otherwise have shipped with

chrome.cookies.set({url, name, value}) looks like it works and quietly creates a second cookie. Chrome derives a host-only, non-HttpOnly, default-path cookie from the URL, which does not replace a Domain=-scoped or HttpOnly original — so the site keeps reading the stale one. That's precisely the silent failure this verb exists to prevent, so attributes are copied off the cookie being replaced, with two that must be omitted rather than passed as undefined:

  • domain on a host-only cookie — Chrome reads its presence as "widen this into a domain cookie"
  • expirationDate on a session cookie — passing undefined would turn a persistent cookie into a session one

Both have their own tests.

Surfacing it to the user

The popup labels it "Overwrite cookies it can already read (can change your signed-in session)", not as a sibling of the read verbs, because it isn't one. docs/SECURITY.md gains §T-cookie-write with the four constraints and the residual risk stated plainly: an MCP with this capability can set a declared cookie to a value of its choosing, which grants no new access (it could already read and exfiltrate those cookies) but does add the ability to alter browser state for them.

Tests

1171 pass (up from 1157). Server-side: capability gate, undeclared key, mixed batch, empty write, and the frame shape including the bare-origin/path invariant. Extension-side: the gate resolver and the attribute-copying builder, including both omission cases. Plus a check that the popup presents this as a write.

Following the repo's existing pattern, the security decisions are exported as pure functions (resolveWriteCookiesRequest, cookieSetDetailsFor) so they're unit-testable without live WS/session state — same shape as resolveReadDomRequest.

Not done

No CLI verb for writing. The CLI gains only fpx profile declare --allow-cookie-write so a profile can opt in; the write itself is an MCP-side operation and I didn't want to add a way to hand-set cookies from a shell without you asking for it.

🤖 Generated with Claude Code

https://claude.ai/code/session_012o2nXwu7tov6j7ciBEpigo


Review fixes (758b62e) — closes #212. Auto-review failed this correctly: packages/protocol/src/validate.ts was never updated, so the verb could not execute at all. Requests were dropped at the frame-decode boundary and writeCookies() hung to its timeout; a successful response would have thrown on the MCP side and closed the extension socket for every MCP on the concentrator. Only successes broke — ok:false already validated via KNOWN_RESPONSE_OPS, which is why nothing noticed.

My error was specific and worth naming: I grepped validate.ts for read_cookies, got no hits, and concluded it didn't dispatch on ops. The file is treated as binary by grep, so the search was silently empty rather than genuinely negative. CLAUDE.md names that file as a merge blocker for new protocol fields and I walked straight past it.

New packages/protocol/tests/write-cookies-wire.test.ts round-trips through sealInnerFrame/openEncryptedFrame. Verified it catches the original bug: with the validator reverted, 8 of its tests fail, including both round-trips. All six checklist items are done, plus two extras that fell out of tracing the findings — the validator refuses a glob in a cookie name (declared keys may be globs; a pattern would reach cookies the popup never showed) and refuses extra fields on a cookie entry (no smuggling domain/expirationDate past the in-place contract).

1202 tests pass.

Closes #212

@github-actions github-actions Bot added the auto-review Trigger Claude + Copilot review on this PR label Aug 5, 2026
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 5m 24s —— View job


Review complete — PR #211 write_cookies

  • Read CLAUDE.md + repo conventions
  • Read the PR diff
  • Review protocol frames + validators
  • Review server-side gate (ws-server.ts)
  • Review extension-side gate + cookie set builder
  • Review CLI / popup / docs
  • Check test coverage
  • Post findings

Verdict: fail — two 🔴 findings, both in packages/protocol/src/validate.ts, which this PR does not touch.

  1. validateInnerRequest has no write_cookies branch, so the request is rejected at the extension's frame-decode boundary (openEncryptedFramevalidateInnerFrame), silently dropped with no response, and writeCookies() hangs to its timeout. handleWriteCookiesRequest is unreachable in production.
  2. validateInnerResponse's ok:true path has no write_cookies branch either, so a successful write would throw on the MCP side and host.ts's catch-all closes the extension socket for every MCP on the concentrator. (ok:false is already fine — KNOWN_RESPONSE_OPS derives from KNOWN_CAPABILITIES, which the PR did update.)

CLAUDE.md names this file explicitly: "Don't merge feature work that adds protocol fields without updating packages/protocol/src/validate.ts validators."

The new tests don't catch either gap because the server tests stub sendInnerFrame and the extension tests call the pure resolvers directly — nothing exercises seal → open.

Everything else is solid: the four-gate security envelope, whole-request refusal, the chrome.cookies.set shadowing trap (domain/expirationDate omission), the popup label, and docs/SECURITY.md §T-cookie-write. Two nits and two open questions are in the full review.

I couldn't run the build or test suite (npm install needs approval here), so findings are from static tracing with quoted code paths, not an executed run.
· branch feat/write-cookies

…ated session

Every verb until now reads. That leaves a failure class consuming MCPs
cannot fix: sites that ROTATE a credential cookie hand back a new value
on every refresh, so when an MCP refreshes and keeps the result to
itself, the copy in the browser's cookie jar is dead. The user is signed
out of a tab they never touched and told it was "inactivity".

Measured on creditkarma.com: two MCP-side refreshes were enough to log
out an untouched, freshly signed-in tab, and reloading does not recover
it — only a full sign-in does (chrischall/creditkarma-mcp#119). That MCP
shipped the only mitigation available to it (refresh only when the token
is genuinely spent) and it still cannot help when the browser has been
idle past the access token's life, which is the scheduled-sync case.

`writeCookies()` overwrites the value of cookies the MCP already
declares. Four independent constraints, all enforced extension-side:

  - capability: `write_cookies`, approved at pair time as its own line
    and stored in the trust record, so adding it later forces a re-pair
  - read scope: every name must already be in declared `cookieKeys`, so
    a write can never reach a cookie the user did not approve reading
  - domain: origin gated against declared `domains`, decided on the bare
    origin before any path, exactly as the read path does
  - existence: the cookie must already exist — this refreshes a value in
    place and cannot author cookies, which is what stops it from being a
    cookie-injection primitive

Reusing `cookieKeys` rather than adding a separate writable-key list is
deliberate: it keeps the blast radius identical to the read scope the
user already saw, at the cost of not being able to grant write on a
cookie you cannot read. That seemed the right trade for a first write
verb; a separate list can be added later without a wire break.

Attributes are copied off the cookie being replaced. Passing only
{url,name,value} looks like it works and quietly creates a SECOND cookie
— Chrome derives a host-only, non-HttpOnly, default-path cookie from the
URL, which does not replace a Domain=-scoped or HttpOnly original, and
the site keeps reading the stale one. `domain` is omitted for host-only
cookies and `expirationDate` for session cookies, since passing either
would widen scope or lifetime.

The popup labels it "Overwrite cookies it can already read (can change
your signed-in session)" rather than as a sibling of the read verbs.
docs/SECURITY.md gains §T-cookie-write with the residual risk.

Closes #202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o2nXwu7tov6j7ciBEpigo
@chrischall

Copy link
Copy Markdown
Owner Author

Deferred: this has never touched a real cookie jar

Recording this on the PR rather than leaving it in a chat log, because it is the one gap the test suite structurally cannot close.

Every test here mocks chrome.cookies. The mock accepts whatever payload it is handed, so it proves the payload is built correctly — it cannot prove Chrome accepts it, and the failure this verb exists to prevent is exactly the kind a mock cannot catch: a set that looks successful and silently creates a second cookie instead of replacing the first, leaving the site reading the stale one.

Agreed to verify post-deployment. What to check, once the rebuilt extension is loaded from chrome://extensions:

  1. Overwrite, not duplicate. On a domain with a Domain=-scoped HttpOnly cookie, write a new value, then confirm in DevTools → Application → Cookies that there is still exactly one cookie of that name and its value changed. Two rows means the attribute copying is wrong and the whole verb is a no-op in practice.
  2. Host-only cookies keep their scope. Same check against a host-only cookie — the domain field must stay absent so Chrome doesn't widen it into a domain cookie.
  3. Session cookies stay session cookies. Write to one and confirm it still has no expiry rather than acquiring one.
  4. The existence gate fires. Write to a name that isn't currently set and confirm it refuses rather than creating it.
  5. The real thing. Rotate a credential cookie via an MCP refresh, write the new value back, and confirm the browser tab stays signed in — the actual outcome the verb is for.

(1) is the one that matters most: it is the difference between this working and appearing to work.

Rebased onto 959fcc5 (#210) — 1174 tests pass on the rebase.

🤖 Generated with Claude Code

Comment on lines +656 to +662
export interface InnerRequestWriteCookies {
type: 'request';
id: number;
op: 'write_cookies';
init: WriteCookiesInit;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 write_cookies requests are rejected at the wire boundary — the verb can never reach the extension.

packages/protocol/src/validate.ts was not touched by this PR:

$ git diff HEAD~1 --name-only
docs/SECURITY.md
packages/cli/src/args.ts
packages/cli/src/main.ts
packages/cli/src/profiles.ts
packages/cli/src/server-opts.ts
packages/cli/tests/args.test.ts
packages/extension-core/src/background.ts
packages/extension-core/src/popup/popup.ts
packages/extension-core/tests/write-cookies.test.ts
packages/protocol/src/frames.ts
packages/server/src/ws-server.ts
packages/server/tests/write-cookies.test.ts

and it contains zero occurrences of the new op (Grep for write_cookies in packages/protocol/src/validate.tsNo matches found).

validateInnerRequest switches on raw.op and falls through to a terminal throw for anything unrecognised — validate.ts:959:

inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download", "graphql_query"; got ...

The receive path is not optional: sealInnerFrame does not validate, but openEncryptedFrameDetailed calls validateInnerFrame (seal.ts:118), and the extension decodes every inbound frame through openEncryptedFrame (background.ts:1344). The throw lands in that function's catch:

} catch (e) {
  console.warn('[fetchproxy] decrypt failed:', e);
  return;
}

So a write_cookies request is silently dropped — no response frame at all — and FetchproxyServer.writeCookies() hangs until _withVerbTimeout fires. handleWriteCookiesRequest is unreachable in production.

The new server tests don't catch this because they stub the transport (packages/server/tests/write-cookies.test.ts:36 mocks sendInnerFrame), and the extension tests call resolveWriteCookiesRequest directly rather than going through the frame decode.

This is also the one thing CLAUDE.md calls out by name:

Don't merge feature work that adds protocol fields without updating packages/protocol/src/validate.ts validators (every inbound frame is validated before dispatch).

Needs a raw.op === 'write_cookies' branch asserting init.origin is an HTTPS origin, init.path via assertCookiePath when present, init.cookies is a non-empty array of {name: string, value: string}, and rejecting unexpected init fields — matching the read_cookies branch's shape. Worth a round-trip test through sealInnerFrame/openEncryptedFrame so the gap can't reopen.

Fix this →

Comment on lines +765 to +774
export interface InnerResponseWriteCookiesOk {
type: 'response';
id: number;
ok: true;
op: 'write_cookies';
/** Names actually written, echoed so the caller can confirm rather than
* assume. Same order as requested. */
written: string[];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The success response has no validator branch either — and this one tears down the concentrator's extension socket.

validateInnerResponse's ok === true path discriminates on op and ends in a terminal throw (validate.ts:1124):

throw new ProtocolError(
  `inner.op: unknown success-response op ${JSON.stringify(raw.op)}`,
);

There's no write_cookies case, so {ok: true, op: 'write_cookies', written: [...]} — the frame handleWriteCookiesRequest sends on success (background.ts:1929-1935) — fails validation on the MCP side.

Note the asymmetry: the failure path is already fine, because KNOWN_RESPONSE_OPS is built from KNOWN_CAPABILITIES (validate.ts:1007-1010) and this PR added write_cookies there. So every gate rejection round-trips correctly and only a successful write hits the throw — which is the worse ordering for noticing it.

The blast radius is the one the KNOWN_RESPONSE_OPS doc comment was written about:

Without this, every ok:false, op:'graphql_query' response … fails validation here, which — via host.ts's message-handler catch-all — closes the extension WebSocket for every MCP on the concentrator.

Confirmed at packages/server/src/host.ts:342-351: the throw out of openEncryptedFrame (line 320) is caught by the message handler's catch-all, which does ws.close(1011, 'internal error') on the extension socket — dropping every other MCP multiplexed through the host, not just the one that wrote a cookie.

Same fix location as the request-side comment above; written wants a string[] assertion.

Comment on lines +1913 to +1921
await fail(
`cookies not present to overwrite: ${missing.join(', ')} — write_cookies refreshes an ` +
'existing value, it does not create cookies',
);
return;
}
const written: string[] = [];
for (const { decl, existing } of targets) {
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit — written reports success purely on "the promise didn't reject", never on the resolved value. The declared type is Promise<unknown> and the result is discarded.

Given this verb's entire thesis is that a silently stale cookie is the failure mode worth adding a write verb for, it seems worth checking the resolution too — Chrome's cookies.set contract is documented as yielding null when the set fails. A const set = await chrome.cookies.set(...); if (!set) { await fail(...); return; } (with the type narrowed to Promise<ChromeCookie | null>) would keep a no-op from being reported back as a completed rotation.

I couldn't execute this against a real Chrome, so treat the behavioural half as an open question rather than a confirmed defect — but the defensive check costs nothing either way.

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review — write_cookies

The design is well argued and the security envelope is right: capability + read-scope + domain + existence, all four re-checked extension-side, whole-request refusal rather than partial application, and the chrome.cookies.set shadowing trap handled correctly (omitting domain on host-only and expirationDate on session cookies, both with their own tests). Reusing cookieKeys instead of a second declaration array is the right call for a first write verb — it keeps the blast radius identical to the scope the user already approved, and the popup label earns its length.

The generic capability gate at background.ts:1391-1408 covers this verb for free (op string equals capability string), and cookies is already in manifest.json, so no new permission is needed.

But the verb cannot currently execute. packages/protocol/src/validate.ts was not updated, and it is the one file CLAUDE.md names explicitly:

Don't merge feature work that adds protocol fields without updating packages/protocol/src/validate.ts validators (every inbound frame is validated before dispatch).

🔴 Important

  1. validateInnerRequest has no write_cookies branch — it falls through to a terminal throw listing the nine known ops. Every inbound frame is decoded via openEncryptedFramevalidateInnerFrame (seal.ts:118, background.ts:1344), and the extension's catch there just logs and returns. So the request is silently dropped, no response is ever sent, and writeCookies() hangs to its timeout. handleWriteCookiesRequest is unreachable in production. (inline)

  2. validateInnerResponse's ok:true path has no write_cookies branch — so once (1) is fixed, the success frame throws inner.op: unknown success-response op on the MCP side, and host.ts:342-351's catch-all responds with ws.close(1011) on the extension socket, dropping every MCP on the concentrator. Note the asymmetry: ok:false already validates fine, because KNOWN_RESPONSE_OPS derives from KNOWN_CAPABILITIES and this PR did add the capability there — so gate rejections round-trip cleanly and only successful writes break things. (inline)

Neither gap is caught by the new tests, and that's structural rather than an oversight in coverage volume: the server tests stub sendInnerFrame (packages/server/tests/write-cookies.test.ts:36) and the extension tests call resolveWriteCookiesRequest / cookieSetDetailsFor directly. Nothing exercises seal → open. A round-trip test through sealInnerFrame/openEncryptedFrame in packages/protocol/tests/ for both the request and the success response would close the gap and keep it closed. While adding the request branch, init.cookies is worth a non-empty assertion — the server refuses an empty write, but the extension currently answers a wire-level empty cookies: [] with ok: true, written: [].

🟡 Nits

  • chrome.cookies.set's resolved value is discarded, so written reports success on "didn't throw" alone. Given the verb exists to prevent a silent stale-cookie failure, checking the resolution is cheap insurance. (inline)
  • packages/extension-chrome/README.md's "Manifest highlights" isn't updated. cookies is already granted so no manifest change is needed — but the graphql bullet sets the precedent of documenting a capability even when it needs no new permission, and chrome.cookies.set is genuinely new chrome.* API surface. A short bullet saying write_cookies rides the existing cookies permission and only overwrites values in place would match how download and graphql are covered there.

Open questions (not counted as findings)

  • I couldn't run the build or test suite — npm install needs approval in this environment — so the 1171-pass figure is unverified from my side, as is the claim about chrome.cookies.set resolving null on failure. Both findings above are from static reading of code paths I traced and quoted.
  • docs/PROTOCOL.md still says "v1 defines two verbs" and documents only fetch / read_cookies / graphql_query. read_dom, download, and read_indexed_db are already missing, so this is pre-existing drift rather than something this PR owes — flagging only in case you want to sweep it.

Fix the two validator branches and this is ready; everything else in the PR is solid.

Verdict: fail

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🟡 Auto-review verdict: warn — The round-3 blocker is fixed — pendingWriteCookies is now drained in both rejectAllPending and sendInnerFrame's catch, backed by a discovery-based test that covers all nine awaiter maps by walking the instance rather than naming them — and all five carried-over nits are closed. Two new minor issues remain: the handleWriteCookiesRequest doc block was orphaned when partialWriteError was spliced in above it, and the pair popup still lists the cookie names only under a "Read cookies" heading when write_cookies is granted.
📋 Tracking follow-ups: #212

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 5m 32s —— View job


Review complete — PR #211

  • Read CLAUDE.md + repo conventions
  • Read the full diff
  • Review protocol frame validators
  • Review server-side gate (ws-server.ts)
  • Review extension-side gate + cookie builder (background.ts)
  • Review CLI + popup + docs
  • Check test coverage
  • Post findings

Verdict: fail — one blocking finding.

🔴 packages/protocol/src/validate.ts was never updated for the new write_cookies op, which CLAUDE.md calls out explicitly ("Don't merge feature work that adds protocol fields without updating packages/protocol/src/validate.ts validators"). grep -n "write_cookies" packages/protocol/src/validate.ts returns nothing. Because seal.ts:118 validates every inner frame inside openEncryptedFrame, the request hits the unconditional throw at validate.ts:959 and the extension returns without a response (background.ts:1345-1348) — so handleWriteCookiesRequest is unreachable and writeCookies() hangs to the verb timeout. And once that's fixed, ok:true, op:'write_cookies' throws at validate.ts:1124, which host.ts:344-352 turns into a socket close for every MCP on the concentrator. The new tests can't catch it: packages/server/tests/write-cookies.test.ts:31 mocks sendInnerFrame, so nothing crosses the seal boundary.

🟡 Four nits, all inline: partial writes possible when chrome.cookies.set throws mid-loop despite the doc claiming whole-request refusal; sameSite/storeId passed as explicit undefined while domain/expirationDate are conditionally spread; the popup test greps popup.ts source text instead of rendering like popup.test.ts:518-535 does; docs/PRIVACY.md §2.2 still frames capabilities as read-only.

The design itself is good — the four-constraint model, the cookieKeys-reuse decision, and the chrome.cookies.set shadowing trap are all handled well. Two open questions (CHIPS partitionKey, and that I couldn't run the suite here) are in the summary comment rather than counted as findings.

No files were modified.
· branch feat/write-cookies

* cannot reach a cookie the MCP was not already trusted to read, which keeps
* the blast radius identical to the read scope the user already saw.
*/
export interface WriteCookiesInit {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 write_cookies was added to frames.ts but never to validate.ts — the verb cannot work on the wire, and its success response would tear down the whole bridge.

CLAUDE.md, What to not do:

Don't merge feature work that adds protocol fields without updating packages/protocol/src/validate.ts validators (every inbound frame is validated before dispatch).

write_cookies appears nowhere in that file:

$ grep -n "write_cookies" packages/protocol/src/validate.ts packages/protocol/tests/*.ts
(no output)

Two concrete consequences, both on the real path:

1. The request never reaches the handler. validateInnerRequest has no write_cookies branch, so it falls through to the unconditional throw at validate.ts:959:

throw new ProtocolError(
  `inner.op: must be one of "fetch", "read_cookies", ... "graphql_query"; got ${JSON.stringify(raw.op)}`,
);

Every inner frame goes through it — seal.ts:118 calls validateInnerFrame(parsed) inside openEncryptedFrame, and the extension opens frames at background.ts:1344. On throw, onEncryptedFrame logs and returns without sending a response:

} catch (e) {
  console.warn('[fetchproxy] decrypt failed:', e);
  return;
}

So handleWriteCookiesRequest is unreachable in production, and the MCP-side writeCookies() promise hangs until _withVerbTimeout fires — surfacing as a bridge timeout, not as "unsupported op".

2. Even after fixing (1), the success response kills the socket for every MCP. validateInnerResponse ends at validate.ts:1124:

throw new ProtocolError(`inner.op: unknown success-response op ${JSON.stringify(raw.op)}`);

and host.ts:344-352 catches any throw from openEncryptedFrame and closes the socket — the exact failure mode already documented in the KNOWN_RESPONSE_OPS comment at validate.ts:995-1006 ("closes the extension WebSocket for every MCP on the concentrator"). Note the ok:false path is fine, since KNOWN_RESPONSE_OPS spreads KNOWN_CAPABILITIES and this PR did add write_cookies there — which is why the gate-refusal tests would still look healthy.

The existing tests don't cover this: packages/server/tests/write-cookies.test.ts:31 mocks sendInnerFrame, so nothing in the new suite crosses the seal/validate boundary.

Needs a raw.op === 'write_cookies' branch in validateInnerRequest (assert origin via assertHttpsOriginOnly, optional path via assertCookiePath, a non-empty cookies array of {name, value} string pairs, and reject unexpected init fields like the read_cookies branch does) plus an op === 'write_cookies' branch in validateInnerResponse asserting written is a string array.

Fix this →

Comment on lines +1919 to +1930
const written: string[] = [];
for (const { decl, existing } of targets) {
try {
await chrome.cookies.set(cookieSetDetailsFor(url, decl, existing));
written.push(decl.name);
} catch (e) {
await fail(`failed to write cookie ${decl.name}: ${String(e)}`);
return;
}
}
await sendInner(mcpId, {
type: 'response',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 A chrome.cookies.set failure part-way through this loop does apply part of the request, which is the one outcome the doc block above (background.ts:1849-1856) says is refused:

Three gates, in order, all refusing the WHOLE request rather than applying part of it — a half-written rotation is worse than a failed one

The three gates hold that promise, but the write loop doesn't: with {SESSION, REFRESH}, if SESSION succeeds and REFRESH throws, SESSION is already rotated in the jar and the error response carries no written list — so the MCP sees only a failure string and has no way to know it must not retry SESSION, or that the browser is now half-rotated. The pre-flight existence check makes this unlikely, not impossible (the store can change between get and set).

Chrome gives no transaction to make this atomic, so the practical fix is to make the partial state legible — include the already-written names in the error, e.g.:

await fail(
  `failed to write cookie ${decl.name}: ${String(e)}` +
    (written.length > 0 ? ` — already written: ${written.join(', ')}` : ''),
);

…and soften the doc block so it doesn't claim atomicity the write phase can't provide.

Comment on lines +1846 to +1848
sameSite: existing.sameSite,
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
storeId: existing.storeId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 sameSite and storeId are both declared optional on ChromeCookie (background.ts:1372-1376) yet are passed through unconditionally, so when they're absent on the source cookie this emits sameSite: undefined / storeId: undefined — exactly the pattern the function's own doc comment singles out as wrong for domain and expirationDate ("must be OMITTED rather than passed as undefined"). Conditional spreads would make the four fields consistent:

Suggested change
sameSite: existing.sameSite,
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
storeId: existing.storeId,
...(existing.sameSite === undefined ? {} : { sameSite: existing.sameSite }),
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
...(existing.storeId === undefined ? {} : { storeId: existing.storeId }),

(the expirationDate line already below this range stays as-is if you'd rather only touch the two — this suggestion replaces lines 1846-1848, so drop the middle line if you keep the existing one).

Comment on lines +124 to +138
it('labels it as changing state, and warns', async () => {
const mod = await import('../src/popup/popup.js');
const display = (mod as unknown as {
__testCapabilityDisplay?: Record<string, { label: string; warn: boolean }>;
}).__testCapabilityDisplay;
// Falls back to reading the source when the map isn't exported for tests.
const { readFileSync } = await import('node:fs');
const src = display
? JSON.stringify(display)
: readFileSync(new URL('../src/popup/popup.ts', import.meta.url), 'utf8');
expect(src).toMatch(/write_cookies/);
expect(src).toMatch(/Overwrite cookies/);
expect(src).toMatch(/signed-in session/);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 This asserts on the source text of popup.ts rather than on rendered output, so it passes as long as those strings appear anywhere in the file — including inside the comment this PR added right above the entry ("'write cookies' alone reads as a sibling", "signed-in session"). Delete the CAPABILITY_DISPLAY entry and keep the comment and the test still goes green.

The adjacent suite already does this properly by rendering — packages/extension-core/tests/popup.test.ts:518-535:

it('renders DOM selectors when read_dom declared', () => {
  ...
  capabilities: ['fetch', 'read_dom'],
  ...
  expect(container.textContent).toContain('Read DOM elements');
});

Following that shape with capabilities: ['fetch', 'write_cookies'] and asserting on container.textContent (plus the warn marker, as read_cookies does at popup.test.ts:426) would actually pin the label the user sees. The __testCapabilityDisplay fallback branch is also dead — nothing exports that symbol.

Comment thread docs/SECURITY.md

**Residual risk:** A user who approves a pair with `read_cookies` is giving the MCP a powerful read primitive for the declared domains. The popup tries to make that visible; the trust record forces re-approval on change. There is no further defense — if you don't trust the MCP, don't approve the pair.

### T-cookie-write — `write_cookies` capability misuse

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 docs/PRIVACY.md §2.2 is the user-facing counterpart to this section and is framed entirely as reads — "Depending on the capabilities an MCP server declares and you approve at pair time, Transporter may read:" followed by a table of read_* / capture_request_header (PRIVACY.md:25-38). The first capability that changes browser state deserves a line there.

Partly pre-existing — read_dom, graphql, and download are missing from that table too — but write_cookies is the one that makes the section's read-only framing inaccurate rather than merely incomplete.

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review — feat(protocol): add write_cookies

The design is sound and the security reasoning is unusually well-argued: capability + read-scope + domain + existence, all re-checked extension-side, with the chrome.cookies.set shadowing trap correctly identified and both omission cases (domain on host-only, expirationDate on session) covered by tests. The decision to reuse cookieKeys rather than add cookieWriteKeys reads right for a first write verb — it keeps the popup's approved scope and the enforced scope the same object, which is the property that makes the containment argument checkable.

One blocker, though, and it's the kind this repo has explicitly written down.

🔴 Important

packages/protocol/src/validate.ts never learned write_cookies. CLAUDE.md, What to not do: "Don't merge feature work that adds protocol fields without updating packages/protocol/src/validate.ts validators (every inbound frame is validated before dispatch)."

$ grep -n "write_cookies" packages/protocol/src/validate.ts packages/protocol/tests/*.ts
(no output)

Every inner frame crosses that validator — seal.ts:118 runs validateInnerFrame(parsed) inside openEncryptedFrame, and the extension opens frames at background.ts:1344. So:

  • The request is rejected before dispatch. validateInnerRequest has no write_cookies branch and falls through to the unconditional throw at validate.ts:959. onEncryptedFrame catches it, logs [fetchproxy] decrypt failed, and returns without a response — so handleWriteCookiesRequest is unreachable on the real path and writeCookies() hangs to the verb timeout.
  • Even once that's fixed, the success response is worse. validateInnerResponse throws unknown success-response op at validate.ts:1124 for ok:true, op:'write_cookies', and host.ts:344-352 catches any throw out of openEncryptedFrame and closes the socket — the exact concentrator-wide teardown already documented in the KNOWN_RESPONSE_OPS comment at validate.ts:995-1006.

The ok:false path is fine (KNOWN_RESPONSE_OPS spreads KNOWN_CAPABILITIES, which this PR did update), which is why the gate-refusal tests still look healthy. And the new suites can't catch any of this: packages/server/tests/write-cookies.test.ts:31 mocks sendInnerFrame, so nothing crosses the seal/validate boundary. Details and a suggested branch shape in the inline comment on frames.ts.

🟡 Nits

  1. Partial writes are possible despite the doc claiming otherwise (background.ts:1919-1930) — the three gates refuse wholesale, but the chrome.cookies.set loop can apply cookie 1 and fail on cookie 2, and the error response carries no written list, so the MCP can't tell the jar is half-rotated.
  2. sameSite / storeId passed as explicit undefined (background.ts:1846,1848) while domain / expirationDate are conditionally spread — inconsistent with the rule the function's own doc comment states.
  3. The popup test asserts on popup.ts source text, not rendered output — it passes on the comment alone. The adjacent popup.test.ts:518-535 renders and asserts container.textContent; matching that would actually pin the label. The __testCapabilityDisplay fallback branch is dead code.
  4. docs/PRIVACY.md §2.2 still frames capabilities as read-only — the first state-changing verb belongs in that user-facing table (partly pre-existing: read_dom/graphql/download are missing too).

Open questions (not findings — I couldn't verify these here)

  • CHIPS / partitionKey. ChromeCookie and cookieSetDetailsFor don't carry partitionKey. If a target cookie is partitioned, the same "quietly writes a second cookie" failure this verb exists to prevent would seem to recur across the partitioned/unpartitioned split. Worth a look — I can't confirm Chrome's behaviour from the repo.
  • I couldn't run npm test (dependency install isn't permitted in this environment), so the 1171-passing claim is unverified from my side. Given the validator gap, I'd expect the suite to be green regardless, since nothing in it exercises the seal boundary.

Verdict: fail

chrischall added a commit that referenced this pull request Aug 5, 2026
…cannot run

Auto-review on #211 caught that `packages/protocol/src/validate.ts` was
never updated. Every inbound frame is validated before dispatch, so:

  - requests fell through to the terminal "unknown op" throw. The
    extension's decode catch logs and returns, so the request was
    silently dropped, no response was ever sent, and writeCookies() hung
    to its timeout. handleWriteCookiesRequest was unreachable in
    production.
  - successful responses would throw "unknown success-response op" on
    the MCP side, which the concentrator host answers by closing the
    EXTENSION socket — taking down every other MCP on the bridge, not
    just the caller.

Only successes broke: `ok:false` already validated, because
KNOWN_RESPONSE_OPS derives from KNOWN_CAPABILITIES and the capability
was added. So gate rejections round-tripped cleanly the whole time,
which is exactly why nothing noticed.

The new validators also enforce two things the extension would otherwise
have accepted off the wire: a non-empty `cookies` array (an empty write
was answered with `ok: true, written: []` — a success that wrote
nothing), and an exact cookie name rather than a glob (declared keys may
be globs; a pattern here would reach cookies the pair popup never
displayed).

Adds packages/protocol/tests/write-cookies-wire.test.ts, which round-
trips request and response through sealInnerFrame/openEncryptedFrame.
Verified it catches the original bug: with the validator reverted, 8 of
its tests fail, including both round-trips. Nothing existing covered
this — the server tests stub sendInnerFrame and the extension tests call
the pure resolvers directly, so no test crossed the wire.

Also from the review: chrome.cookies.set resolves null on failure
WITHOUT throwing, so reporting `written` on "didn't throw" alone could
claim a write that never landed — the same silent staleness this verb
exists to prevent. Now checked. And the extension README documents that
write_cookies rides the existing `cookies` permission while adding new
chrome.* surface, matching how `graphql` and `download` are covered.

Refs #202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o2nXwu7tov6j7ciBEpigo
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 6m 39s —— View job


Review — write_cookies (round 2)

  • Read CLAUDE.md + repo conventions
  • Read the full PR diff
  • Verify the validator fix (commit 758b62e)
  • Review packages/server/src/extension-trust.ts (new, undescribed in PR body)
  • Review extension-side gate + cookie set builder
  • Check test coverage
  • Post findings

The validator gap from the last round is properly fixed. validateInnerRequest and validateInnerResponse both learned write_cookies, and packages/protocol/tests/write-cookies-wire.test.ts round-trips request, success response, and gate rejection through the real sealInnerFrameopenEncryptedFrame path — the layer nothing was crossing before. The added refusals go past what was asked for: non-empty cookies, SCOPE_KEY_RE rather than the glob variant on a written name (so a write names one exact cookie even when the declared key was CK*), and no extra fields on either the init or a cookie entry, so attributes can't be smuggled. The chrome.cookies.setnull check and the README bullet also landed.

The verb itself remains well built: the capability gate is generic (background.ts:1391-1408, op string equals capability string), cookieKeys come from the approved trust record via applyGrantedScopeToSession, gate #2 goes through the same glob-aware undeclaredKeys the read path uses, the domain gate is decided on the bare origin, and the shadowing trap is handled with both omission cases tested.

Two 🔴 findings, neither about write_cookies semantics.

🔴 Important

1. packages/server/src/extension-trust.ts is wired to nothing. 199 lines of new security code plus 156 lines of tests, describing a TOFU pin on the extension's identity for #208 — imported by nothing but its own test:

$ rg -n "extension-trust|decideExtensionTrust|readExtensionPin|writeExtensionPin" --glob '!*.md' . | grep -v node_modules
./packages/server/src/extension-trust.ts:…
./packages/server/tests/extension-trust.test.ts:…

host.ts doesn't import it, index.ts doesn't re-export it, and packages/server/package.json exposes exactly one entrypoint — so it's unreachable internally and unavailable to consumers. decideExtensionTrust is never called, so an extension presenting a different identity is accepted exactly as before. It rode in on commit 758b62e, whose message (fix(protocol): validate write_cookies frames) never mentions it, under a PR that would squash-merge as feat(protocol): add write_cookies. Wire it into the hello path in host.ts with tests, or split it into its own PR for #208. (inline)

2. pendingWriteCookies is missing from both cleanup paths every sibling map is in. rejectAllPending (ws-server.ts:3111-3138) rejects all eight other pending maps and not this one, and it's the handler for extension disconnect, for the extension re-entering pairing (where it carries the actionable pair-code message), and for close(). An in-flight writeCookies() therefore waits out the full fetchTimeoutMs and reports a timeout instead of "extension disconnected" — and with fetchTimeoutMs: 0, an explicitly supported opt-out (ws-server.ts:729), _withVerbTimeout arms no timer at all (1690-1691) and the promise never settles. sendInnerFrame's catch (1604-1616) has the same omission, breaking the invariant its own comment states. Two one-line additions. (inline)

🟡 Nits

  • validateInnerRequest's terminal unknown-op message (validate.ts:999-1001) still lists the pre-write_cookies set of nine ops. (inline)
  • sameSite / storeId still passed as explicit undefined while domain / expirationDate use conditional spreads — inconsistent with the rule the function's own doc comment states. (inline)
  • The write loop can still apply part of a batch (now via result === null as well as a throw), and the error carries no written list, so the MCP can't tell the jar is half-rotated — contradicting the "refuses the WHOLE request" doc block. (inline)
  • The popup test still asserts on popup.ts source text, so it passes on the comment alone; __testCapabilityDisplay is dead (rg over packages/extension-core/srcNo matches found). (inline)
  • docs/PRIVACY.md:25 still reads "Depending on the capabilities an MCP server declares and you approve at pair time, Transporter may read:" — the first state-changing capability makes that framing inaccurate rather than merely incomplete (read_dom / graphql / download are missing from that table too, which is pre-existing).

Open questions (not findings, not counted toward the verdict)

  • I could not run npm test or npm run build — there is no node_modules in this checkout and installing needs approval — so the 1174-passing figure is unverified from my side, as is Chrome's actual acceptance of the set payload. The two 🔴 findings are from static tracing with the commands and code quoted above.
  • chrome.cookies.get({url, name}) returns a single cookie when several of that name are in scope (differing path or host-only vs domain). If a site holds both, the overwrite targets whichever get picks, which could reproduce the stale-cookie failure the verb exists to prevent. Same family as the CHIPS/partitionKey question — both belong on the manual post-deploy checklist you already wrote out on this PR.
  • Unrelated curiosity: packages/protocol/src/validate.ts contains a NUL byte (rg reports binary file matches (found "\0" byte around offset 14518)). Pre-existing — git cat-file blob origin/main:packages/protocol/src/validate.ts | rg reports it at the identical offset — so not this PR's, but it makes the file invisible to plain rg searches, which is how the last round's validator gap stayed quiet.

Verdict: fail
· branch feat/write-cookies

Comment thread packages/server/src/extension-trust.ts Outdated
*
* So this module is the other half of `trustedMcps`: first contact is trusted
* and remembered (TOFU, matching what the extension does), and a DIFFERENT
* identity afterwards is refused rather than silently accepted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 This module is wired to nothing — the pin it describes is never read, written, or checked on any connection path.

199 lines of new security code (plus 156 lines of tests) arrived in commit 758b62e, whose message is entirely about validating write_cookies frames and never mentions it:

$ git show --stat 758b62e
    fix(protocol): validate write_cookies frames, without which the verb cannot run
 packages/extension-chrome/README.md                |   1 +
 packages/extension-core/src/background.ts          |  12 +-
 packages/protocol/src/validate.ts                  |  52 ++++++
 packages/protocol/tests/write-cookies-wire.test.ts | 144 +++++++++++++++
 packages/server/src/extension-trust.ts             | 199 +++++++++++++++++++++
 packages/server/src/identity.ts                    |  14 +-
 packages/server/tests/extension-trust.test.ts      | 156 ++++++++++++++++

Nothing imports it:

$ rg -n "extension-trust|decideExtensionTrust|readExtensionPin|writeExtensionPin|allowNewExtensionIdentity" --glob '!*.md' . | grep -v node_modules
./packages/server/src/extension-trust.ts:68:export const TRUST_NEW_EXTENSION_ENV = …
…
./packages/server/tests/extension-trust.test.ts:6:  decideExtensionTrust,

Every hit is the module itself or its own test file. host.ts does not import it (rg -n "^import" packages/server/src/host.tsidentity.js, session.js, session-ready.js, build-server-hello.js, and the protocol/ws packages — no extension-trust.js), and packages/server/src/index.ts does not re-export it, while packages/server/package.json exposes exactly one entrypoint ("." → ./dist/index.js). So it is unreachable internally and unavailable to consumers.

Concretely, the doc comment above says:

So this module is the other half of trustedMcps: first contact is trusted and remembered (TOFU, matching what the extension does), and a DIFFERENT identity afterwards is refused rather than silently accepted.

None of that happens. decideExtensionTrust is never called, so an extension presenting a different identity is still accepted exactly as it was before this PR — the #208 asymmetry is unchanged. Shipping it in this state means the npm package carries a security control that reads as active, under a squash-merge whose title is feat(protocol): add write_cookies.

Two clean resolutions:

  • Wire it in — call readExtensionPin / decideExtensionTrust / writeExtensionPin from the extension-hello path in host.ts (around the sessionSig verification at host.ts:270-273), with tests through that path; or
  • Split it out — drop it from this PR and land it as its own feat(security): PR for MCP side does not pin the extension's identity — freshness without continuity #208, where the design gets reviewed and released under a title that names it.

Either way it shouldn't ride along inside a fix(protocol): validate write_cookies frames commit.

Fix this →

>();
// 1.12.0+: write-cookies awaiters resolve the list of names actually
// written, so a caller can confirm rather than assume.
private pendingWriteCookies = new Map<

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 pendingWriteCookies is missing from both cleanup paths every sibling map is in — a disconnect mid-write never rejects the caller.

$ rg -n "pendingReadCookies|pendingWriteCookies|pendingCapture\b" packages/server/src/ws-server.ts
1608:        this.pendingReadCookies.delete(id);
1610:        this.pendingCapture.delete(id);
…
3122:    for (const cb of this.pendingReadCookies.values()) {
3125:    this.pendingReadCookies.clear();
3128:    for (const { reject } of this.pendingCapture.values()) reject(err);
3129:    this.pendingCapture.clear();

pendingWriteCookies appears only at its declaration, its .set (2201), its _withVerbTimeout hand-off (2204), and its response handler (3071-3073).

1. rejectAllPending (ws-server.ts:3111-3138) rejects pending, pendingReadCookies, pendingStorage, pendingCapture, pendingRedirect, pendingIdb, pendingDownload, pendingGraphql — all eight — and not pendingWriteCookies. It's called on extension disconnect (1309, 1345, 1369), on the extension re-entering pairing (1315, 1351, with this.pairingErrorMessage(code)), and from close() (3178).

Failure scenario: an MCP calls writeCookies(), the extension's MV3 service worker is evicted (or the user hits re-pair) before the response frame arrives. Every other verb settles immediately with extension disconnected — or, in the pairing case, the actionable "here is your pair code" message the class goes out of its way to surface. writeCookies() instead waits out the full fetchTimeoutMs and reports a FetchproxyTimeoutError against https://<host>, which classifyBridgeError will read as a slow bridge rather than a down one, pointing the consumer at the wrong remedy.

Worse, with fetchTimeoutMs: 0 — an explicitly supported opt-out, per ws-server.ts:729 ("0 means the caller explicitly opted out of the …") — _withVerbTimeout short-circuits at 1690-1691:

const timeoutMs = this.opts.fetchTimeoutMs;
if (timeoutMs === undefined || timeoutMs <= 0) return pending;

No timer is armed, nothing else ever settles the promise, and writeCookies() hangs forever after a disconnect. That's an MCP tool call that never returns.

2. sendInnerFrame's catch (ws-server.ts:1604-1616) deletes the just-registered resolver from all eight sibling maps when the send throws, under a doc comment that states the invariant:

drop the just-registered pending resolver for this id (it lives in exactly one of the op maps — request ids are unique) so it doesn't leak until the server closes

For write_cookies that invariant is now false: a FetchproxySessionNotReadyError out of the send leaves the entry in pendingWriteCookies for the life of the server.

Both are one line each:

// in rejectAllPending, alongside the others:
for (const { reject } of this.pendingWriteCookies.values()) reject(err);
this.pendingWriteCookies.clear();

// in sendInnerFrame's catch:
this.pendingWriteCookies.delete(id);

Fix this →

}
return raw as unknown as InnerFrame;
}
if (raw.op === 'write_cookies') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit — the branch is right, but validateInnerRequest's terminal message (validate.ts:999-1001) still enumerates the pre-write_cookies op list:

throw new ProtocolError(
  `inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download", "graphql_query"; got ${JSON.stringify(raw.op)}`,
);

It's the file's only human-readable inventory of valid ops, and it now under-reports by one. A typo'd write_cookie would produce an error listing nine ops that doesn't include the one the caller nearly typed correctly.

Comment on lines +1846 to +1848
sameSite: existing.sameSite,
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
storeId: existing.storeId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over from the previous round, still open) — sameSite and storeId are declared optional on ChromeCookie (background.ts:1370-1374) but passed unconditionally, so an absent value is emitted as an explicit undefined — the exact pattern this function's own doc comment singles out as wrong two fields above:

Two fields must be OMITTED rather than passed as undefined

domain and expirationDate use conditional spreads; these two don't. Making all four consistent removes the question of whether Chrome distinguishes "absent" from "present and undefined" here, rather than relying on it not doing so:

Suggested change
sameSite: existing.sameSite,
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
storeId: existing.storeId,
...(existing.sameSite === undefined ? {} : { sameSite: existing.sameSite }),
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
...(existing.storeId === undefined ? {} : { storeId: existing.storeId }),

Comment on lines +1925 to +1935
// staleness this verb exists to prevent.
const result = await chrome.cookies.set(cookieSetDetailsFor(url, decl, existing));
if (!result) {
await fail(`failed to write cookie ${decl.name}: chrome.cookies.set returned null`);
return;
}
written.push(decl.name);
} catch (e) {
await fail(`failed to write cookie ${decl.name}: ${String(e)}`);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over, still open — and the new result === null check adds a second way to hit it) — this loop can apply part of the request, which the doc block at background.ts:1849-1856 says it does not:

Three gates, in order, all refusing the WHOLE request rather than applying part of it — a half-written rotation is worse than a failed one

The three gates keep that promise; the write phase doesn't. With {SESSION, REFRESH}, if SESSION sets cleanly and REFRESH returns null or throws, SESSION is already rotated in the jar and the error response carries no written list — so the MCP sees only a failure string, can't tell the jar is half-rotated, and will likely retry the whole batch. The pre-flight existence pass makes this unlikely, not impossible (the store can change between get and set).

Chrome offers no transaction, so the fix is legibility rather than atomicity — name what already landed:

const partial = written.length > 0 ? ` — already written: ${written.join(', ')}` : '';
await fail(`failed to write cookie ${decl.name}: chrome.cookies.set returned null${partial}`);

…and soften the doc block so it claims whole-request refusal for the gates rather than for the verb.

Comment on lines +126 to +137
const display = (mod as unknown as {
__testCapabilityDisplay?: Record<string, { label: string; warn: boolean }>;
}).__testCapabilityDisplay;
// Falls back to reading the source when the map isn't exported for tests.
const { readFileSync } = await import('node:fs');
const src = display
? JSON.stringify(display)
: readFileSync(new URL('../src/popup/popup.ts', import.meta.url), 'utf8');
expect(src).toMatch(/write_cookies/);
expect(src).toMatch(/Overwrite cookies/);
expect(src).toMatch(/signed-in session/);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over, still open) — this asserts on the source text of popup.ts, so it passes as long as those strings appear anywhere in the file, including inside the comment this PR added directly above the map entry (popup.ts:40-42: "'write cookies' alone reads as a sibling…", "can change your signed-in session"). Delete the CAPABILITY_DISPLAY entry, keep the comment, and this test stays green while the user sees nothing.

The adjacent suite already does it properly by rendering — packages/extension-core/tests/popup.test.ts:518-535:

it('renders DOM selectors when read_dom declared', () => {
  
  capabilities: ['fetch', 'read_dom'],
  
  expect(container.textContent).toContain('Read DOM elements');
});

Following that with capabilities: ['fetch', 'write_cookies'] and asserting on container.textContent (plus the warn marker, as the read_cookies case does at popup.test.ts:426) would actually pin the label. The __testCapabilityDisplay branch is dead either way — rg -n "__testCapabilityDisplay" packages/extension-core/src returns nothing.

…cannot run

Auto-review on #211 caught that `packages/protocol/src/validate.ts` was
never updated. Every inbound frame is validated before dispatch, so:

  - requests fell through to the terminal "unknown op" throw. The
    extension's decode catch logs and returns, so the request was
    silently dropped, no response was ever sent, and writeCookies() hung
    to its timeout. handleWriteCookiesRequest was unreachable in
    production.
  - successful responses would throw "unknown success-response op" on
    the MCP side, which the concentrator host answers by closing the
    EXTENSION socket — taking down every other MCP on the bridge, not
    just the caller.

Only successes broke: `ok:false` already validated, because
KNOWN_RESPONSE_OPS derives from KNOWN_CAPABILITIES and the capability
was added. So gate rejections round-tripped cleanly the whole time,
which is exactly why nothing noticed.

The new validators also enforce two things the extension would otherwise
have accepted off the wire: a non-empty `cookies` array (an empty write
was answered with `ok: true, written: []` — a success that wrote
nothing), and an exact cookie name rather than a glob (declared keys may
be globs; a pattern here would reach cookies the pair popup never
displayed).

Adds packages/protocol/tests/write-cookies-wire.test.ts, which round-
trips request and response through sealInnerFrame/openEncryptedFrame.
Verified it catches the original bug: with the validator reverted, 8 of
its tests fail, including both round-trips. Nothing existing covered
this — the server tests stub sendInnerFrame and the extension tests call
the pure resolvers directly, so no test crossed the wire.

Also from the review: chrome.cookies.set resolves null on failure
WITHOUT throwing, so reporting `written` on "didn't throw" alone could
claim a write that never landed — the same silent staleness this verb
exists to prevent. Now checked. And the extension README documents that
write_cookies rides the existing `cookies` permission while adding new
chrome.* surface, matching how `graphql` and `download` are covered.

Refs #202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o2nXwu7tov6j7ciBEpigo
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 2m 46s —— View job


Review — write_cookies (round 3)

  • Read CLAUDE.md + repo conventions
  • Read the full PR diff
  • Verify prior round's 🔴 findings
  • Review protocol validators + frames
  • Review server-side gate (ws-server.ts)
  • Review extension-side gate + cookie builder
  • Check test coverage
  • Post findings

One of the two blockers from the last round is resolved; the other is not.

packages/server/src/extension-trust.ts is gone from the branch entirely — the 199 lines of unwired TOFU-pin code no longer ship under this PR:

$ ls packages/server/src/extension-trust.ts
ls: cannot access 'packages/server/src/extension-trust.ts': No such file or directory

$ rg -n "extension-trust|decideExtensionTrust" --glob '!node_modules' .
(no output)

$ git diff origin/main...HEAD --stat
 CLAUDE.md | 2 +-
 docs/SECURITY.md | 18 ++
 packages/cli/src/args.ts | 4 +-
 …
 16 files changed, 843 insertions(+), 8 deletions(-)

The validator fix from 758b62e also holds up well. validateInnerRequest and validateInnerResponse both learned write_cookies, and packages/protocol/tests/write-cookies-wire.test.ts round-trips request, path-carrying request, success response, and gate rejection through the real sealInnerFrameopenEncryptedFrame path — the layer nothing was crossing before. The added refusals go past what was asked: non-empty cookies, SCOPE_KEY_RE rather than the glob variant on a written name, and no extra fields on either the init or a cookie entry, so domain/expirationDate can't be smuggled past the in-place contract.

The verb itself remains well built: the capability gate is generic, cookieKeys come from the approved trust record (background.ts:9341888), gate #2 goes through the same glob-aware undeclaredKeys the read path uses, the domain gate is decided on the bare origin, the shadowing trap is handled with both omission cases tested, and the chrome.cookies.set → null check landed.

🔴 Important

pendingWriteCookies is missing from both cleanup paths every sibling map is in. Carried over from the last round, unchanged.

$ rg -n "pendingWriteCookies" packages/server/src/ws-server.ts
1030:  private pendingWriteCookies = new Map<
2201:      this.pendingWriteCookies.set(id, { resolve, reject });
2204:    return this._withVerbTimeout(pending, this.pendingWriteCookies, id, `https://${host}`);
3071:    const writeCookiesCb = this.pendingWriteCookies.get(inner.id);
3073:      this.pendingWriteCookies.delete(inner.id);

Declaration, registration, timeout hand-off, response handler — and nothing else. rejectAllPending (3111-3137) rejects all eight siblings and not this one, so an in-flight writeCookies() doesn't settle on extension disconnect / re-pair / close(); it waits out fetchTimeoutMs and reports a timeout instead of "extension disconnected" — or, with the supported fetchTimeoutMs: 0 opt-out (729), _withVerbTimeout arms no timer at 1690-1691 and the promise never settles. sendInnerFrame's catch (1604-1616) has the same omission, breaking the invariant its own doc comment states. Two one-line additions. (inline)

🟡 Nits

  • validateInnerRequest's terminal unknown-op message (validate.ts:999-1001) still lists the pre-write_cookies set of nine ops. (inline)
  • sameSite / storeId still passed as explicit undefined while domain / expirationDate use conditional spreads — inconsistent with the rule the function's own doc comment states. (inline)
  • The write loop can still apply part of a batch (via result === null or a throw), and the error carries no written list, so the MCP can't tell the jar is half-rotated — contradicting the "refuses the WHOLE request" doc block. (inline)
  • The popup test asserts on popup.ts source text, so it passes on the comment alone; __testCapabilityDisplay is dead (rg over src + tests hits only the test's own type annotation). (inline)
  • docs/PRIVACY.md:25 still reads "Depending on the capabilities an MCP server declares and you approve at pair time, Transporter may read:" — the first state-changing capability makes that framing inaccurate rather than merely incomplete. (inline) (That inline comment renders the line with **read**; the file has it unbolded — the line is otherwise as quoted.)

Open questions (not findings, not counted toward the verdict)

  • I could not run npm test or npm run buildls node_modules/.bin/vitest"No such file or directory", and installing needs approval — so the 1202-passing figure is unverified from my side, as is Chrome's actual acceptance of the set payload. The 🔴 finding is from static tracing with the commands and code quoted above.
  • chrome.cookies.get({url, name}) returns a single cookie when several of that name are in scope (differing path, or host-only vs domain). If a site holds both, the overwrite targets whichever get picks. Same family as the CHIPS/partitionKey question raised earlier — both belong on the manual post-deploy checklist you already wrote out on this PR.

Verdict: fail
· branch feat/write-cookies

@chrischall

Copy link
Copy Markdown
Owner Author

Correct, and it was mine to cause

032774d removes the unrelated code. The PR is now four files:

packages/extension-chrome/README.md                |   1 +
packages/extension-core/src/background.ts          |  12 +-
packages/protocol/src/validate.ts                  |  52 ++++
packages/protocol/tests/write-cookies-wire.test.ts | 144 +++++++++

What happened

I committed with git add -A, which swept up in-progress work sitting untracked in the working tree — packages/server/src/extension-trust.ts, its test, and a change to identity.ts. None of it is mine and none of it belongs in this PR.

"Completely unwired" was exactly right, and the reason is worse than accidental inclusion: the wiring for that module (host.ts, peer.ts, ws-server.ts) was also uncommitted, so git add -A took the module and its test while leaving behind the code that uses it. Had this merged, main would have carried 199 lines of dead security code and a test suite exercising it in isolation — which reads as reviewed, shipped infrastructure rather than someone's half-finished branch.

The files are untouched and remain as working-tree changes in the local repo; I removed them from the commit rather than reverting them.

Verification

Because the working tree still holds that other work, a local test run cannot prove this branch stands alone. I built and tested it in a detached worktree containing only these commits: typecheck clean, 1188 tests pass. (The 1202 I quoted last round included the 14 tests from the swept-in file — so that figure was inflated, and I'd have kept quoting it.)

Process fix

git add -A was wrong for a repo with unrelated work in flight. Staging explicit paths is the fix, and it's what I should have done from the first commit — c87808a happened to be clean only because nothing else was dirty at that moment.

🤖 Generated with Claude Code

>();
// 1.12.0+: write-cookies awaiters resolve the list of names actually
// written, so a caller can confirm rather than assume.
private pendingWriteCookies = new Map<

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Still open from the last round — pendingWriteCookies is in neither cleanup path, unlike all eight sibling maps.

$ rg -n "pendingWriteCookies" packages/server/src/ws-server.ts
1030:  private pendingWriteCookies = new Map<
2201:      this.pendingWriteCookies.set(id, { resolve, reject });
2204:    return this._withVerbTimeout(pending, this.pendingWriteCookies, id, `https://${host}`);
3071:    const writeCookiesCb = this.pendingWriteCookies.get(inner.id);
3073:      this.pendingWriteCookies.delete(inner.id);

Declaration, registration, timeout hand-off, response handler — and nothing else. Compare the sibling:

$ rg -n "pendingGraphql" packages/server/src/ws-server.ts
1057:  private pendingGraphql = new Map<
1614:        this.pendingGraphql.delete(id);      ← sendInnerFrame catch
…
3136:    for (const { reject } of this.pendingGraphql.values()) reject(err);   ← rejectAllPending
3137:    this.pendingGraphql.clear();

1. rejectAllPending (ws-server.ts:3111-3137) rejects pending, pendingReadCookies, pendingStorage, pendingCapture, pendingRedirect, pendingIdb, pendingDownload, pendingGraphql — and not pendingWriteCookies. It's the handler for extension disconnect, for the extension re-entering pairing (where it carries the actionable pair-code message), and for close().

Failure scenario: an MCP calls writeCookies(), the MV3 service worker is evicted (or the user hits re-pair) before the response arrives. Every other verb settles immediately with extension disconnected; writeCookies() instead waits out the full fetchTimeoutMs (default 30_000, ws-server.ts:1172) and reports a FetchproxyTimeoutError, which reads as a slow bridge rather than a down one.

With fetchTimeoutMs: 0 — an explicitly supported opt-out (ws-server.ts:729: "0 means the caller explicitly opted out") — _withVerbTimeout short-circuits at 1690-1691:

const timeoutMs = this.opts.fetchTimeoutMs;
if (timeoutMs === undefined || timeoutMs <= 0) return pending;

No timer is armed and nothing else settles the promise, so writeCookies() never returns after a disconnect.

2. sendInnerFrame's catch (1604-1616) deletes the just-registered resolver from all eight sibling maps, under a doc comment stating the invariant:

drop the just-registered pending resolver for this id (it lives in exactly one of the op maps — request ids are unique) so it doesn't leak until the server closes

For write_cookies that's now false: a throw out of the send (e.g. FetchproxySessionNotReadyError) leaves the entry in pendingWriteCookies for the life of the server.

Two one-line additions:

// rejectAllPending, alongside the others:
for (const { reject } of this.pendingWriteCookies.values()) reject(err);
this.pendingWriteCookies.clear();

// sendInnerFrame's catch:
this.pendingWriteCookies.delete(id);

Fix this →

}
return raw as unknown as InnerFrame;
}
if (raw.op === 'write_cookies') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over, still open) — the new branch is right, but validateInnerRequest's terminal message still enumerates the pre-write_cookies op list (validate.ts:999-1001):

throw new ProtocolError(
  `inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download", "graphql_query"; got ${JSON.stringify(raw.op)}`,
);

It's the file's only human-readable inventory of valid ops and now under-reports by one — a typo'd write_cookie produces an error listing nine ops that omits the one the caller nearly typed correctly.

httpOnly: existing.httpOnly,
sameSite: existing.sameSite,
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
storeId: existing.storeId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over, still open) — sameSite and storeId are declared optional on ChromeCookie (background.ts:1373-1375) but passed unconditionally, so an absent value is emitted as an explicit undefined — the exact pattern this function's own doc comment singles out as wrong two fields above:

Two fields must be OMITTED rather than passed as undefined

domain and expirationDate use conditional spreads; these two don't. Making all four consistent removes the reliance on Chrome not distinguishing "absent" from "present and undefined":

Suggested change
storeId: existing.storeId,
...(existing.sameSite === undefined ? {} : { sameSite: existing.sameSite }),
...(existing.expirationDate === undefined ? {} : { expirationDate: existing.expirationDate }),
...(existing.storeId === undefined ? {} : { storeId: existing.storeId }),

} catch (e) {
await fail(`failed to write cookie ${decl.name}: ${String(e)}`);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over, still open) — this loop can apply part of the request, which the doc block at background.ts:1849-1856 says it does not:

Three gates, in order, all refusing the WHOLE request rather than applying part of it — a half-written rotation is worse than a failed one

The three gates keep that promise; the write phase doesn't. With {SESSION, REFRESH}, if SESSION sets cleanly and REFRESH returns null or throws, SESSION is already rotated in the jar and the error response carries no written list — so the MCP sees a bare failure string, can't tell the jar is half-rotated, and will likely retry the whole batch. The pre-flight existence pass makes this unlikely, not impossible (the store can change between get and set).

Chrome offers no transaction, so the fix is legibility rather than atomicity — name what already landed:

const partial = written.length > 0 ? ` — already written: ${written.join(', ')}` : '';
await fail(`failed to write cookie ${decl.name}: chrome.cookies.set returned null${partial}`);

…and soften the doc block so whole-request refusal is claimed for the gates rather than for the verb.

expect(src).toMatch(/write_cookies/);
expect(src).toMatch(/Overwrite cookies/);
expect(src).toMatch(/signed-in session/);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over, still open) — this asserts on the source text of popup.ts, so it passes as long as those strings appear anywhere in the file, including inside the comment this PR added directly above the map entry (popup.ts:40-42: "'write cookies' alone reads as a sibling…", "can change your signed-in session"). Delete the CAPABILITY_DISPLAY entry, keep the comment, and this test stays green while the user sees nothing.

The __testCapabilityDisplay branch is dead — the symbol exists only in this test file:

$ rg -n "__testCapabilityDisplay" packages/extension-core/src packages/extension-core/tests
packages/extension-core/tests/write-cookies.test.ts:127:      __testCapabilityDisplay?: Record<string, { label: string; warn: boolean }>;
packages/extension-core/tests/write-cookies.test.ts:128:    }).__testCapabilityDisplay;

The adjacent suite already does this properly by rendering (packages/extension-core/tests/popup.test.ts:518-535); following that with capabilities: ['fetch', 'write_cookies'] and asserting on container.textContent (plus the warn marker, as the read_cookies case does at popup.test.ts:426) would actually pin the label.

Comment thread docs/SECURITY.md

**Residual risk:** A user who approves a pair with `read_cookies` is giving the MCP a powerful read primitive for the declared domains. The popup tries to make that visible; the trust record forces re-approval on change. There is no further defense — if you don't trust the MCP, don't approve the pair.

### T-cookie-write — `write_cookies` capability misuse

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (carried over, still open) — docs/PRIVACY.md §2.2 is the user-facing counterpart to this section and is framed entirely as reads:

$ sed -n 25p docs/PRIVACY.md
Depending on the capabilities an MCP server declares and you approve at pair time, Transporter may **read**:

followed by a table of read_* / capture_request_header. write_cookies is the first capability that changes browser state, which makes that framing inaccurate rather than merely incomplete. (Partly pre-existing — read_dom, graphql, and download are missing from that table too.)

…ss of gap

`writeCookies()` registered an awaiter but was missing from both cleanup
paths every sibling map is in. On extension disconnect, re-pair, or
close(), an in-flight call waited out `fetchTimeoutMs` and reported a
timeout instead of "extension disconnected" — and with the supported
`fetchTimeoutMs: 0` opt-out, `_withVerbTimeout` arms no timer, so the
promise never settled at all.

Two one-line additions fix this instance. The more useful part is
`packages/server/tests/pending-maps-drained.test.ts`, which asserts the
invariant across every map by DISCOVERY rather than by name: it walks the
instance for `pending*` Maps, seeds each, and requires `rejectAllPending`
to settle-and-clear all of them and `sendInnerFrame`'s catch to drop the
id from all of them. The next verb to add a map is covered the moment it
exists, with no new test to remember. Verified it fails by name on the
unfixed code ("pendingWriteCookies was not cleared by rejectAllPending").

Per-verb tests could never have caught this — each exercises its own map,
so the one map nobody wired up is exactly the one nothing tests.

Also from review:

  - validateInnerRequest's unknown-op message still listed nine ops
  - cookieSetDetailsFor passed `sameSite`/`storeId` as explicit undefined
    while `domain`/`expirationDate` used conditional spreads, against the
    rule its own doc states; all four are conditional now
  - a mid-batch write failure reported a bare error, so an MCP could not
    tell a jar that is half-rotated from one untouched, and would retry
    the whole batch. The gates are all-or-nothing but the writes cannot
    be (there is no transaction over chrome.cookies), so the error now
    names what already landed and the doc says so instead of implying
    atomicity
  - the popup test asserted on popup.ts SOURCE TEXT, so it passed on a
    comment containing the right words. CAPABILITY_DISPLAY is exported
    and the test now reads the actual label, plus asserts it does not
    read as another "Read …" verb
  - docs/PRIVACY.md said Transporter "may read"; the first
    state-changing capability makes that inaccurate, not just incomplete

Refs #202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o2nXwu7tov6j7ciBEpigo
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 5m 25s —— View job


Review — write_cookies (round 4)

  • Read CLAUDE.md + repo conventions
  • Read the full PR diff
  • Verify prior round's 🔴 (pendingWriteCookies drain)
  • Review the new pending-maps-drained.test.ts
  • Re-check all five carried-over nits
  • Post findings

The 🔴 from round 3 is fixed, and fixed at the level of the class rather than the instance. Both cleanup paths now carry the map:

$ git show 3c12399 -- packages/server/src/ws-server.ts
@@ -1607,6 +1607,7 @@         this.pendingStorage.delete(id);
+        this.pendingWriteCookies.delete(id);
@@ -3125,6 +3126,8 @@     this.pendingStorage.clear();
+    for (const { reject } of this.pendingWriteCookies.values()) reject(err);
+    this.pendingWriteCookies.clear();

packages/server/tests/pending-maps-drained.test.ts is the better half of it: it discovers pending* Maps on the instance instead of naming them, seeds each with a resolver of either shape, and requires rejectAllPending to settle and clear all of them (the settled counter is the part that matters — clearing without settling hangs the caller just as badly) and sendInnerFrame's catch to drop the id from all of them. The toBeGreaterThanOrEqual(9) guard keeps it from passing vacuously if discovery ever breaks. I checked that discovery-by-name is currently exact — every pending* field on the class is an awaiter map, so there's no non-awaiter map that would produce a false failure:

$ grep -n "  private pending" packages/server/src/ws-server.ts
1012:  private pending = …            1035:  private pendingCapture = …
1017:  private pendingReadCookies = … 1040:  private pendingRedirect = …
1024:  private pendingStorage = …     1045:  private pendingIdb = …
1030:  private pendingWriteCookies = … 1050:  private pendingDownload = …
                                      1057:  private pendingGraphql = …

Nine maps, nine drained.

All five carried-over nits are closed too, and mostly not by the minimum: the unknown-op message lists eleven ops; sameSite/storeId are conditional spreads with a comment explaining why all four now follow one rule; partialWriteError names what already landed and the doc block no longer implies atomicity it can't provide; the popup test imports the now-exported CAPABILITY_DISPLAY and asserts the real label plus not.toMatch(/^read/i); docs/PRIVACY.md §2.2 is reframed as "may read — and, for one capability, modify" with a write_cookies row.

The verb itself continues to hold up on re-read. The capability gate is generic (background.ts:1395, op string equals capability string), gate #2 runs the same glob-aware undeclaredKeys as the read path, the domain gate is decided on the bare origin, cookieUrlFor is the same helper the read path uses, and the gate-#2 wording (cookie keys not in declared set: …) matches SCOPE_REJECTION = /not in declared/ at ws-server.ts:620, so a write scope rejection surfaces as FetchproxyScopeError with its re-pair hint rather than a bare protocol error — the thing CLAUDE.md asks for.

🟡 Nits

  1. handleWriteCookiesRequest's doc block was orphaned by 3c12399. partialWriteError and its own JSDoc were spliced in between the verb's ~35-line doc (ending background.ts:1891) and the function it documents (1906), so the gate documentation now attaches to a string formatter and the handler hovers empty. Moving the helper above the block fixes it without touching prose. (inline)
  2. The cookie names are still surfaced only under a "Read cookies" heading (popup.ts:693), even when write_cookies is granted — and that sub-list is the only place the individual names appear, so the user joins two parts of the card to learn which cookies are overwritable. Varying the heading on capabilities.includes('write_cookies') is a one-liner. (inline)

Open questions (not findings, not counted toward the verdict)

  • I could not run npm test or npm run buildls node_modules"No such file or directory", and installing needs approval — so the 1188-passing figure is unverified from my side, as is Chrome's actual acceptance of the set payload. Everything above is static tracing with the commands and code quoted.
  • On a failed batch, the partial state travels only as prose inside the error string; the ok:false frame has no structured written field the way the success frame does. Consistent with every other verb's error path, so not a nit — but if a consuming MCP ever needs to act on it programmatically, it'll be regex-parsing a sentence.
  • chrome.cookies.get({url, name}) returns one cookie when several of that name are in scope (differing path, or host-only vs domain), and neither ChromeCookie nor cookieSetDetailsFor carries CHIPS partitionKey. Both belong on the manual post-deploy checklist you already wrote out on this PR — item (1) there covers the observable symptom.

🟣 Pre-existing (not this PR's)

  • docs/PROTOCOL.md documents none of the newer verbs — grep -n "write_cookies\|read_dom\|download\|read_indexed_db" docs/PROTOCOL.md returns nothing, so this is standing drift, not something this PR owes.
  • packages/protocol/src/validate.ts is treated as binary by grep because DOM_SELECTOR_RE (line 389) contains literal control bytes in its character class. Identical on origin/maingit show origin/main:… | grep -a -n -o -P '[\x00-\x08\x0b\x0c\x0e-\x1f]' reports the same line — so it predates this branch, but it is the mechanism that hid the round-1 validator gap, and -a is the workaround for anyone searching that file.

Verdict: warn
· branch feat/write-cookies

@chrischall

Copy link
Copy Markdown
Owner Author

3c12399 — the 🔴 and all five nits.

The blocker

Both cleanup paths now drain pendingWriteCookies. Two lines. The fetchTimeoutMs: 0 case you traced is the real severity here: not a slow failure but a promise that never settles.

The more useful part is packages/server/tests/pending-maps-drained.test.ts, which asserts the invariant by discovery rather than by name — it walks the instance for pending* Maps, seeds each, and requires rejectAllPending to settle-and-clear all of them and sendInnerFrame's catch to drop the id from all of them. The next verb to add a map is covered the moment it exists, with no new test to remember.

Verified it fails on the unfixed code, naming the culprit:

AssertionError: pendingWriteCookies was not cleared by rejectAllPending
AssertionError: pendingWriteCookies still holds the id after a failed send

It also asserts it found ≥9 maps, so it can't pass vacuously if the discovery ever breaks, and it checks awaiters are settled rather than merely dropped — clearing a map without settling hangs the caller just as badly.

Worth naming why this survived three rounds: every per-verb test exercises its own map, so the one map nobody wired up is exactly the one nothing tests. Same shape as the validator gap — a per-op test suite can't see a per-op omission.

Nits

  • Unknown-op message — now lists write_cookies.
  • sameSite / storeId — all four optional attributes are conditional spreads now. The rule the doc states is "omit, don't pass undefined"; two of four following it was worse than either convention, and it insulates against a future Chrome that distinguishes absent from undefined.
  • Partial batch — you're right that the doc claimed more than the code delivered. There is no transaction over chrome.cookies, so the gates are all-or-nothing but the writes cannot be. The error now names what already landed (already written: A, B — the jar is partially updated) versus no cookies were changed, and the doc block says so instead of implying atomicity. An MCP that believes nothing was written will retry a batch against a half-updated jar; it should be able to tell those apart.
  • Popup test — this one was the worst of the five. It matched on popup.ts source text, so it would have passed on a comment containing the right words while the label said anything at all. CAPABILITY_DISPLAY is now exported and the test reads the actual entry, plus asserts the label doesn't read as another Read … verb. The dead __testCapabilityDisplay branch is gone.
  • docs/PRIVACY.md — "may read" → "may read — and, for one capability, modify", with a table row for write_cookies and the constraint that it cannot create cookies or reach anything the MCP couldn't already read.

Verification

Built and tested in a detached worktree containing only this branch's commits — typecheck clean, 1192 tests pass. Local runs are still unreliable for this branch because the working tree holds unrelated in-progress work, which is what produced the inflated 1202 figure two rounds ago.

Staging is now explicit per path rather than git add -A. ws-server.ts in particular is dirty with that other work, so my two lines were staged by reconstructing them on top of HEAD and restoring the working copy afterwards — the staged diff for that file is exactly 1 file changed, 3 insertions(+).

Your two open questions

Both go on the manual post-deploy checklist rather than being answered here, since neither is decidable from static reading:

  • chrome.cookies.get({url, name}) with several cookies of that name in scope (differing path, or host-only alongside domain-scoped) returns one, and the overwrite follows whichever it picked. A site holding both is exactly where an in-place overwrite could land on the wrong one.
  • CHIPS / partitionKey — a partitioned cookie is a distinct entry, and neither the read nor the write carries a partition key today.

Added both to the checklist comment above.

* this verb exists to prevent. `hostOnly` decides whether `domain` may be sent
* at all: Chrome rejects `domain` on a host-only cookie.
*/
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit (new in 3c12399) — inserting partialWriteError here orphaned the verb's doc block.

Lines 1857–1891 are the ~35-line JSDoc for handleWriteCookiesRequest (the three gates, the all-or-nothing note, the chrome.cookies.set shadowing trap). partialWriteError and its own JSDoc were spliced in between that block and the function it documents:

1891  */                                    ← end of handleWriteCookiesRequest's doc
1892 /**                                    ← partialWriteError's doc starts
…
1899 function partialWriteError(…)
…
1906 async function handleWriteCookiesRequest(   ← now has no doc comment attached

Two consecutive JSDoc blocks: the tooling-visible one for partialWriteError is the second, and everything above it now attaches to partialWriteError too (or to nothing). Hovering handleWriteCookiesRequest in an editor shows nothing, and the gate documentation — which is the security argument for this verb — reads as documentation for a string formatter.

Moving the partialWriteError definition (with its own doc) above line 1857 restores the pairing without touching any prose.

Fix this →

// reading it. Labelled to say so outright — 'write cookies' alone reads
// as a sibling of the reads above, and it is not one.
write_cookies: {
label: 'Overwrite cookies it can already read (can change your signed-in session)',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit — this label is right, but the cookie names are still surfaced only under a heading that says "Read cookies", even when write_cookies is granted.

renderPendingPair itemises the scope at popup.ts:693:

appendScopeSubList(dl, 'Read cookies', pending.cookieKeys);

appendScopeSubList renders on non-empty cookieKeys regardless of capability (popup.ts:206-218), so the list does appear for a write-declaring MCP — but it's labelled as a read, and it is the only place in the card the individual names appear. The user has to join the capability line here with a sub-list two sections down to work out which cookies are overwritable.

That matters more for this capability than for the reads, per the comment directly above that call (popup.ts:690-692):

the user approves the exact set of names, not just "this MCP can read storage" — so the pair popup MUST show them

Since the write scope is by construction identical to cookieKeys, the cheapest fix is to vary that heading:

appendScopeSubList(
  dl,
  pending.capabilities.includes('write_cookies') ? 'Read + overwrite cookies' : 'Read cookies',
  pending.cookieKeys,
);

Worth pinning with a rendering-based assertion in the style of popup.test.ts:518-535 rather than another map lookup.

Fix this →

@chrischall chrischall added the ready-to-merge Owner has reviewed; arm auto-merge to land when CI is green label Aug 5, 2026
@chrischall
chrischall enabled auto-merge (squash) August 5, 2026 23:06
@chrischall
chrischall merged commit b2557c2 into main Aug 5, 2026
15 checks passed
@chrischall
chrischall deleted the feat/write-cookies branch August 5, 2026 23:07
chrischall added a commit that referenced this pull request Aug 5, 2026
…itable cookies as writable (#215)

Closes #212 — the two round-4 findings on #211, which merged before they
could be addressed on that PR.

## Orphaned doc block

`partialWriteError` was spliced in between `handleWriteCookiesRequest`'s
doc block and the function, so ~40 lines documenting the verb's four
gates, its all-or-nothing refusal and the cookie-shadowing trap ended up
describing a five-line string helper. The helper moves above the block;
nothing else changes.

Worth naming the cause: I was editing source by matching a string
anchor. The anchor matched, the edit applied, the build passed, the
tests passed — and the result was still wrong, because nothing verifies
that prose sits on the function it describes.

## Cookie names headed "Read cookies" while granting a write

`popup.ts` listed `cookieKeys` under `'Read cookies'` unconditionally.
That sub-list is the **only** place those names appear, so a user
granting `write_cookies` saw the affected cookies filed under a read
verb — directly contradicting the capability line above it, which says
*"Overwrite cookies it can already read (can change your signed-in
session)"*.

It now reads **"Read and overwrite cookies"** when the capability is
present. The user sees one consistent story at the moment they decide,
which is the whole point of itemising the names in the first place.

Pinned by a **rendering** test — `renderPopup` into a container, then
read the actual `<dt>` — not a source-text match. The earlier popup test
in this series asserted on source text and would have passed on a
comment containing the right words; not worth repeating that mistake two
PRs later.

Verified the test fails against the unfixed heading before fixing it.

## Note on #212

I reopened it. `Closes #212` went into #211's body when only the round-3
items were resolved, and round 4 then added these two to the same issue
— so the merge closed it with both outstanding. `Closes` belongs on a PR
only once every item is genuinely done, and I added it a round early.

1194 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012o2nXwu7tov6j7ciBEpigo

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
chrischall added a commit that referenced this pull request Aug 6, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.0.0](v1.11.0...v2.0.0)
(2026-08-06)


### ⚠ BREAKING CHANGES

* **protocol:** bind the ephemeral key into the ready signature
([#222](#222))

### Features

* **protocol:** add write_cookies, the one verb that can repair a
rotated session
([#211](#211))
([b2557c2](b2557c2))
* **protocol:** bind the ephemeral key into the ready signature
([#222](#222))
([c13aeed](c13aeed))
* **server:** let a request name the tab that relays it
([#207](#207))
([c5d3f4d](c5d3f4d))
* **server:** pin the extension's identity, and verify it on the peer
path ([#213](#213))
([0eeced7](0eeced7))


### Bug Fixes

* **cli:** let a real filesystem error be itself, not "no extension pin"
([#221](#221))
([c87a864](c87a864)),
closes [#220](#220)
* **cli:** validate --via-tab before connecting, like the request URL
([#210](#210))
([959fcc5](959fcc5))
* **extension:** reattach the write_cookies doc block, and name the
writable cookies as writable
([#215](#215))
([2730c4a](2730c4a))
* **extension:** use the guarded caps local for the cookie heading
([#217](#217))
([f95c832](f95c832))
* **server:** release only our own extension claim, and stop guessing
scoped names
([#219](#219))
([3d90a64](3d90a64)),
closes [#218](#218)
* **server:** type no-tab rejections so they stop reading as version
mismatches ([#205](#205))
([dc30bd9](dc30bd9))


### Refactor

* **server:** drop the concatBytes imports the signature change orphaned
([#224](#224))
([4985ba7](4985ba7)),
closes [#223](#223)

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review Trigger Claude + Copilot review on this PR ready-to-merge Owner has reviewed; arm auto-merge to land when CI is green

Projects

None yet

1 participant