Skip to content

Multiple accounts per connector — one Gmail per user is a primary key, and GitHub already does it right #715

Description

@serge-ivo

The ask

One person, several identities. A personal mailbox, a work mailbox, a job-hunting mailbox — and
different agents that should each use a different one. Today an account holds exactly one Gmail,
one Drive, one WorkDrive, one Sheets, one MCP credential.

Reported from a live case: the operator's serge.pro.job@gmail.com is connected for job
applications, and a household task needs sivochkin@gmail.com. Both are wanted at once, on the
same PAGS account, on different agents.

The platform is already inconsistent with itself

GitHub is multi-account and always has been.

-- migrations/0020_coding_workspaces.sql:59-71
CREATE TABLE github_installations (
  id                 TEXT PRIMARY KEY,
  user_id            TEXT NOT NULL REFERENCES users(id),
  installation_id    INTEGER NOT NULL,
  account_login      TEXT NOT NULL DEFAULT '',       -- org/user the app is installed on
  ...
  UNIQUE(user_id, installation_id)
);

One user, many installations, each labelled, each selectable per repo. That is exactly the shape
being asked for.

Every OAuth-vault connector is single-account by primary key.

-- migrations/0003_user_keys.sql:6-15
CREATE TABLE user_api_keys (
  user_id        TEXT NOT NULL,
  provider       TEXT NOT NULL,
  ...
  PRIMARY KEY (user_id, provider)
);

A second Gmail has nowhere to be stored. This is not a policy that can be relaxed in code — it is
the table definition. compileConnector (lib/connectors/manifest.ts) hardcodes
grantModel: "user" for every manifest connector, which is the same statement one layer up.

So the App-auth connector got multi-account and the OAuth ones did not, and nothing records that
as a decision. It reads as an accident, and the live case says it is one.

What is NOT the ask, and must not be built

Preferences.tsx says of connections:

Connections get no such control and must never grow one: it would promise a per-agent
scoping that cannot exist.

That comment is correct and stays correct. It forbids an agent silently OVERRIDING the
account-wide connection — the misread that caused #355, where disconnecting Gmail from the
Coder's settings silently disconnected it for the Job Application Assistant.

Holding two genuine credentials and choosing between them is a different thing: the account page
still owns connect/disconnect, and an instance picks from what the account holds rather than
establishing its own. The comment needs updating to say which of the two it means, or the next
reader will take it as blocking this.

Design

1. Re-key the vault, additively. PRIMARY KEY (user_id, provider, account_id), with
account_id defaulting to '' so every existing row keeps working and keeps meaning "the
account's connection". account_label already exists (migration 0035) and becomes the display
name. SQLite cannot alter a primary key in place, so this is a table rebuild — which is why it
wants doing before the table grows further, not after.

2. Per-instance selection as config, not a new grant table.
agent_instances.config.connectorAccounts = { "gmail": "<account_id>" }. It is a preference
about which of the owner's own credentials to use, not a grant of new reach — the owner already
holds all of them, and modelling it as a grant would imply a permission that is not being made.

3. Resolution, and what happens when it is ambiguous. connectorClient(provider) already
carries caller.instanceId, so readConnectorRefreshToken gains the instance's chosen account.

The decision that matters: with two or more accounts connected and no per-instance choice
recorded, REFUSE rather than pick one.
Sending mail from the wrong identity is not a
degraded outcome, it is a wrong one — it reaches a real person under a name the owner did not
choose. One connected account keeps working with no choice recorded, so nothing regresses for
anyone who never adds a second.

4. Console. Preferences lists each connector's accounts with "Add another"; disconnect names
WHICH account it is removing and which agents were pointed at it. The instance Settings tab gets
a picker over accounts the owner already holds — never a connect button.

Scope

Gmail, Google Drive, Zoho WorkDrive, Google Sheets, outbound MCP — every auth: "oauth" and
auth: "token" connector. Doing it per-provider is how five copies of this start; the whole
point of the registry is that a connector declares itself and the framework does the rest.

GitHub is out of scope: it already works, and its mechanism (App installations) is genuinely
different from a stored refresh token. The goal is parity of BEHAVIOUR, not a shared table.

Not verified

Whether any existing code assumes the (user_id, provider) pair is unique beyond the vault
reads — keys.ts, the proxy path, and connected-accounts.ts all touch that table and each needs
reading before the rebuild, not after.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / 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