mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
A denial or a cancel is a final answer to an authorization request, but `completeOAuthCallback` treated it as merely a failed callback: the row in `tool_oauth_states` survived, so the `state` the user just refused stayed completable for the rest of its 10-minute TTL by anyone who could produce a code. Any linked `request_confirmation` also stayed `pending`, so the board kept offering an authorization link for a flow the user had already declined and the requesting agent never learned the answer. Route every terminal callback — success, denial, cancel — through one `consumeOAuthState` helper. Two properties pull in opposite directions and both matter: - a stranger's callback must not *consume* the request, so the row is loaded and bound to the caller (expiry, actor type/id, session, or the subject user) before anything is deleted, and a failed binding check leaves the victim's flow live and completable; - a replayed callback must not *complete* the request, so the delete is the single statement that decides ownership — `DELETE … RETURNING` hands the row to exactly one of two concurrent callbacks and the loser is told the state is spent rather than exchanging a code against it. A denial then resolves any linked interaction as `rejected`, scoped to a still-`pending` row so a board user who answered the card directly keeps their own answer, with a Paperclip-authored reason rather than the provider's. The message and its `providerError` label are PAP-17108's, unchanged. Five regression tests, both negative-controlled: a code obtained before a denial can no longer be exchanged; an unrecognized cancel code behaves the same; another session's or another user's denial is refused without spending the request, which the owner then completes; the board's prompt ends `rejected`; and two simultaneous callbacks produce exactly one token exchange. That last one needs a second database handle — this driver pipelines everything issued through one handle, which serializes the two callbacks and makes a non-atomic delete pass — and it has to connect before racing, or the first callback wins uncontested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>