fix: an OAuth connection can no longer be pinned to read forever (#107) - #108
Merged
Conversation
An OAuth connection was pinned to saddle:read at consent time and could never be raised. get_tier() returns min(site tier, credential ceiling), so a site set to "Managing the site" still ran every ChatGPT call at read and tools/list withheld every write tool — 83 of 160 offered, with the agent able to draft an SEO title and description and unable to save it. Three places named saddle:read as a constant. normalize_scope() fell back to it when the client sent no scope, and ChatGPT sends none: it registers dynamically and starts the flow without a scope parameter, so that was the live cause. The 401 challenge advertised it regardless of the site's level. The consent screen rendered Allow/Deny with no control that could grant more than was asked for. Reconnecting produced an identical grant. Now: tier_to_scope()/site_scope() derive the scope from the site's own level; the challenge advertises it; the authorize endpoint falls back to it ONLY when the client sent no scope at all, because widening a request a client actually made is both a spec violation and a safety regression; the consent screen offers the levels up to the site tier and clamps the posted choice server-side; and set_grant_scope() plus POST /oauth-connections/<id> repair a connection that already exists. The ceiling now takes the lower of the token's scope and its grant's, so lowering a connection's level lands on the next request instead of whenever its hour-long access token happens to expire. Raising still needs the token rewritten, which is the safe direction to be strict in. $grant was renamed $token_record because it never held a grant, and mistaking one for the other is exactly what makes this fail quietly. Also hardens update_settings(), which read JSON params only: a form-encoded tier passed the enum, passed can_manage, returned 200 and saved nothing. Refs #107
Connect now carries a level picker per signed-in app, capped at the site tier, so the connection that shipped at read can be raised without a reconnect — which would not have helped anyway, since an app that requests no scope comes back at the same level every time. Permissions names any connection sitting below the level saved there. That gap was invisible from the one screen where someone sets the level, which is how "I set this to Managing the site and my app still can't save anything" happens. The apply-bar note said already-connected apps keep the old tool list "until they reconnect". Wrong for apps that sign in themselves: reconnecting re-runs consent and re-grants the same level. They need a refresh, not a reconnect. Refs #107
The pot had gone stale again — it was generated against 1.0.0-rc3 and was missing most of a release's worth of msgids, not only this change's. Refs #107
4 tasks
ifahimreza
added a commit
that referenced
this pull request
Aug 17, 2026
ifahimreza
added a commit
that referenced
this pull request
Aug 24, 2026
The function ended with verbatim copies of two branches it had already run. Both conditions are character-identical to the earlier ones, the earlier ones always return, and nothing between them touches $required, $gate, or any state either condition reads — so the copies could never execute. Worth deleting rather than ignoring, because the duplicate tier block is a WORSE version of the one that runs: it omits the saddle_insufficient_scope branch. If it ever became reachable it would send an OAuth-scope-limited agent to raise the site's access level, which is the wrong screen — the exact bug #108 fixed. A stale copy of a corrected code path sitting next to the corrected one is how that comes back. No behaviour change and no new test: unreachable code cannot change behaviour, and the proof nothing moved is capabilities-test.php's existing gate-ordering coverage staying green. 612 tests, 0 lint errors. Closes #151
3 tasks
ifahimreza
added a commit
that referenced
this pull request
Aug 24, 2026
…() (#152) The function ended with verbatim copies of two branches it had already run. Both conditions are character-identical to the earlier ones, the earlier ones always return, and nothing between them touches $required, $gate, or any state either condition reads — so the copies could never execute. Worth deleting rather than ignoring, because the duplicate tier block is a WORSE version of the one that runs: it omits the saddle_insufficient_scope branch. If it ever became reachable it would send an OAuth-scope-limited agent to raise the site's access level, which is the wrong screen — the exact bug #108 fixed. A stale copy of a corrected code path sitting next to the corrected one is how that comes back. No behaviour change and no new test: unreachable code cannot change behaviour, and the proof nothing moved is capabilities-test.php's existing gate-ordering coverage staying green. 612 tests, 0 lint errors. Closes #151
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #107
What
An app that signs in through Saddle's OAuth screen — ChatGPT is the only client that must — was granted
saddle:readonce, at consent, and could never be raised. On a site set to Managing the site the connector still ran atread, was offered 83 of 160 tools, and could draft an SEO title and description with no way to save it. Reconnecting produced an identical grant.You now pick the level on the approval screen, and you can change it afterwards for an app that is already connected.
Why
Saddle_Capabilities::get_tier()returnsmin(site tier, credential ceiling). The ceiling wasreadbecause three separate places namedsaddle:readas a constant:normalize_scope()fell back toDEFAULT_SCOPEwhen the client sent no scope. ChatGPT sends none: it registers via DCR and starts the flow without ascopeparameter at all (OpenAI developer community).Saddle_OAuth_Bearer::challenge()advertised it inWWW-Authenticateregardless of the site's level, so a spec-following client was told to ask for read-only.filter_adapter_tools_list()then dropped every ability above the ceiling at dispatch, which is why the tools vanished rather than failing loudly. Application-Password clients carry no ceiling and were never affected.How
tier_to_scope()/site_scope()derive a cumulative scope string from the site's own level. The challenge advertises it.normalize_scope( $requested, $fallback )— the authorize endpoint passessite_scope()only when the client sent no scope at all. An explicitsaddle:readis still granted exactlysaddle:read: widening a request a client actually made is a spec violation, and some clients compare the granted scope against what they asked for. A fallback is re-run through the same intersection so it cannot introduce a scope Saddle does not grant.get_site_tier(), preselected at what the client asked for (or the site's level when it asked for nothing), andhandle()re-clamps the posted value server-side. The granted level is now recorded in the activity log.Saddle_OAuth_Store::set_grant_scope()rewrites the grant and every token bound to it, behindPOST /oauth-connections/<id>(manage_options, clamped to the site tier).tier_ceiling()now takes the lower of the token's scope and its grant's current one, so lowering a level lands on the next request rather than up toACCESS_TTLlater. Raising still requires the token rewritten — the safe direction to be strict in.Saddle_OAuth_Bearer::$grantto$token_record. It never held a grant, and mistaking one for the other is precisely what makes this feature fail silently.update_settings(), which readget_json_params()only: a form-encodedtierpassed the route enum, passedcan_manage, returned 200 and saved nothing.Guardrails — unchanged
read.test_a_scope_still_cannot_exceed_the_site_tier.Testing
composer test— 594 tests, 1986 assertions, green (577 before; 17 new intests/oauth-scope-test.php, which pins the reported symptom first, then the fix, then its limits)composer lint— clean; new files scoped-linted explicitlynpm run lint:js— clean;npm run buildrun, committed bundle matches.potregenerated (it was stale against 1.0.0-rc3, missing far more than this change)Note for the live test
If the write tools are now listed and ChatGPT still declines to call them, that is client-side, not Saddle. OpenAI's developer-mode docs say write actions work on every eligible plan with confirmation, while several secondary sources and our own
STATUS.md:266say fully write-capable custom connectors are limited to Business/Enterprise/Edu. The Connect tab now says so. Worth settling inSTATUS.mdonce we have a definitive answer from the live test.Version
No version bump. The plugin header,
SADDLE_VERSION,readme.txtstable tag andpackage.jsonare untouched; changelog entries went under the existing unreleased= 1.0.0 =.Ask before merge
Over the ~400-line threshold (~570 lines of source, plus the pot regeneration and the rebuilt bundle), and it touches OAuth. Yours to merge.