Skip to content

auth: per-project credential activation (provider profile per folder) #51152

Open

Description

@marcotroisi

Summary

Credential activation (auth switch, /connect account list) is global to the shared background server: switching the active account for an integration in one project silently re-bills background agents running in unrelated projects. There is no way to pin a provider account (e.g. work vs personal API key) to a project/folder.

Environment

  • opencode version: 2.0.12
  • OS: Linux 7.1.8-arch1-3 (x64)
  • Terminal: TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /usr/bin/bash
  • Install/channel: latest (release binary via ~/.opencode/bin/opencode serve --service)
  • Active plugins: none

Reproduction

  1. Connect two API-key accounts to the same integration (e.g. two OpenCode Go keys via /connect or opencode auth login), label them e.g. Work and Personal.
  2. Start a session with background agents in project A using provider/* models.
  3. In project B, run opencode auth switch <integration> Work (or pick the account in /connect).
  4. Observe via opencode auth list (active account sorts first) that the switch applies machine-wide: project A's background agents now resolve the same provider/* model IDs against the Work credential.

Verified at the store level: the credential table in opencode.db holds a single active flag per credential row with no directory/project column, e.g. SELECT label, active FROM credential WHERE integration_id='opencode-go' returns exactly one row with active=1. The credential PATCH/activate API endpoints take no location parameter, and GET /api/integration/{id} connection order does not vary by location[directory].

Expected Behavior

A project/folder config (e.g. opencode.json) can select which stored credential of an integration is active for sessions under that directory, with the global active account as fallback. Switching accounts in one project never affects sessions rooted elsewhere. Documented precedence rule needed for the existing cases too (saved account vs settings.apiKey vs env connection).

Actual Behavior

One active credential per integration per server. Any auth switch (CLI or TUI) instantly changes which key backs provider/* model IDs for every session on the shared server, including background agents in unrelated directories, with no warning.

Additional Context

  • Frequency: consistent.
  • Impact: accidental cross-billing between work/personal accounts; switching is unsafe on a machine with agents running in multiple projects.
  • Related: stored credentials silently override explicit provider config (upstream report: anomalyco/opencode#10950); both issues stem from credential resolution ignoring project config. Workaround attempted: namespaced custom provider (insieme-go with {env:...} key) per project, but Go serves models over different routes (/chat/completions vs /responses), so each route needs its own provider ID with hand-mirrored model metadata — fragile and high-maintenance.

Activity

  1. jakoss commented on Sep 29, 2026

    @jakoss

    It would be great to get some movement here, this would be the biggest gamechanger for people like me, where i have 4 copilot licenses from each customer and juggling between those is a nightmare

  2. jakoss commented on Sep 30, 2026

    @jakoss

    I went and did some investigation.

    Short version: V2 already stores several credentials per integration with one active flag. I want to keep that table exactly as it is and make the selection Location-scoped instead of the activation. Proposal below, we need to push on the two decisions I'm least sure about.

    The shape

    // work/opencode.json
    {
      "providers": {
        "github-copilot": {
          "account": "work",
        },
      },
    }

    The binding lives in the project file that already describes the project, and Core resolves it per Location at request time.

    Decision 1: a label, not a credential ID

    The value names a stored account by label, matched case-insensitively.

    Credential IDs are machine-local, so a committed opencode.json naming one is meaningless on a teammate's machine. Labels are already user-facing and already printed by opencode auth list.

    Cost of this choice: labels are not unique and have no rename history. Renaming an account breaks the binding. I think that is the right trade, see Decision 2, but it is a real cost and I would rather you agree with it than discover it later.

    Decision 2: no fallback, fail loudly

    If the named account is missing or matches more than one credential, the request fails with the provider name, the label you configured, the labels that do exist, and the fix. It does not fall back to the active account. Same reasoning covers a renamed account. It fails rather than retargeting.

    If the account is NOT set explicitly - we keep current behavior (use globally active account), so unless user sets the account for workspace - nothing changes.

    Selection is per Location, activation stays global

    auth switch and /connect keep working exactly as they do today, and they stay global. Opening a project never activates anything.

    Nothing about the stored credential changes, each Location just resolves a different one.

    providers.<id>.account folds onto Provider.Info in ConfigProviderPlugin. Project config folds after global config, so a project file overrides a global binding for the same provider with no merge change. ModelResolver reads only Provider, never Config, which is why the fold happens there.

    What else has to follow selection

    The agent step, title generation, and compaction all resolve through SessionRunnerModel into ModelResolver, so one change covers all three. They cannot disagree about which account is in use.

    Two things do not follow automatically:

    Discovery. GitHub Copilot derives its model list, its endpoint, and its utility token from the OAuth credential. Its endpoint is per-credential, because copilotBaseURL reads metadata.apiEndpoint or metadata.enterpriseUrl. A work project discovering through the personal account sends personal-issued requests to the wrong endpoint. Discovery has to run against the Location's selected connection, and it has to re-run when the binding changes. Note that a config reload re-runs integration and provider registration but not plugin discovery, so the binding edit needs its own trigger.

    Provider availability. Core drops a provider whose discovered state came from a connection that is no longer the one in use, so one account's endpoints never get combined with another's. That comparison has to be against the Location's selected connection. Left as-is, a bound project loses its discovered models every time you switch accounts anywhere else, which would defeat the feature.

    Precedence

    Current order, highest first, for a model request:

    1. providers.<id>.account, if set
    2. the globally active stored credential for that integration
    3. a stored credential, by position, if none is active
    4. an env connection for that integration
    5. settings.apiKey in the provider config

    Shape of the change

    packages/schema (the field on ConfigProvider.Info and Provider.Info), packages/core/src/integration.ts (connection.select next to connection.active, plus the two tagged errors), packages/core/src/model-resolver.ts (one selection point, plus an early account check so a missing account does not first surface as a missing model), packages/core/src/provider.ts (the availability comparison), the Copilot plugin (discovery and its triggers), and the plugin host surface. Roughly a day's work, and it needs a real per-Location test harness, which the existing model-resolver tests do not have.

    Happy to give a shot at this and open a PR.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions