Skip to content

[Feature] Support multiple accounts per git provider (e.g. two GitHub accounts) #8

Description

@haexhub

Problem

ProviderHandler and the settings model store exactly one credential per ProviderType:

PROVIDER_TOKEN_TYPE = Mapping[ProviderType, ProviderToken]

(openhands/app_server/integrations/provider.py)

A user with two GitHub accounts (e.g. personal + work, or two orgs that don't share SSO) can only ever have one connected at a time. Connecting a second account overwrites the first — there's no way to keep both and pick per-repo/per-conversation which one to use. Same limitation applies to GitLab, Bitbucket, etc.

I searched existing issues (multiple github accounts, multiple git provider tokens, switch github account, one token per provider, the integrations label, and related closed proposals like #10850 and #15492) and didn't find this specific gap tracked, so filing it separately. Apologies in advance if I missed a duplicate.

Proposed design

The codebase already solved this exact shape of problem for LLM configs — LLMProfiles (openhands/app_server/settings/llm_profiles.py) stores a named dict[str, LLM] plus an active: str | None, with validators that reconcile active against the dict and skip individually-invalid entries on load. Git provider tokens could reuse the same pattern instead of inventing a new one:

class ProviderTokenSet(BaseModel):
    """Named collection of ProviderToken for one provider, plus which one is active.

    Mirrors LLMProfiles. Backward compatible with today's bare-ProviderToken
    storage via a `mode="before"` adapter.
    """

    model_config = ConfigDict(validate_assignment=True)

    accounts: dict[str, ProviderToken] = Field(default_factory=dict)
    active: str | None = None

    @model_validator(mode="before")
    @classmethod
    def _lift_legacy_single_token(cls, value: Any) -> Any:
        # Old on-disk shape for this provider was a bare ProviderToken
        # payload (token/user_id/host at the top level). Lift it into the
        # new shape transparently so existing users don't lose their
        # connection on upgrade.
        if isinstance(value, dict) and "accounts" not in value and (
            "token" in value or "user_id" in value or "host" in value
        ):
            return {"accounts": {"default": value}, "active": "default"}
        return value

    @model_validator(mode="after")
    def _reconcile_active(self) -> "ProviderTokenSet":
        if self.active is not None and self.active not in self.accounts:
            object.__setattr__(
                self, "active", next(iter(self.accounts), None)
            )
        return self

    def active_token(self) -> ProviderToken | None:
        return self.accounts.get(self.active) if self.active else None


PROVIDER_TOKEN_TYPE = Mapping[ProviderType, ProviderTokenSet]

ProviderHandler.get_service becomes the one required call-site change — everything else in that class (get_user, get_repositories, search_repositories, verify_repo_provider, get_authenticated_git_url, etc. — about a dozen methods) still does for provider in self.provider_tokens / self.get_service(provider) and keeps working unmodified, because it's still iterating provider types, not accounts:

def get_service(
    self, provider: ProviderType, account: str | None = None
) -> GitService:
    token_set = self.provider_tokens[provider]
    token = token_set.accounts[account] if account else token_set.active_token()
    if token is None:
        raise AuthenticationError(f"No active account configured for {provider}")
    service_class = self.service_class_map[provider]
    return service_class(
        user_id=token.user_id,
        external_auth_id=self.external_auth_id,
        external_auth_token=self.external_auth_token,
        token=token.token,
        external_token_manager=self.external_token_manager,
        base_domain=token.host,
    )

The optional account param is additive — every existing caller that doesn't pass it keeps resolving to today's single/active token.

API surface (sketch, not implemented)

Following the shape /api/v1/settings/profiles already established for LLMProfiles:

Method Path Purpose
GET /api/v1/secrets/git-providers/{provider}/accounts list connected accounts for a provider
POST /api/v1/secrets/git-providers/{provider}/accounts/{name} add/update a named account
DELETE /api/v1/secrets/git-providers/{provider}/accounts/{name} remove a named account
POST /api/v1/secrets/git-providers/{provider}/accounts/{name}/activate switch which account is used by default

The existing POST /api/v1/secrets/git-providers (bare provider→token) would keep working by writing to accounts["default"] + active="default", so nothing breaks for single-account users.

What this issue is not

I deliberately stopped at the model + one method, not a full PR. This type touches the settings/secrets routers, every git search endpoint (git/repositories/search, git/branches/search, users/git-info), and presumably the Settings UI for account selection — real work, but I wanted to check the shape of the approach first, especially since #10850 and #15492 suggest the settings/integrations area is under active redesign. Happy to build this out (backend + a minimal UI) if the direction looks right, or to adjust if there's a preferred design already in flight.

Open questions for maintainers

  • Is per-provider "named accounts with one active" (mirroring LLMProfiles) the right mental model, or is per-repository/per-conversation account selection (no global "active" account) closer to what's planned for the settings/integrations redesign?
  • Should account names be free-text (like LLM profile names) or should the connect flow auto-derive a label from the authenticated username (avoids "personal"/"work" bookkeeping the user has to invent)?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions