Repository navigation
[design] Stage 2: register the generic connector OAuth redirect for Drive/WorkDrive/Gmail and retire the dedicated flows (Stage 1 — the registry declaration — has shipped) #352
Description
The gap
lib/connectors/registry.ts is the platform's answer to "what can an agent reach, and who says so". A
connector is declared once and everything derives from that entry: the tool catalog,
connectorClient auth dispatch, capabilities.tools gating, the write-consent gate (#90), and all
three surfaces (agent runtime / /v1/instances/:id/tools / MCP).
Eleven connectors live there: github, meta, terminal, tmux, browser, repo-local,
supervision, http, mcp, google_sheets, web-search.
Google Drive, Zoho WorkDrive and Gmail are not among them. They are bespoke routes
(routes/drive.ts, routes/workdrive.ts, routes/email.ts), with hand-written console blocks in
SettingsTab.tsx:967-1075.
Because they are not declared, there is nothing for a capability to gate against. So they render on
every instance, for every agent, regardless of what that agent can do. On this account that is 26
instances — including Repo Chat, whose entire job is answering questions about code, and the four
terminal Operators, none of which has any file-connector tool.
Why this is a half-finished migration, not a refactor
Three pieces of the destination already exist and are already tested:
1. The grant model is built and unused. Connector.grantModel (lib/connectors/types.ts:129)
takes "user" | "instance-resource", the second documented as "each tool call must target a
resource granted to the instance" — which is precisely Drive/WorkDrive's folder grant.
connectors/client.ts:90-102 mints a token for such a connector only after
requireConnectorGrant passes, fail-closed with a 403, and client.test.ts:143,152 covers both
directions.
No connector in CONNECTORS declares grantModel: "instance-resource". The feature was built for
these three and they never arrived.
2. They already share the storage. routes/drive.ts:27 and routes/workdrive.ts:16 both import
lib/connector-grants.js — the same connector_grants table the registry model reads. Its own
header comment names the intended consumers: "Known values: google_drive | zoho_workdrive | (any
connector id with grantModel:"instance-resource")".
3. The OAuth path is proven on the same credential. google_sheets (#89/#143/#147) is a
declarative manifest driven by the generic OAuth2 handler, connecting at
/v1/connectors/google_sheets/oauth/start — using the same GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET
as Drive, with its refresh token in its own vault slot. Its source comment says so explicitly:
"separate from Drive". Sheets took the new road; Drive stayed on the old one.
So the destination is not hypothetical. It is a manifest per connector plus handlers for the parts
that are genuinely custom (Drive's file listing, WorkDrive's DC-specific bases, Gmail's
find_confirmation_link permission gate).
What it buys
- Each connector's console block appears only when the agent declares a tool for it. The clutter
in [bug] An unconfigured connector renders a red error on every agent's Settings tab — Zoho WorkDrive has never been configured #353 and [design] Account-level connections are rendered inside one agent's Settings — disconnecting Gmail there disconnects it everywhere #355 stops being UI to prune and becomes a derived property. - They join the tool-policy list, so "what can this agent do" stays the one honest answer.
- Drive/WorkDrive writes become write-consentable ([connectors] Connector consent + write-scope safety + admin visibility #90) like every other write connector.
- One fewer parallel system.
connectors/client.ts:34currently carries the tell:
"the hand-written google_drive connector keeps its hardcoded creds."
Sequencing
This supersedes the symptom fixes rather than competing with them — but those are small and land
today, so do them first and let this delete the code they touch.
Gmail is the delicate one: find_confirmation_link is permission-gated on
AgentState.permissions.email and reads the owner's inbox. Its gate must survive the move as a
declared scope, not be softened into a plain write-consent checkbox.
Related: #146 (connector manifests), #147 (generic connector OAuth), #86 (registry), #90
(write-consent).
Activity
serge-ivo commentedon Aug 7, 2026
Correction — the gating mechanism in "What it buys" does not hold
Checked after filing. The first bullet claimed each connector's console block would appear "only when
the agent declares a tool for it". That is wrong for all three connectors, for two different reasons.
Drive and WorkDrive have no agent-callable tools at all.
routes/drive.tsis OAuth start/status/callback, grants CRUD,
GET /files, andPOST /instances/:id/import.routes/workdrive.tsis thesame shape. Nothing in
agent-do-tools.tsor the registry exposes adrive_*tool. They areingestion sources — user-driven import plus sync triggers — not things an agent calls mid-turn. So
there is no tool to declare, and
capabilities.toolscannot gate them.Gmail's tool is deliberately not declarable.
find_confirmation_linkexists(
lib/storage-tools.ts:206) but is intentionally excluded fromCREATOR_SELECTABLE_TOOLS—agent-do-tools.ts:126says so, and it is granted at runtime by the permission flag instead(
:225,agent-think.ts:638). Gating its console block on a declared tool would invert a securitydecision that is currently correct.
What this changes
The diagnosis stands. The remedy was mis-assigned. Everything in the body above about the two
parallel systems is still true and still verifiable:
grantModel:"instance-resource"is built,tested and unused;
connector_grantsis already shared;google_sheetsruns the same Googlecredential through the new road while Drive stays on the old one;
client.ts:34still hardcodesDrive's creds.
But this issue is not the fix for the settings clutter it was filed alongside. #355 is — once
connect/disconnect moves to the account page, an instance only shows a grant box for a connector the
account has actually connected, and on this deployment that removes both Drive (
connected:false)and WorkDrive (
configured:false) from all 26 instances immediately, with no registry work.What this issue is still needed for is the steady state. Connect Drive once at account level and
#355 alone puts a Drive grant box back on all 26 instances — including the four terminal Operators,
which will never read a document. Suppressing that needs a real capability axis, and that axis is
what the registry provides.
Re-scope
Folding these in requires deciding what the agent-facing surface actually is, which the original body
skipped:
drive_list_files,drive_read_file)so
grantModel:"instance-resource"gates them the way it was designed to, or keep themuser-driven and gate the UI on a different declared axis (e.g. "this agent ingests documents").
The first is more work and more honest; it is also a scope expansion that was not stated here.
permissions.emailas the gate. Its shape is apermission-gated tool, not a creator-selectable one, and the registry has no such category today.
Until (1) is decided this is architectural debt, not a queued fix. Sequence: #353 → #354 → #355 →
this, not the order the four were filed in.
serge-ivo commentedon Aug 7, 2026
One more open question for whoever triages: this may belong under the #68 focus bet as
deferred: no demand. The debt is real and the evidence above stands, but nothing user-visible depends on it once #353 and #355 land — those remove the symptom without touching the registry. Leaving the label off deliberately rather than applying it unilaterally; it is a priority call, not a technical one.serge-ivo commentedon Aug 7, 2026
Assessment: fold them in — but the migration is smaller than the risk suggests, and the destination is smaller than the ticket claims. Three things here should not be built.
I ran two probes against the code rather than reasoning from the design docs. Both changed the answer.
Tested: the data migration is empty
The framing everyone (including me, before checking) brings to this is "moving live grants is a data risk". It isn't, because the storage is not merely shared — it is identical, keyed by the same value.
routes/drive.ts:149androutes/workdrive.ts:134store the refresh token withsaveConnectorRefreshToken(env, {userId, provider: "google_drive" | "zoho_workdrive", …}).routes/email.ts:156does the same INSERT inline under providergmail.saveConnectorRefreshTokenwritesuser_api_keys (user_id, provider, …), envelope-encrypted (lib/connector-oauth.ts:120). There is noconnector_tokenstable; [design] Account-level connections are rendered inside one agent's Settings — disconnecting Gmail there disconnects it everywhere #355's scope table names one that doesn't exist.connectors/client.ts:125mints a bearer for anauth:"oauth"registry connector by callingreadConnectorRefreshToken(env, uid, connector.id, connector.label)— same function, same table, provider = the connector id.connector_grantsunder the same ids (lib/connector-grants.ts:6documents them by name).So: add
google_drivetoCONNECTORSwithauth:"oauth"andconnectorClientfinds every already-connected user's token on the first call. No backfill, no rename, no dual-write window, nothing to roll back. The ids already match because the hand-written routes were written against the shared helper.The one non-empty cost is external, and it is a deployment step rather than a data step: the generic flow's callback is
/v1/connectors/:id/oauth/callback, so new connects need that redirect URI registered on the Google/Zoho OAuth client — the same "inert until the redirect is registered" caveatgoogle_sheetsalready carries. Existing connections keep working throughout, because the token row is found by provider id and nothing about it changes.Tested: a manifest cannot hold these three
The body's case rests on "google_sheets took the new road; Drive stayed on the old one". I compiled and validated it:
Two independent blockers, both in
lib/connectors/manifest.ts:grantModelis not a manifest field.ConnectorManifest(:69) has no such key andcompileConnectorhardcodesgrantModel: "user"(:184). Theinstance-resourcemodel — the exact thing the body correctly identifies as built-and-unused for these connectors — cannot be expressed in a manifest at all. Only a hand-writtenConnectorobject inCONNECTORScan express it.sanitizeConnectorManifestdrops any tool without arequest(url/path), then rejects a manifest with zero tools. Both existing "manifest" connectors —web-searchandgoogle_sheets— define their tools with thehandlerescape hatch and norequest, so both would be rejected by the repo's own manifest validator; they exist only because first-party manifests go throughcompileConnector, which does not validate. (ID_REalso forbids underscores, sogoogle_sheets/google_drive/zoho_workdrivefail on the id alone.)Drive's file listing, MIME export, folder-descendant check and WorkDrive's DC-specific bases are exactly the custom-handler shape the validator rejects. So this work should be costed as "a hand-written registry entry with handlers calling the existing
lib/drive.ts", not as "a reviewed JSON manifest, not a.ts+ deploy" (#146's promise). That promise is real for a plain REST API and is not available here.What the fold actually buys — and what it doesn't
Honest accounting, because two of the body's four bullets don't survive contact:
tools: []they join the connection list, not the tool listDRIVE_SCOPE = ".../auth/drive.readonly"(lib/drive.ts:4). Nothing to consent toThat last one is the real case, and it is bigger than the two files named. Five places independently know "google_drive and zoho_workdrive are the file connectors, and here is where their routes live":
routes/drive.ts+routes/workdrive.ts(the flows)tabs/SettingsTab.tsx:931-1075(three hand-written blocks)workers/mcp/src/instance-tools/connectors.ts:21— a hardcodedPROVIDERS = {google_drive: {base:"/v1/drive"}, zoho_workdrive: {base:"/v1/workdrive"}}map, plus az.enumof the same twolib/triggers.ts:669,721— thesync_connectorbranch, one hardcodedreadConnectorRefreshToken(…, "google_drive", …)per providerlib/connectors/client.ts:34— the comment admitting the exceptionA sixth connector of this kind means editing five files and a Zod enum. That is the cost the registry removes, and it is a maintenance argument rather than a safety one — which brings me to:
The fold adds no safety property. Drive/WorkDrive already fail closed:
requireConnectorGrantguards the routes, and the sync path re-checks descent from the granted root (triggers.ts:674) so the walk cannot leave the grant. Gmail'sfind_confirmation_linkis already gated onAgentState.permissions.emailand deliberately excluded fromCREATOR_SELECTABLE_TOOLS. Nothing here is currently reachable by an agent that shouldn't reach it. The problem is comprehension and duplication, not exposure, and it should be justified on those terms rather than by borrowing #90's language.What I would not build
1. Inventing
drive_list_files/drive_read_filesocapabilities.toolshas something to gate. This is option (1) in the correction above, described there as "more work and more honest". I think it is more work and less honest: a gate whose precondition is inventing the thing it gates is designed backwards. Concretely it costs —sync_connectortrigger does, and the content then lives in the instance's own store where every other agent surface can already see it.If the goal is "the four terminal Operators shouldn't show a Drive card", gate on an axis that already exists — declared
surfaces, or a boolean capability likeingests_documents— rather than manufacturing a tool to be the gate.capabilitiesis a registry of what an agent is; it does not have to be spelled as tools.2. Renaming the ids to satisfy
ID_RE.google_drive→google-driveis the only change in this whole area that would touch live rows (user_api_keys.provider,connector_grants.provider, the MCP enum, existing triggers' stored config). It buys eligibility for a validator these connectors cannot pass anyway. Leave the ids alone; if the underscore ban is wanted, it can stay a rule about creator-supplied manifests, which is all it currently governs.3. Moving Gmail's tool in the same pass. Its shape — permission-gated, non-creator-selectable, granted at runtime from
AgentState.permissions— has no category in the registry today.scopesis{read, write}andgrantModelisuser | instance-resource; neither expresses "granted by an owner-set permission flag, never declarable by a creator". Folding it in means either inventing a thirdgrantModelor softening the gate, and the second is a security regression dressed as a refactor. Gmail's account-level connect/disconnect should move to Preferences with the others (#355); its tool stays exactly where it is until someone deliberately designs a permission-gated grant model.Recommended shape and sequence
Stage 1 — declare, don't migrate (small, no behaviour change, do it first). Add
google_drive,zoho_workdriveandgmailtoCONNECTORSwithtools: [],auth: "oauth", andgrantModel: "instance-resource"for the first two. EmptytoolsleavesconnectorTools()unchanged, so no agent gains anything. Add the one route the console needs and does not have:GET /v1/connectorsreturning{id, label, auth, connected, configured}— no such endpoint exists today (routes/connectors.tshas only/:id/oauth/{start,callback,status}) and the console never calls/v1/connectors/*at all.Stage 1 should land before #355, not after. The correction on #355 is right that its Connections section is new work rather than a transplant — which means building it first, by hand, writes a third hardcoded copy of these three blocks that Stage 1 then deletes. With
GET /v1/connectorsin place, #355's section is a loop over the registry, and it arrives already correct forgoogle_sheetsand any future OAuth connector — both of which the hand-written version would silently omit.Stage 2 — retire the duplicated flows (worth it). Point Drive/WorkDrive connect at
/v1/connectors/:id/oauth/{start,callback}and delete their copies. The generic route is already equivalent — samesignConnectorState, same bind nonce, sameaccess_type=offline&prompt=consent, same save. Two small gaps to close first: it doesn't requestopenid emailor passaccountLabel, so the console would lose the "connected (name@example.com)" line, and it has no per-connector "is this deployment configured" answer of the shape/v1/drive/statusreturns. Keep the Drive-specific file routes (list, import, grants) — those are the genuinely custom part and there is no reason to make them generic.Stage 3 — never, or not until someone asks: agent-callable Drive tools, a manifest form, id renames.
Sequence, amending the one above: #353 → #354 → #352 Stage 1 → #355 → #352 Stage 2.
fix(console): an unconfigured connector is a deployment gap, not the …
feat(connectors): declare Drive, WorkDrive and Gmail, and expose the …
serge-ivo commentedon Aug 7, 2026
Stage 1 landed — b9e224a on
main. The two probes held; the reach was one place bigger than the count.Both findings survived implementation exactly as written, so this is a report rather than a correction.
The data migration was empty, as measured.
google_drive,zoho_workdriveandgmailare now entries inCONNECTORS(lib/connectors/connected-accounts.ts). No backfill, no rename, no dual-write window, nothing to roll back — the ids already match because the hand-written routes were written againstsaveConnectorRefreshToken, andconnectorClientlooks up the sameuser_api_keys(user_id, provider)row.A manifest could not hold them, so none was attempted. These are hand-written
Connectorobjects withgrantModel: "instance-resource"on the first two — the field a manifest has no key for.The three things not to build, not built
drive_*tools.tools: []on all three, and a test assertsconnectorTools()contains nothing from them, soconnectorTools()is byte-identical and no agent gained anything.user_api_keys.provider,instance_connector_grants.provider, the MCP enum and stored trigger configs are untouched.find_confirmation_linkis still granted at runtime byAgentState.permissions.emailand still excluded fromCREATOR_SELECTABLE_TOOLS.What landed
GET /v1/connectors— the catalog resolved for the caller:{id, label, auth, scopes, grantModel, unattended, tools, configured, connected, account, connectedAt}.configuredvsconnectedkeeps #353's distinction (this deployment cannot / you have not) in the payload rather than leaving each consumer to re-derive it, andconnectedisnull— notfalse— for a connector that holds no credential, so a relay connector does not read as a gap. One indexed read overuser_api_keysfor the whole catalog.client.ts:34's hardcodedgoogle_drivebranch is deleted, not left as a fallback. Drive resolves its OAuth config from its own declaration now, like everything else. That is one of the five copies actually gone rather than merely made derivable. The test that pinned the special case was rewritten to assert the declaration produces the same result;client.test.ts'soauth_connfixture had to gain a realoauthblock, which is the honest consequence — a fixture that declares no credentials should resolve to none.One thing the design note did not anticipate
DELETE /v1/connectors/:id/oauthhad to grow the #357 cascade in the same commit. Declaring Drive/WorkDrive makes the generic disconnect route a second path to disconnect them — and it dropped only the token row, which is exactly the bug #357 had just closed on the dedicated routes. Left alone, Stage 1 would have silently re-opened it through another door. It now callsrevokeUserConnectorGrantsforinstance-resourceconnectors and reportsrevoked;/oauth/statusreturnsreachfor the same connectors, the same contract/v1/drive/statuscarries.This is the general form of the risk in folding a hand-written flow into a generic one, and it is worth carrying into Stage 2: the generic route is only "already equivalent" for the behaviour someone checked. The two gaps the design note names (
openid email/accountLabel, and a per-connectorconfiguredanswer) are real; this was a third.Two declarations that say something deliberately unfinished
oauthblock. Zoho's authorize/token endpoints are per data-centre (workDriveAccountsBase(env)), so there is no static URL to declare. Omitting it keeps/v1/connectors/zoho_workdrive/oauth/*answering 404 rather than minting an authorize URL against the wrong DC. Stage 2 needs an answer for this before it can retire/v1/workdrive/zoho/start.redirect_uriis/v1/connectors/<id>/oauth/callback— a path those OAuth apps do not have registered. Same inert stategoogle_sheetsalready carries; nothing links it, and the dedicated flows remain the live ones.Where that leaves the sequence
#352 Stage 1 → #355 → #352 Stage 2still holds, and Stage 1 landing first is what the design note said it was for: #355's Connections section is now a loop overGET /v1/connectorsrather than a third hardcoded copy of these blocks, and it arrives already correct forgoogle_sheets.Of the five hardcoded copies: one is deleted (
client.ts), one is superseded by an endpoint anyone can call. The remaining three — the two route files, the console blocks, the MCPPROVIDERSmap + Zod enum, thesync_connectortrigger branch — are Stage 2 and #355 work, and each is now a consumer of one answer rather than an author of its own.Also worth noting for whoever picks up Stage 2:
#357closed independently, so the "silently restores them" hazard is gone from both disconnect paths, and the argument in that issue about #355 making the move riskier no longer applies — there is nothing left to restore.feat(api): the connector catalog says what a disconnect costs and whe…
feat(console): connections move to the account page, grants stay with…
serge-ivo commentedon Aug 7, 2026
Recommend deferring — #355 shipped and took the symptom with it
0735b24+91e762cmoved connect/disconnect to Preferences and, per its closing note, the Drive/WorkDrive grant panel now renders onconnectedrather thanconfigured— so the block is gone from all 26 instances. That was the user-visible reason this issue existed.What remains is exactly the steady-state argument in the correction above, and it is now the ONLY thing here: connect Drive once at account level and the grant panel returns on every instance, including the four terminal Operators that will never read a document. Real, but hypothetical until someone connects Drive.
Against that: this issue has been corrected twice, its symptom is fixed, and it still needs a design decision nobody has made — whether Drive/WorkDrive get genuine agent-callable read tools (a scope expansion) or a different declared axis. Starting it today means making that call to service a problem no user currently has.
Suggest
deferred: no demandunder the #68 focus bet, to be revived by either a user connecting Drive and complaining, or a third-party-creator push where the closed connector vocabulary actually blocks someone. Not applying the label — that is a priority call.fix(connectors): a file connector is now offered per AGENT, not per a…
fix(connectors): a folder grant is refused for an agent that cannot r…
serge-ivo commentedon Aug 8, 2026
The gate is now a fact at the door, not a decision the console makes —
64598ddonmain, deployed and verified livePicking this up after the "recommend deferring" note above: that note was written at 06:00Z, and
4c03862landed 15 hours later and changed what is left. It made a file connector offered per agent — the console stops showing the folder-grant panel to an agent holding none ofsearch_knowledge/list_knowledge/read_knowledge— and said in its own header that it added no enforcement point. That is what this commit finishes.What was actually still open
The rule was decorative, because the panel is one of three doors onto
instance_connector_grants:POST /v1/{drive,workdrive}/instances/:id/grants(did not),grant_instance_connector_folder, which posts to that route (did not).The grant is the permission —
requireConnectorGranton every read route asks whether the row exists, never whether it should have been created — so granting a Drive folder to a terminal Operator was one MCP call away while the console showed no panel at all. That is the shape #358 named on this same surface ("a picker-only fix would leave two of them open") and the reason #404 puts constraints at dispatch rather than in the UI.What shipped
Both grant routes refuse, with the sentence the rule itself supplies (
connectorRefusal, pure, beside the verdict — so the panel and the 403 cannot describe the same instance differently). It names the tools that would fix it: "declare a knowledge tool" is advice,search_knowledgeis an instruction.The resolution moved to
lib/instance-connector-access.ts, andGET /v1/instances/:id/connectorsreads it too rather than keeping its own copy ofcapabilitiesForInstance+toolNamesFor+ off-switches — a second copy is exactly how one door starts saying no while the other says yes. The owner's per-instance off-switches count in both, with a test: a creator who declared the knowledge tools does not outrank a subscriber who switched them off.No provider is named anywhere in the branch. It keys on the declared
grantModel: "instance-resource", and a test invents anacme_filesconnector to assert a third file connector is gated by declaring itself. That derivation is the only thing Stage 1 was ever going to buy, and it is the reason to spend it here rather than add a fourth hardcoded pair.Verified in production, not just in CI
64598dd→ API + MCP deploys green, then against the live API on two real instances of this account:POST /v1/drive/…/grantsPOST /v1/workdrive/…/grantsGET …/connectorsgoogle_drive: false / no_knowledgegoogle_drive: true / knowledgeThe 400 on the second row is the proof the gate is about the agent and not about Drive: that failure comes from the step after the gate. And the two doors agree on both instances, which is the property the shared resolver exists to hold.
What I deliberately did not do
pags up#358's placement.connectorRefusalreturns null for areason:"permission"connector, becausefind_confirmation_link's gate is the owner'spermissions.emailflag and a second answer derived from the tool allowlist would either soften or contradict it.sync_connectortrigger's wiring gate. The console's action picker still offers "Sync folder" on every agent, and the save path still accepts it. It is practically closed now — a sync needs agrantId, and no new grant can be created for such an agent — but the picker is honestly wrong for a stale grant, and fixing it properly means teachingtrigger-capability.tsa tool-based requirement beside its workflow-based one so the picker and the validator keep reading the same fact. Small, separable, and not the same change; worth its own ticket rather than being smuggled in here.Where that leaves the issue
Leaving it open for Stage 2 only, which is unchanged and still blocked externally: retiring the dedicated OAuth flows needs
/v1/connectors/<id>/oauth/callbackregistered on OAuth apps no commit can reach, and WorkDrive still has no static authorize URL to declare (its declaration says so). The remaining derivable-but-hardcoded copies — the MCPPROVIDERSmap + Zod enum, and thesync_connectorexecutor's per-provider listing branch — are both consumers of an answer they could now derive, which is still the precondition for deleting them rather than the deletion.The title's claim is, however, closed: a capability now gates all three, and for the two that carry a grant it gates them at the door rather than in the picker.
docs(architecture): the Coder never drove CLI sessions through the Te…
serge-ivo commentedon Aug 18, 2026
Audit verdict: Stage 1 delivered — the title's premise is now false. Leaving open for Stage 2, and retitling.
Delivered
workers/api/src/lib/connectors/connected-accounts.tsdeclares all three —google_drive(:49),zoho_workdrive(:71),gmail(:90) — as registryConnectors. So "Drive, WorkDrive and Gmail sit outside the connector registry" is no longer true, andGET /v1/connectorsis the one answer to "which connectors exist" instead of five independent copies (two route files, three console blocks, an MCPPROVIDERSmap with a matching Zod enum, thesync_connectortrigger branch, and the apology comment inclient.ts).64598ddclosed the other half you found on this thread: the grant rule was decorative because the console panel was one of three doors ontoinstance_connector_grants, and the other two (POST /v1/{drive,workdrive}/instances/:id/grantsand MCPgrant_instance_connector_folder) did not consult it. It is now enforced at the door.tools: []on all three is a stated decision, not an omission (connected-accounts.ts:12-26): Drive/WorkDrive are ingestion sources whose content lands in the instance's own store, so inventingdrive_read_filepurely to givecapabilities.toolssomething to gate would add a second content path bypassing the vector store and a second place the folder grant must be enforced per call. Gmail'sfind_confirmation_linkstays outside because it is granted by an owner-setAgentState.permissions.emailflag andgrantModelhas no category for that — folding it in would mean inventing a third grant model or softening the gate.Not delivered — Stage 2, and it carries a real caveat
connected-accounts.ts:33-39, verbatim:So there is now a route that will 4xx at Google/Zoho if anything ever links it. Nothing does today; the mitigation is that nothing links it, which is the weaker kind. Stage 2 is: register the generic redirect with both OAuth apps, then retire the dedicated flows.
Retitle
The old title states a premise that expired. Changed to name Stage 2.
[-][design] Drive, WorkDrive and Gmail sit outside the connector registry — so no capability can gate them[/-][+][design] Stage 2: register the generic connector OAuth redirect for Drive/WorkDrive/Gmail and retire the dedicated flows (Stage 1 — the registry declaration — has shipped)[/+]serge-ivo commentedon Aug 18, 2026
The body's premise has expired — all three ARE in the registry now. What is left is Stage 2, and the title says the opposite.
Old-issue sweep, 2026-08-18,
origin/main@6ceec4f. Not taking the delivery verdict (the delivered-issues pass owns that); flagging that a reader of the title and body would be told something the code disproves.The opening claim
workers/api/src/lib/connectors/registry.ts:All three are members of
CONNECTORS.workers/api/src/lib/connectors/connected-accounts.tsdeclares them fully —auth: "oauth",scopes: {read:true, write:false}, and the grant model this issue said was built-and-unused:google_drive→grantModel: "instance-resource"(:58)zoho_workdrive→grantModel: "instance-resource"(:75)gmail→grantModel: "user"(:96), with the reason stated: there is no per-mailbox grant row, reach is decided byAgentState.permissions.email, and the registry deliberately did not grow a third grant model to hold it.Its header calls itself "#352 Stage 1" and explains why it is a declaration rather than a migration — each already stored its refresh token through
saveConnectorRefreshTokeninto the byte-identicaluser_api_keys(user_id, provider)row anauth:"oauth"registry connector reads, so nothing needed backfilling.tools: []on all three is argued for rather than left empty: Drive and WorkDrive are ingestion sources whose content lands in the instance's own store where RAG already sees it, and inventingdrive_read_filewould add a second content path bypassing the vector store plus a second place the folder grant must be enforced per call.And the symptom is fixed too
lib/instance-connector-access.tsis now the single resolution, read by the console panel and byGET /v1/instances/:id/connectors, withPOST /v1/{drive,workdrive}/instances/:id/grantsrefusing (via the pureconnectorRefusal, so the panel and the 403 cannot describe the same instance differently). That closed the three-doors hole the 2026-08-08 comment identified — the console consulted the rule and both write paths did not.What is actually left, and it is narrow
The Stage 1 header names it precisely (
connected-accounts.ts:36-40):So the remaining scope is: register the generic redirect URI with the Google and Zoho OAuth apps, point the console at
/v1/connectors/:id/oauth/start, and delete the dedicated flows. Note the first of those is a provider-dashboard action only the owner can take — same class as #108's Cloudflare Access application — so it is not a ticket a dev agent can finish alone. That is worth saying on the issue, because nothing currently does.Recommendation
Retitle and rewrite the body to Stage 2, or close this and open "retire the dedicated Drive/WorkDrive/Gmail OAuth flows for the generic connector redirect". The current title — "Drive, WorkDrive and Gmail sit outside the connector registry — so no capability can gate them" — is now false in both clauses, and it is the kind of stale framing that gets an agent to re-derive a migration that already happened.
Verified:
registry.ts:8,150-152;connected-accounts.ts:49-110including all threegrantModelvalues and thetools: []rationale; the existence oflib/instance-connector-access.ts. Not verified: whether the generic OAuth redirect has been registered with either provider — that is dashboard state I cannot read.feat(gmail): the mailbox declares tools — search a message, read it, …
serge-ivo commentedon Aug 23, 2026
PARTIAL — Stage 1 is delivered and Gmail has since taken Stage 2 as well. What is left is Drive + WorkDrive's two dedicated OAuth flows.
Delivered-work audit, 2026-08-23, against
main. The title already records Stage 1; this comment states the Stage 2 residue precisely, because one of the three has already moved and the body does not say so.Delivered since this was filed
All three are now declared connectors.
workers/api/src/lib/connectors/registry.ts:178-187:lib/connectors/connected-accounts.ts:61and:78both declaregrantModel: "instance-resource"— the fieldtypes.ts:129defined for exactly these and that no connector had ever taken.connectors-oauth.test.ts:205pinsgoogle_driveas{grantModel:"instance-resource", scopes:{read:true,write:false}}.4c038623— "a file connector is now offered per AGENT, not per account" — and64598dd4— "a folder grant is refused for an agent that cannot read a document". So Repo Chat and the four terminal Operators no longer render file-connector blocks they have no tool for.oauth2manifest —lib/connectors/gmail.ts:471-500,auth: { type: "oauth2", authUrl: …, tokenUrl: …, scopes: [openid, email, gmail.readonly, gmail.send] }— with real declared tools (GMAIL_TOOLS), awriteMeaning(:483-485), and its permission surface preserved rather than softened, which was this ticket's stated worry. Drive and WorkDrive are still declared withtools: []on purpose (registry.ts:179-181).What remains — retire two dedicated flows
workers/api/src/routes/drive.ts:56driveRoutes.get("/google/start")and:119/google/callback, with the redirect built at:49(/v1/drive/google/callback).workers/api/src/routes/workdrive.ts:53/zoho/startand:113/zoho/callback, redirect at:46.workers/api/src/routes/email.ts:65/google/start,:189/google/callback, redirect at:56. Since the manifest connects through the generic/v1/connectors/gmail/oauth/*path, this pair is now a second door to the same connection rather than the only one — retiring it is cheaper than the other two and should be sequenced first, as the proof that the generic flow fully replaces a dedicated one.The destination is unchanged and is proven twice over:
google_sheets(#89/#143/#147) and nowgmailboth connect through the generic OAuth handler on the sameGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET.Redirect registration is the owner-action part — each retired flow means a redirect URI added to (and later removed from) the Google / Zoho OAuth app consoles. Do not delete a dedicated callback before its generic replacement's redirect is registered and observed working, or an in-flight reconnection breaks with no route home.
Files:
workers/api/src/routes/{drive,workdrive,email}.ts·workers/api/src/lib/connectors/connected-accounts.ts(the twotools: []declarations that would gainauth: {type:"oauth2", …}) ·store/console/src/pages/SettingsTab.tsx(the hand-written blocks). Collides with the Gmail work on #715/#725/#726 — those touchroutes/email.tsand the Gmail connector; land the Drive/WorkDrive halves independently of the Gmail one.Verified: every
file:lineabove, onmaintoday. Inferred: nothing — I did not exercise either OAuth flow.serge-ivo commentedon Sep 26, 2026
Stage 2 shipped:
8439a739. Drive, WorkDrive and Gmail connect through the one generic OAuth flow; the dedicated implementations are gone.How it avoids the hazard the 2026-08-23 audit named
No provider-dashboard change was needed, because nothing the providers see has changed. Each connector now declares the callback its OAuth app already has registered (
oauth.redirectPath:/v1/drive/google/callback,/v1/workdrive/zoho/callback,/v1/email/google/callback). Those paths stay mounted, but only as one-line aliases of the generic handler. The authorize URL, the token exchange and the receiving route all name that one registered URI. In-flight connects and existing connections keep working.What the generic flow gained (
lib/connector-oauth-flow.ts)Everything the three copies carried by hand is now declared on the connector and done once:
identity:account_id=''row that every existing Drive/WorkDrive connection already is;endpointsFromEnv: Zoho's per-data-centre accounts server. This is why WorkDrive used to have nooauthblock. The token refresh honours it too.?grant=adds a declared optional grant and refuses an undeclared one;include_granted_scopesandlogin_hintapply only to a connector that declares optional grants, so Drive's token never widens to scopes granted to Gmail on the shared Google client;New:
GET /v1/connectors/oauth/callback. One redirect URI per OAuth app, for every connector it serves:redirectPathto it.Retired:
DEDICATED_FLOWS, soGET /v1/connectorsnow names the generic start/disconnect for every OAuth connector and the console moved with no change;DELETEgained Gmail's?account=(removed+remaining) alongside the Disconnecting Drive/WorkDrive leaves every instance's folder grants, and reconnecting silently restores them #357 grant cascade.No D1 migration. The stored rows are unchanged, and that's the backward-compatibility claim the tests below pin.
Tests
routes/connector-oauth-flow.test.ts: 20 tests on the real migrated schema with real encryption.include_granted_scopes;?account=removes one mailbox and reports the rest; only the caller's rows are touched.include_granted_scopesrestriction, the registered redirect, or the bind refusal each fails a test.Updated tests:
oauth-scope-driftis inverted: no dedicated flow may come back, and every live flow requests a superset of its declared scopes.email-grantsexercises the legacy/v1/email/google/*aliases.fetch-deadlinepin went from 55 to 49: six bare fetches were deleted, and the flow's two carry a deadline.Results:
docs:drift(the new route is inopenapi.yaml, and the legacy callbacks are marked as aliases), surface-lock and MCP parity are clean.Left, and it's owner action only
The id-less
/v1/connectors/oauth/callbackisn't yet registered with Google or Zoho. That registration can't be done from a commit. It's optional: everything works on the registered legacy paths. Once it's registered and observed working, each connector's move is itsredirectPathline. The legacy callback aliases can be deleted only after that, per the audit's rule.Not verified live: I didn't run a real consent against Google or Zoho. The flows are proven against the real schema and the exact URIs, not observed end to end.