Skip to content

gmail.modify is declared but never requested: archive and mark-read can never work, and every Gmail account now reads "read-only — reconnect to allow sending" that reconnecting cannot clear #717

Description

@serge-ivo

What the owner sees

Two things, both wrong, both live in production right now (measured 2026-08-21, operator account, GET /v1/connectors):

  1. A Gmail account connected yesterday with everything granted is reported as short of scope, and the reconnect it asks for cannot clear it.
{ "accountId": "sivochkin@gmail.com",
  "connectedAt": "2026-08-20 20:39:54",
  "missingScopes": ["https://www.googleapis.com/auth/gmail.modify"] }

That same account reports "canSend": true on GET /v1/email/status. The console renders it as "read-only — reconnect to allow sending" — false on both halves: it is not read-only, and what it lacks is not send.

  1. gmail_archive and gmail_mark_read cannot succeed for anybody, including a fresh subscriber to the Inbox Chat agent seeded alongside them (migration 0136). They refuse with a remedy that does not exist:

"Reconnect it in the console (Preferences → Connections) and allow the manage-mail permission"

There is no manage-mail permission to allow. Google never offers it, because PAGS never asks for it.

Mechanism — two correct-looking decisions, one live gap

c7c1adae (#716) added gmail.modify to the connector manifest:

workers/api/src/lib/connectors/gmail.ts:465

"https://www.googleapis.com/auth/gmail.modify",

But Gmail does not connect through the generic connector OAuth route. routes/connectors.ts:81 pins it to a dedicated flow that predates the registry:

gmail: { start: "/v1/email/google/start", disconnect: "/v1/email/google" },

and that route's authorize URL has not changed since #713

workers/api/src/routes/email.ts:65

url.searchParams.set("scope", `openid email ${GMAIL_SCOPE} ${GMAIL_SEND_SCOPE}`);

git show c7c1adae -- workers/api/src/routes/email.ts is empty. The manifest and the live authorize URL are two different lists, and only one of them moved.

The generic route /v1/connectors/gmail/oauth/start does build the URL from the manifest (routes/connectors.ts:258), so it would carry gmail.modify — but its redirect_uri is /v1/connectors/gmail/oauth/callback, which is not registered on the Google client, so it fails at the provider. lib/connectors/connected-accounts.ts:38-43 says so explicitly, and the console never links it. There is no reachable path to a gmail.modify grant today.

Then the shortfall report reads the manifest, not the flow:

routes/connectors.ts:147missingScopesFor(connector, grantedScopes) diffs connector.oauth.scopes against what was granted. gmail.modify is in the former, can never be in the latter, so it is permanently missing.

store/console/src/lib/accountConnections.ts:68 and :136 then render any non-empty missingScopes with one hardcoded sentence:

? "read-only — reconnect to allow sending"

The note is binary; the shortfall is not. So an account missing only modify is described as read-only.

Why this is the #715 bug in a new costume

routes/connectors.ts:110-118 still carries the post-mortem of the last one:

"every Gmail connection reported email permanently missing, so every account rendered as 'read-only — reconnect to allow sending' no matter what the owner had just granted … The reconnect it asked for could never clear it, because the mismatch is in the comparison rather than in the grant."

Same symptom, same unclearable remedy, different cause: last time the comparison was wrong, this time the ask is wrong.

It also breaks the acceptance criterion #714 shipped under, verbatim:

"every refusal the tools can produce corresponds to something the owner can see and change in the console, without reading a chat transcript to discover it"

RECONNECT_TO_MODIFY (lib/connectors/gmail.ts:241) is a refusal that corresponds to nothing the owner can change anywhere.

Why no test caught it

  • lib/connectors/gmail.test.ts:207 asserts the manifest contains gmail.modify. It does. Nothing asserts the live authorize URL does.
  • lib/tool-reachability.test.ts already has the exact vocabulary for this state — sheets_read: "inert until the Sheets OAuth scope lands on the Google client; until then every call fails at consent, not at the tool." gmail_archive / gmail_mark_read are in that state, but the guard measures declaration, not grantability, so they pass.

What to do — cheapest first

1. One line, restores the feature (ship this first). Add the scope to the live flow:

// routes/email.ts:65
url.searchParams.set("scope", `openid email ${GMAIL_SCOPE} ${GMAIL_SEND_SCOPE} ${GMAIL_MODIFY_SCOPE}`);

Note what this costs and decide it knowingly: gmail.modify then appears in every new Gmail consent, including for owners who only want reading. That is a restricted scope on a CASA-assessed app. If the answer is "not by default", the one-line fix is instead to remove gmail.modify from the manifest and mark the two tools unreachable-by-design in tool-reachability.test.ts until the elevate path exists — which un-breaks the status line and the false refusal in the same commit, at the cost of the archive feature. Either is coherent; shipping neither is not.

2. Make the shortfall note say what is actually missing. accountRows() and connectionSummary() should branch on WHICH scopes are absent, not on length > 0:

Missing Note
gmail.send "read-only — reconnect to allow sending"
gmail.modify only "cannot archive or mark read — reconnect to allow managing mail"
both "read-only — reconnect to allow sending and managing mail"

This is worth doing even if (1) lands, because after (1) a user who declines the manage-mail box at Google lands in exactly this state legitimately, and would be told the wrong thing.

3. Guard it. A test that builds the authorize URL /v1/email/google/start returns and asserts its scope param is a superset of GMAIL_MANIFEST.auth.scopes. Same for /v1/drive/google/start vs GOOGLE_DRIVE_CONNECTOR.oauth.scopes — the class is "a dedicated flow and its manifest drift", not "Gmail". Cheap, and it is the only thing that stops the next scope addition repeating this.

4. Correct the published doc, which currently states something untrue. platform-docs/connectors.md:405 says "Two OAuth scopes are requested" above a three-row table, and :418-420 says:

"Google's consent screen lets a person grant reading and sending but decline manage-mail"

It does not — the checkbox is never shown. Replace with whatever (1) settles.

Alternatives considered and rejected

Acceptance criteria

  • GET /v1/connectors no longer reports a scope PAGS never asks for. Either the flow asks for it, or the manifest stops declaring it.
  • An account that granted send does not render as "read-only".
  • Either gmail_archive succeeds after a reconnect, or it is not declared on a published catalog agent.
  • A test fails if a scope is added to GMAIL_MANIFEST.auth.scopes without the live start route requesting it.
  • platform-docs/connectors.md describes the consent screen a user actually sees.

Regression risk

  • Adding modify re-prompts every connected user (prompt=consent is already set at email.ts:70, so a reconnect genuinely re-asks). Anyone who reconnects and unticks manage-mail lands in the state fix (2) exists to describe — so (1) without (2) trades a universal wrong message for an occasional one.
  • The note branching touches accountConnections.ts, which accountConnections.test.ts covers; needsReconnect's multi-account short-circuit (:109) must keep behaving as Multiple accounts per connector — one Gmail per user is a primary key, and GitHub already does it right #715 left it — a two-account owner already sees per-account notes and no summary note.
  • Removing modify from the manifest instead would flip GMAIL_CONNECTOR.scopes.write? No — write is derived from tool scope, and gmail_reply/gmail_send keep it true. lib/connectors/gmail.test.ts:220 asserts that and would catch it if wrong.

Measured, not inferred

  • Production GET /v1/connectors and GET /v1/email/status, operator account, 2026-08-21: quoted above verbatim.
  • git show c7c1adae -- workers/api/src/routes/email.ts → empty diff.
  • git grep -c include_granted_scopes over the repo → 0 matches (relevant to the follow-up, not to this fix).
  • Not reproduced: an actual gmail_archive call. Doing so would need the owner's agent driven live; the refusal path is read from lib/connectors/gmail.ts:416 and canModifyscopesAllowModify (lib/gmail.ts:694), which returns false for any grant string without gmail.modify or https://mail.google.com/, and neither is obtainable.

Related: #716 (the commit trailer for this work cites #716, which is a different issue — subscribe idempotency; the Gmail-modify work has no issue of its own), #715, #714, #713, #352.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1: blocks external usersMust be true before someone who is not the owner can run an agent (#68)bugSomething isn't workingconnectorsConnector + tool framework

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions