Build1 publisher3 min readPublished
A Safari MCP server minted capability tokens its own cold start would throw away
One cached identifier was enough to retire a browser tab permanently, because every recovery path in the ownership model also demanded the proof of ownership that had just gone missing.
The Engineer · Build desk

What happened
- A scheduled run of a Safari MCP server opened a tab, received a capability token for it, and 33 seconds later had the next command refused as a forged, stale or ambiguous receipt.
- The extension worker had cached the browser-session epoch, kept stamping new tokens with the old value after it rotated in storage, and discarded all of them on its next cold start.
- A counter meant to catch exactly that rotation is compared at six call sites, and the only line that touches it is the declaration setting it to zero.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Every verb in this ownership model needs proof of ownership, so a session that loses its proof cannot hand the resource back, and the tab stays allocated for as long as the browser runs.
- exposure The window that produces bad tokens opens right after a daemon restart, which is when scheduled and unattended runs are the likely caller and no operator is watching the tab it abandons.
- decision Reviewing a guard like this now means checking that some line writes the variable, because a comparison plus a hand-written error message only proves someone thought about the case.
The mint path asks `_ensureBrowserSessionEpoch` for the epoch. That function throws "Browser session storage is unavailable; refusing durable tab authority" when session storage is missing, then tests the in-memory copy against `/^[a-f0-9]{36}$/` and returns it on a match [12]. The test only checks the shape of the cached value; storage is not consulted [3]. Session storage is the authority for the epoch, and the worker reads it once per lifetime [11][13].
A daemon restart plus an extension re-handshake rotates the epoch in storage while the worker is still alive, so the worker goes on stamping fresh tokens with the retired epoch and persisting them that way [14]. Nothing on the write path reads storage, so the stale stamping is silent. The rejection arrives from the one path that does read it. On cold start the worker rehydrates its tables and keeps only receipts whose envelope version and `browserEpoch` match the current values [15]. Every stored record carried the retired epoch, so the whole array was discarded and the in-memory tables were repopulated from an empty set [16]. Two tabs created five seconds apart were refused together [17]. The cold start sets the time of death; the age of the token has nothing to do with it [2].
Two hypotheses died before the code got read. Minting a token, opening a second tab, then using the first token worked fine [7]. Switching to a tab by index and then running a command with no token at all also worked [8]. Timing was the only thing every failure shared: the first tab creation inside the restart window took 22 seconds while the extension's background worker cold-started, and anything opened after the daemon had been up for a couple of minutes behaved perfectly [10][9]. The developer, who publishes as achiya-automation on dev.to, wrote of the refused token: "Forged. By me. For a tab I had just opened, four lines earlier." [21]
The codebase anticipated this failure. A counter exists whose job is to notice that the browser-session identity changed underneath an in-flight operation, and it is compared at six call sites across three functions: 2885, 2896 and 2920 in `_ensureBrowserSessionEpoch`, 3043 in `_hydrateOwnedTabs`, 3075 and 3095 in `_persistOwnedTabs` [18]. Each comparison guards a real invariant and carries its own hand-written message, including "Browser-session identity changed during initialization." [19]. Line 2819 reads `let _browserEpochGeneration = 0;`, and nothing anywhere increments it [20]. Six comparisons against a constant always compare equal, so all six error paths are unreachable [1]. A grep for assignment or increment returns one hit, and the hit is the declaration [20].
The epoch scheme itself is sound, and the author says as much, calling it "Good design." [22] The gap is the recovery path. The token could not be rotated, switching to the tab by index was refused as "not opened by this MCP session", and closing it was refused as well [4]. The last refusal is defensible on the author's own reasoning: closing cannot be undone, and ownership could no longer be proven [5]. That reasoning covers close. It does not cover a verb that drops the server's claim on a tab without touching the page, and such a verb can fail closed safely, because the worst outcome is that the caller loses authority it already cannot exercise. Instead the tab sat open and unusable while the agent reopened its work somewhere else [6].
The 22-second cold start is a number about one extension worker on one machine [10]. The pattern transfers wherever three conditions hold together: an identifier that can rotate while a process stays alive, a mint path that caches that identifier, and a verification path that reads the durable copy. Any tool server that hands out handles before a daemon restart and re-verifies them afterwards has the same window.
What to watch
- Whether the fix increments _browserEpochGeneration or drops the in-memory cache and re-reads session storage on every mint.
- Whether the server gains a disown or release verb a session can call without proving ownership of the tab.
- Whether the same cache-once epoch pattern shows up in other MCP browser extensions that persist receipts.