Skip to content

feat(core): restore GitLab workflow discovery and add OAuth login - #50422

Open
vglafirov wants to merge 10 commits into
anomalyco:v2from
vglafirov:restore-gitlab-discovery
Open

vglafirov wants to merge 10 commits into
anomalyco:v2from
vglafirov:restore-gitlab-discovery

Conversation

@vglafirov

@vglafirov vglafirov commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #50421

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Two related changes to the built-in GitLab Duo provider, both in
packages/core/src/plugin/provider/gitlab.ts.

1. Restore workflow model discovery (#50421)

Restores the discovery call present in v1 but missing from the v2 GitLab
adapter. It calls gitlab-ai-provider.discoverWorkflowModels() for the current
directory and adds the returned models, token limits, and workflowRef settings
to the provider catalog.

Discovery uses the existing credential resolution and refreshes on account
changes. Failures leave static chat models available, and existing model
definitions are preserved. Fetching and project detection remain in the SDK.

2. Add OAuth (PKCE) login

Adds a PKCE OAuth method to the gitlab integration, so "Login with GitLab"
works out of the box. Until now the only in-core auth methods were the generic
key (personal access token) and env (GITLAB_TOKEN) methods that
ModelsDevPlugin derives from the models.dev catalog; the OAuth path required
the external opencode-gitlab-auth
plugin, which this makes unnecessary.

Core already owns credential storage, refresh scheduling, and merging
instanceUrl/apiKey into provider settings. This only supplies the pieces core
cannot infer: authorize, refresh, label, and a self-managed instance URL
form field. The implementation follows the existing in-core OAuth providers
(poe.ts, digitalocean.ts, snowflake-cortex.ts): crypto.subtle for PKCE, a
loopback node:http listener, OauthCallbackPage for the browser response, and
HttpClient for the token exchange.

GitLab specifics worth calling out:

  • Fixed callback port. GitLab matches redirect_uri exactly, so the listener
    cannot use an ephemeral port like poe.ts does. It is pinned to
    http://127.0.0.1:8080/callback; EADDRINUSE reports an actionable message
    (same approach as digitalocean.ts, which pins 1456).
  • redirect_uri on the refresh grant. GitLab requires it there too, unlike
    most providers.
  • Generic invalid_grant. GitLab returns one opaque error for a reused or
    expired code, a PKCE verifier mismatch, a redirect-URI mismatch, and a
    confidential-client misconfiguration alike, so failures are annotated with the
    redirect URI and client ID actually sent (never the code or verifier).
  • Client ID. The bundled application is instance-owned and trusted, and only
    exists on gitlab.com. GITLAB_OAUTH_CLIENT_ID overrides it for self-managed
    instances, and the docs explain how to register one.
  • methodID is pkce, matching the credentials the external plugin already
    issued, so existing logins keep resolving and refreshing.

How the two interact: discovery now prefers the instance an OAuth credential
was issued against over ambient defaults, so a self-managed login discovers
workflow models from the right host rather than
GITLAB_INSTANCE_URL/gitlab.com.

3. Bump gitlab-ai-provider to 6.18.0

packages/core goes from 6.16.0 to 6.18.0 (packages/core/package.json and bun.lock only):

  • 6.17.0 adds a per-account cacheKey for workflow discovery, which this PR passes (review item 6).
  • 6.17.1 fixes the SDK's VERSION constant, which had always reported the previous release in the User-Agent. The package also no longer bundles a copy of its own tarball.
  • 6.18.0 adds the duo-chat-gpt-6-sol and duo-chat-gpt-6-luna mappings. Their models.dev entries are in feat: add gitlab duo-chat-gpt-6-sol and duo-chat-gpt-6-luna models models.dev#7868.

How did you verify your code works?

  • packages/core: bun typecheck clean; root bun run check passes (the
    pre-push hook ran all 35 typecheck tasks successfully).
  • bun test test/plugin/322 pass, 0 fail across 42 files.
  • Discovery (test/plugin/provider-gitlab.test.ts, 12 tests): PAT/OAuth/
    environment credentials, registry population, exact ref routing, and account
    changes/failures.
  • OAuth (test/plugin/provider-gitlab-oauth.test.ts, 15 new tests): method
    registration shape, full authorize -> callback -> exchange against gitlab.com
    and a self-managed URL, credential labelling, refresh (asserting redirect_uri
    and the stored instance), invalid/denied callbacks, failed exchanges preserving
    the previously active connection, EADDRINUSE, and the
    GITLAB_OAUTH_CLIENT_ID override.
  • Live test with SDK 6.15.1: fresh v2 server, empty cache/credential store,
    GITLAB_TOKEN, and the gitlab-org/gitlab checkout. The model API and CLI
    both listed all 54 discovered workflow models with refs and positive token
    limits. That dependency upgrade is tracked separately in chore: bump gitlab-ai-provider to 6.16.0 #50318.

One integration detail the tests surfaced: discovery subscribes to
Credential.Event.Switched and resolves the credential, so it legitimately races
the explicit connection.resolve to refresh the same expired token. The refresh
test asserts on the matching token request rather than on a single queued reply.

Screenshots / recordings

Not a UI change. The new method appears as "Login with GitLab (OAuth)" in the
existing /connect flow.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@vglafirov vglafirov changed the title fix(core): restore GitLab workflow model discovery feat(core): restore GitLab workflow discovery and add OAuth login Sep 21, 2026
vglafirov and others added 3 commits September 22, 2026 23:23
Registers a PKCE OAuth method on the `gitlab` integration so browser login
works without the external opencode-gitlab-auth plugin. Token storage and
refresh scheduling are already owned by core; this supplies the authorize,
refresh, and label implementations plus a self-managed instance URL field.

The bundled client ID is an instance-owned, trusted application registered
for the loopback redirect; GITLAB_OAUTH_CLIENT_ID overrides it for
self-managed instances. GitLab matches redirect_uri exactly and also
requires it on the refresh grant, so the callback port is fixed and
EADDRINUSE reports a clear message.

Workflow discovery now prefers the instance an OAuth credential was issued
against over ambient defaults.
@rekram1-node
rekram1-node force-pushed the restore-gitlab-discovery branch from abea2d3 to 567ec9d Compare September 23, 2026 04:34
@rekram1-node

Copy link
Copy Markdown
Collaborator

This is an automated review generated by a coding agent. None of these are necessarily blockers; raising them for visibility. We'll look further tomorrow.

Branch update: this branch was rebased onto the latest v2 (dropping the merge commits) and force-pushed, plus one commit fixing a typecheck error. provider-gitlab-oauth.test.ts built a connection without the method field, which became required in #50267. If you have the branch checked out locally, reset to the remote before pushing again.

OAuth

  1. Existing logins may fail to refresh. Every published version of opencode-gitlab-auth (1.3.3 to 3.0.0) uses client ID 1d89f9fd…. gitlab-ai-provider names that ID OPENCODE_GITLAB_AUTH_CLIENT_ID and uses it as its gitlab.com default. This PR bundles fd180700…. Refresh tokens are tied to the application that issued them, so credentials created by the external plugin would likely be rejected when core refreshes them with the new ID. That conflicts with the description's note that reusing pkce keeps existing logins refreshing. Which application is meant to be the long-term one?
  2. Concurrent refreshes can collide. GitLab issues a new refresh token on each refresh, and core's connection.resolve doesn't deduplicate concurrent refreshes. Discovery now resolves the credential at startup in every Location. With an expired token, several refreshes can race and all but one fail. The comment in the refresh test about queueing three replies describes this race. gitlab-ai-provider guards against it with an in-flight map in exchangeRefreshToken. This is partly a core gap (OpenAI and Console have similar exposure), but GitLab's 2-hour tokens make it more frequent.
  3. Refresh failures show sign-in hints. The refresh path also uses describeGrantFailure, so a revoked refresh token reports "the authorization code was already used…".
  4. Self-managed rough edges.
    • normalizeInstanceUrl keeps only the protocol and host, so instances served under a relative URL root (e.g. https://example.com/gitlab) break.
    • The form accepts any instance URL, but the bundled application only exists on gitlab.com. Without GITLAB_OAUTH_CLIENT_ID, users land on a GitLab error page. Failing early with a clear message would help.

Discovery

  1. No timeout. The other discovery plugins bound their fetch (Copilot 5s, Modal 3s, DigitalOcean 10s). Discovered models are tagged with sourceConnection, so after an account switch core hides the whole GitLab provider until discovery for the new account finishes. An unresponsive host keeps it hidden and holds the semaphore for later switches.
  2. The SDK cache isn't per account. discoverWorkflowModels caches results for 10 minutes, keyed by directory and instance URL. Switching accounts within that window returns the previous account's models, which then get tagged with the new connection.
  3. GITLAB_TOKEN now triggers discovery. v1 only ran discovery for stored logins. This also runs with GITLAB_TOKEN or a configured apiKey. Users who set GITLAB_TOKEN for other tools now get a git config call per Location. For GitLab-hosted repos they also get REST and GraphQL requests plus a cache write in ~/.cache/opencode/. This may be intended, but it's worth calling out.

Tests / minor

  1. The "labels a stored credential with the instance host" test creates the credential directly and asserts label: "default". The label hook only runs when an OAuth login completes, so it's currently untested.
  2. Small consistency points compared with poe.ts and digitalocean.ts:
    • The EADDRINUSE check uses type casts, where DigitalOcean's listen helper is typed.
    • parsed._tag === "Some" is used where poe.ts uses Option.getOrUndefined.
    • Helpers sit above the export rather than below it.

@vglafirov

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Addressed in 27cd0aa and a30dd17 (on top of your rebase):

OAuth

  1. Client ID. fd180700… stays the long-term application. New and refreshed credentials now record metadata.clientID, and refreshes always use the recorded application. Credentials without one (issued by opencode-gitlab-auth) try the bundled app first and, on invalid_grant/invalid_client, retry once with the legacy 1d89f9fd… app (a rejected refresh doesn't consume the token). The working ID is then recorded, so the fallback runs at most once per legacy credential.
  2. Concurrent refreshes. Refreshes are single-flighted per instanceUrl + refresh token at module level, so discovery in every Location and explicit resolves share one exchange. Successful and rejected outcomes are retained for 60s, because callers that read the credential just before the rotated one is stored would otherwise spend the used token again. The test now asserts exactly one token request, plus a new test runs three concurrent resolves. The general connection.resolve gap in core (OpenAI/Console) still exists, and I'm happy to open an issue for it.
  3. Refresh errors. Refresh uses its own message ("refresh token was revoked, expired, or issued to a different OAuth application… Sign in to GitLab again") instead of the authorization-code hints.
  4. Self-managed.
    • normalizeInstanceUrl keeps the path, so https://example.com/gitlab works (tested for authorize, token exchange, and stored metadata).
    • authorize fails right away for non-gitlab.com hosts when GITLAB_OAUTH_CLIENT_ID isn't set, with instructions for registering an application.

Discovery

  1. Timeout. Discovery is limited to 10s, and the SDK is given a fetch that honors the interruption signal, so a hung host no longer keeps the provider hidden or holds the permit. A test covers a hanging discovery followed by a successful switch.
  2. Per-account cache. Added an optional cacheKey to discoverWorkflowModels in gitlab-ai-provider (https://gitlab.com/vglafirov/gitlab-ai-provider/-/merge_requests/45). It shipped in 6.17.0, which a30dd17 bumps to, and core passes the credential ID. I checked it live against gitlab.com: the same key is served from cache, a different key gets its own entry, and calls without cacheKey hash exactly as before, so v1 and existing cache files are unaffected.
  3. GITLAB_TOKEN. Now matches v1: only a stored login (OAuth or PAT via /connect) triggers discovery. GITLAB_TOKEN and a configured apiKey alone no longer do. Docs note added.

Tests / minor

  1. The label test now goes through the full OAuth flow without an explicit label and asserts the credential is named after the host.
  2. Added a typed listen(server) helper like DigitalOcean's, switched to Option.getOrUndefined, and moved the helpers below the export.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants