Skip to content

[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

Closed

Description

@serge-ivo

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

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

  1. serge-ivo commented on Aug 7, 2026

    @serge-ivo
    ContributorAuthor

    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.ts is OAuth start/status/
    callback, grants CRUD, GET /files, and POST /instances/:id/import. routes/workdrive.ts is the
    same shape. Nothing in agent-do-tools.ts or the registry exposes a drive_* tool. They are
    ingestion sources — user-driven import plus sync triggers — not things an agent calls mid-turn. So
    there is no tool to declare, and capabilities.tools cannot gate them.

    Gmail's tool is deliberately not declarable. find_confirmation_link exists
    (lib/storage-tools.ts:206) but is intentionally excluded from CREATOR_SELECTABLE_TOOLS —
    agent-do-tools.ts:126 says 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 security
    decision 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_grants is already shared; google_sheets runs the same Google
    credential through the new road while Drive stays on the old one; client.ts:34 still hardcodes
    Drive'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:

    1. Drive / WorkDrive — either give them genuine read tools (drive_list_files, drive_read_file)
      so grantModel:"instance-resource" gates them the way it was designed to, or keep them
      user-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.
    2. Gmail — moving it in must preserve permissions.email as the gate. Its shape is a
      permission-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.

  2. serge-ivo commented on Aug 7, 2026

    @serge-ivo
    ContributorAuthor

    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.

  3. serge-ivo commented on Aug 7, 2026

    @serge-ivo
    ContributorAuthor

    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:149 and routes/workdrive.ts:134 store the refresh token with saveConnectorRefreshToken(env, {userId, provider: "google_drive" | "zoho_workdrive", …}). routes/email.ts:156 does the same INSERT inline under provider gmail.
    • saveConnectorRefreshToken writes user_api_keys (user_id, provider, …), envelope-encrypted (lib/connector-oauth.ts:120). There is no connector_tokens table; [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:125 mints a bearer for an auth:"oauth" registry connector by calling readConnectorRefreshToken(env, uid, connector.id, connector.label) — same function, same table, provider = the connector id.
    • Grants are already in connector_grants under the same ids (lib/connector-grants.ts:6 documents them by name).

    So: add google_drive to CONNECTORS with auth:"oauth" and connectorClient finds 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" caveat google_sheets already 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:

    sanitizeConnectorManifest(GOOGLE_SHEETS_MANIFEST)               =>  REJECTED
    compileConnector({…oauth2 manifest…}).connector.grantModel      =>  "user"
    

    Two independent blockers, both in lib/connectors/manifest.ts:

    1. grantModel is not a manifest field. ConnectorManifest (:69) has no such key and compileConnector hardcodes grantModel: "user" (:184). The instance-resource model — 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-written Connector object in CONNECTORS can express it.
    2. The manifest validator only accepts pure-declarative HTTP tools. sanitizeConnectorManifest drops any tool without a request (url/path), then rejects a manifest with zero tools. Both existing "manifest" connectors — web-search and google_sheets — define their tools with the handler escape hatch and no request, so both would be rejected by the repo's own manifest validator; they exist only because first-party manifests go through compileConnector, which does not validate. (ID_RE also forbids underscores, so google_sheets/google_drive/zoho_workdrive fail 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:

    Claimed benefit Holds?
    Console block appears only when the agent declares a tool ❌ already withdrawn in the correction above, and see "would not build"
    They join the tool-policy list — one honest answer to "what can this agent do" ⚠️ only if they get tools; with tools: [] they join the connection list, not the tool list
    Drive/WorkDrive writes become write-consentable (#90) ❌ there are no writes. DRIVE_SCOPE = ".../auth/drive.readonly" (lib/drive.ts:4). Nothing to consent to
    One fewer parallel system ✅ and it is worth more than the body says

    That 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 hardcoded PROVIDERS = {google_drive: {base:"/v1/drive"}, zoho_workdrive: {base:"/v1/workdrive"}} map, plus a z.enum of the same two
    • lib/triggers.ts:669,721 — the sync_connector branch, one hardcoded readConnectorRefreshToken(…, "google_drive", …) per provider
    • lib/connectors/client.ts:34 — the comment admitting the exception

    A 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: requireConnectorGrant guards the routes, and the sync path re-checks descent from the granted root (triggers.ts:674) so the walk cannot leave the grant. Gmail's find_confirmation_link is already gated on AgentState.permissions.email and deliberately excluded from CREATOR_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_file so capabilities.tools has 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 —

    • a second content path into the agent that bypasses the KB and the vector store, so the same document reaches the model chunked-and-cited via RAG or raw-and-unbounded via a tool, depending which it picks;
    • a second place the folder grant must be enforced, per call, for a connector whose current security story is "one grant check at the top of a walk that cannot leave the root";
    • a live-read capability nobody asked for. Drive is an ingestion source: the user imports, or a sync_connector trigger 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 like ingests_documents — rather than manufacturing a tool to be the gate. capabilities is 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-drive is 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. scopes is {read, write} and grantModel is user | instance-resource; neither expresses "granted by an owner-set permission flag, never declarable by a creator". Folding it in means either inventing a third grantModel or 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_workdrive and gmail to CONNECTORS with tools: [], auth: "oauth", and grantModel: "instance-resource" for the first two. Empty tools leaves connectorTools() unchanged, so no agent gains anything. Add the one route the console needs and does not have: GET /v1/connectors returning {id, label, auth, connected, configured} — no such endpoint exists today (routes/connectors.ts has 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/connectors in place, #355's section is a loop over the registry, and it arrives already correct for google_sheets and 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 — same signConnectorState, same bind nonce, same access_type=offline&prompt=consent, same save. Two small gaps to close first: it doesn't request openid email or pass accountLabel, 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/status returns. 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.

  4. serge-ivo commented on Aug 7, 2026

    @serge-ivo
    ContributorAuthor

    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_workdrive and gmail are now entries in CONNECTORS (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 against saveConnectorRefreshToken, and connectorClient looks up the same user_api_keys(user_id, provider) row.

    A manifest could not hold them, so none was attempted. These are hand-written Connector objects with grantModel: "instance-resource" on the first two — the field a manifest has no key for.

    The three things not to build, not built

    • No drive_* tools. tools: [] on all three, and a test asserts connectorTools() contains nothing from them, so connectorTools() is byte-identical and no agent gained anything.
    • No id renames. The ids in user_api_keys.provider, instance_connector_grants.provider, the MCP enum and stored trigger configs are untouched.
    • Gmail's tool was not moved. Only its account-level connection is declared; find_confirmation_link is still granted at runtime by AgentState.permissions.email and still excluded from CREATOR_SELECTABLE_TOOLS.

    What landed

    GET /v1/connectors — the catalog resolved for the caller: {id, label, auth, scopes, grantModel, unattended, tools, configured, connected, account, connectedAt}. configured vs connected keeps #353's distinction (this deployment cannot / you have not) in the payload rather than leaving each consumer to re-derive it, and connected is null — not false — for a connector that holds no credential, so a relay connector does not read as a gap. One indexed read over user_api_keys for the whole catalog.

    client.ts:34's hardcoded google_drive branch 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's oauth_conn fixture had to gain a real oauth block, 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/oauth had 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 calls revokeUserConnectorGrants for instance-resource connectors and reports revoked; /oauth/status returns reach for the same connectors, the same contract /v1/drive/status carries.

    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-connector configured answer) are real; this was a third.

    Two declarations that say something deliberately unfinished

    • WorkDrive declares no oauth block. 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.
    • Drive and Gmail declare theirs, which makes the generic start route able to build a URL whose redirect_uri is /v1/connectors/<id>/oauth/callback — a path those OAuth apps do not have registered. Same inert state google_sheets already carries; nothing links it, and the dedicated flows remain the live ones.

    Where that leaves the sequence

    #352 Stage 1 → #355 → #352 Stage 2 still holds, and Stage 1 landing first is what the design note said it was for: #355's Connections section is now a loop over GET /v1/connectors rather than a third hardcoded copy of these blocks, and it arrives already correct for google_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 MCP PROVIDERS map + Zod enum, the sync_connector trigger 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: #357 closed 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.

  5. serge-ivo commented on Aug 7, 2026

    @serge-ivo
    ContributorAuthor

    Recommend deferring — #355 shipped and took the symptom with it

    0735b24 + 91e762c moved connect/disconnect to Preferences and, per its closing note, the Drive/WorkDrive grant panel now renders on connected rather than configured — 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 demand under 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.

  6. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    The gate is now a fact at the door, not a decision the console makes — 64598dd on main, deployed and verified live

    Picking this up after the "recommend deferring" note above: that note was written at 06:00Z, and 4c03862 landed 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 of search_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:

    • the console (consulted the rule),
    • POST /v1/{drive,workdrive}/instances/:id/grants (did not),
    • MCP grant_instance_connector_folder, which posts to that route (did not).

    The grant is the permission — requireConnectorGrant on 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_knowledge is an instruction.

    The resolution moved to lib/instance-connector-access.ts, and GET /v1/instances/:id/connectors reads it too rather than keeping its own copy of capabilitiesForInstance + 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 an acme_files connector 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:

    instance POST /v1/drive/…/grants POST /v1/workdrive/…/grants GET …/connectors
    tmux Operator 403 "cannot use Google Drive … declares none of the tools that read one" 403, same shape google_drive: false / no_knowledge
    doc-chat (Fridges) 400 "Google Drive is not connected" 400, same google_drive: true / knowledge

    The 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

    1. No gate on READING a folder already granted. An existing grant keeps working, unchanged. A rule about who may be given reach must not retroactively break reach somebody has and may still be using; the refusal is at creation, where the human is present — [bug] A scheduled browser run can be saved on 25 of 26 agents that cannot perform one — and on cloud-only agents it skips forever telling you to run pags up #358's placement.
    2. Gmail untouched. connectorRefusal returns null for a reason:"permission" connector, because find_confirmation_link's gate is the owner's permissions.email flag and a second answer derived from the tool allowlist would either soften or contradict it.
    3. The sync_connector trigger'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 a grantId, 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 teaching trigger-capability.ts a 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/callback registered 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 MCP PROVIDERS map + Zod enum, and the sync_connector executor'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.

  7. serge-ivo commented on Aug 18, 2026

    @serge-ivo
    ContributorAuthor

    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.ts declares all three — google_drive (:49), zoho_workdrive (:71), gmail (:90) — as registry Connectors. So "Drive, WorkDrive and Gmail sit outside the connector registry" is no longer true, and GET /v1/connectors is the one answer to "which connectors exist" instead of five independent copies (two route files, three console blocks, an MCP PROVIDERS map with a matching Zod enum, the sync_connector trigger branch, and the apology comment in client.ts).

    64598dd closed the other half you found on this thread: the grant rule was decorative because the console panel was one of three doors onto instance_connector_grants, and the other two (POST /v1/{drive,workdrive}/instances/:id/grants and MCP grant_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 inventing drive_read_file purely to give capabilities.tools something 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's find_confirmation_link stays outside because it is granted by an owner-set AgentState.permissions.email flag and grantModel has 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:

    declaring oauth makes the generic /v1/connectors/:id/oauth/start able to build an authorize URL whose redirect_uri is /v1/connectors/<id>/oauth/callback — a path these providers' OAuth apps do not have registered, so it would fail at the provider. The console does not link it; the dedicated flows (/v1/drive/google/start, …) remain the live ones until #352 Stage 2 registers the generic redirect and retires them.

    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.

  8. changed the title [-][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)[/+] on Aug 18, 2026
  9. serge-ivo commented on Aug 18, 2026

    @serge-ivo
    ContributorAuthor

    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

    "Eleven connectors live there … Google Drive, Zoho WorkDrive and Gmail are not among them."

    workers/api/src/lib/connectors/registry.ts:

      8: import { GMAIL_CONNECTOR, GOOGLE_DRIVE_CONNECTOR, ZOHO_WORKDRIVE_CONNECTOR } from "./connected-accounts.js";
    ...
    150:	GOOGLE_DRIVE_CONNECTOR,
    151:	ZOHO_WORKDRIVE_CONNECTOR,
    152:	GMAIL_CONNECTOR,
    

    All three are members of CONNECTORS. workers/api/src/lib/connectors/connected-accounts.ts declares 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 by AgentState.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 saveConnectorRefreshToken into the byte-identical user_api_keys(user_id, provider) row an auth:"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 inventing drive_read_file would 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

    "they render on every instance, for every agent, regardless of what that agent can do"

    lib/instance-connector-access.ts is now the single resolution, read by the console panel and by GET /v1/instances/:id/connectors, with POST /v1/{drive,workdrive}/instances/:id/grants refusing (via the pure connectorRefusal, 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):

    "declaring oauth makes the generic /v1/connectors/:id/oauth/start able to build an authorize URL whose redirect_uri is /v1/connectors/<id>/oauth/callback — a path these providers' OAuth apps do not have registered, so it would fail at the provider. The console does not link it; the dedicated flows (/v1/drive/google/start, …) remain the live ones until #352 Stage 2 registers the generic redirect and retires them."

    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-110 including all three grantModel values and the tools: [] rationale; the existence of lib/instance-connector-access.ts. Not verified: whether the generic OAuth redirect has been registered with either provider — that is dashboard state I cannot read.

  10. added
    P3: laterDesign, strategy, or deferred pending a decision or demand
    enhancementNew feature or request
    on Aug 18, 2026
  11. serge-ivo commented on Aug 23, 2026

    @serge-ivo
    ContributorAuthor

    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:

    GOOGLE_DRIVE_CONNECTOR,
    ZOHO_WORKDRIVE_CONNECTOR,
    // Gmail (#711) — the same stored connection, now with declared read tools of its own.
    GMAIL_CONNECTOR,
    
    • The unused grant model is now used, which was this ticket's finding 1. lib/connectors/connected-accounts.ts:61 and :78 both declare grantModel: "instance-resource" — the field types.ts:129 defined for exactly these and that no connector had ever taken. connectors-oauth.test.ts:205 pins google_drive as {grantModel:"instance-resource", scopes:{read:true,write:false}}.
    • The console clutter is a derived property now, which was the user-facing complaint. 4c038623 — "a file connector is now offered per AGENT, not per account" — and 64598dd4 — "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.
    • Gmail went further than Stage 1. It is now a full declarative oauth2 manifest — lib/connectors/gmail.ts:471-500, auth: { type: "oauth2", authUrl: …, tokenUrl: …, scopes: [openid, email, gmail.readonly, gmail.send] } — with real declared tools (GMAIL_TOOLS), a writeMeaning (:483-485), and its permission surface preserved rather than softened, which was this ticket's stated worry. Drive and WorkDrive are still declared with tools: [] on purpose (registry.ts:179-181).

    What remains — retire two dedicated flows

    • Drive: workers/api/src/routes/drive.ts:56 driveRoutes.get("/google/start") and :119 /google/callback, with the redirect built at :49 (/v1/drive/google/callback).
    • WorkDrive: workers/api/src/routes/workdrive.ts:53 /zoho/start and :113 /zoho/callback, redirect at :46.
    • Gmail's legacy pair is also still standing alongside its manifest: 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 now gmail both connect through the generic OAuth handler on the same GOOGLE_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 two tools: [] declarations that would gain auth: {type:"oauth2", …}) · store/console/src/pages/SettingsTab.tsx (the hand-written blocks). Collides with the Gmail work on #715/#725/#726 — those touch routes/email.ts and the Gmail connector; land the Drive/WorkDrive halves independently of the Gmail one.

    Verified: every file:line above, on main today. Inferred: nothing — I did not exercise either OAuth flow.

  12. serge-ivo commented on Sep 26, 2026

    @serge-ivo
    ContributorAuthor

    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

    "Do not delete a dedicated callback before its generic replacement's redirect is registered and observed working."

    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:

    New: GET /v1/connectors/oauth/callback. One redirect URI per OAuth app, for every connector it serves:

    • How it picks the connector: the state's provider claim only selects which connector to verify against.
    • Why a forged claim fails: verification still pins the signature, expiry, provider and the browser's single-use nonce cookie.
    • How a connector adopts it: register the URI at the provider, then change that connector's redirectPath to it.

    Retired:

    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.

    • Start:
      • each connector redirects to its registered path;
      • the legacy start aliases produce the same authorize URL as the generic route;
      • WorkDrive uses this deployment's data-centre;
      • only Gmail widens with include_granted_scopes;
      • undeclared powers are refused.
    • Callback:
      • Drive writes the single labelled row, exchanged against the same redirect;
      • a pre-existing Drive row is updated in place, not duplicated;
      • Gmail keeps a row per mailbox and merges scopes across a narrower reconnect;
      • WorkDrive exchanges at the configured data-centre;
      • no refresh token means nothing is stored.
    • State and nonce security:
      • a Drive state is refused at Gmail's legacy and generic callbacks;
      • a different browser (no cookie, or another flow's cookie) is refused;
      • expired and re-signed states are refused;
      • the bind cookie is cleared;
      • the id-less route completes only with the named connector's own nonce;
      • a forged claim, an unknown or non-OAuth id, or garbage is refused with nothing stored.
    • Disconnect: ?account= removes one mailbox and reports the rest; only the caller's rows are touched.
    • Mutation-checked: removing per-account keying, the include_granted_scopes restriction, the registered redirect, or the bind refusal each fails a test.

    Updated tests:

    • oauth-scope-drift is inverted: no dedicated flow may come back, and every live flow requests a superset of its declared scopes.
    • The catalog now names generic flows.
    • WorkDrive's data-centre endpoints are pinned.
    • email-grants exercises the legacy /v1/email/google/* aliases.
    • The fetch-deadline pin went from 55 to 49: six bare fetches were deleted, and the flow's two carry a deadline.

    Results:

    • Full suite: 712 files, 12,807 tests passed.
    • Checks: API typecheck, biome, the file-size ratchet, docs:drift (the new route is in openapi.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/callback isn'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 its redirectPath line. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3: laterDesign, strategy, or deferred pending a decision or demandbackendBackend / Worker / API workconnectorsConnector + tool frameworkenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions