OpenHands / OpenHands/sandbox-server
[Feature] Support multiple accounts per git provider (e.g. two GitHub accounts)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 5
- Forks
- 13
- PR merge metrics
- No merged PRs in 30d
Description
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)?
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with openhands/app_server/integrations/provider.py and openhands/app_server/settings/llm_profiles.py to compare the existing token and profile models. Then inspect the settings/secrets routers, git search endpoints, and Settings UI described in the issue. Done means an agreed account model supports multiple provider accounts, preserves legacy single-account storage, and covers account selection across the affected API and UI.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, authentication, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100