From 36a53e3f8ed16844fbd183c45e36054863abb640 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 21 Sep 2026 22:02:10 +0200 Subject: [PATCH 1/7] fix(core): restore GitLab workflow model discovery --- packages/core/src/plugin/provider/gitlab.ts | 100 +++++++++- .../core/test/plugin/provider-gitlab.test.ts | 188 +++++++++++++++++- 2 files changed, 284 insertions(+), 4 deletions(-) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 6d0e52b33054..be6d66425bbc 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,12 +1,108 @@ import os from "os" import { App } from "../../app.js" -import { Effect } from "effect" +import { Effect, Semaphore, Stream } from "effect" import { define } from "@opencode/plugin/effect/plugin" +import type { DiscoveredWorkflowModel } from "gitlab-ai-provider" +import { Bus } from "../../bus.js" +import { Credential } from "../../credential.js" +import { Integration } from "../../integration.js" +import { IntegrationConnection } from "../../integration/connection.js" +import { Model } from "../../model.js" import { Provider } from "../../provider.js" +import type { PluginInternal } from "../internal.js" + +const providerID = Provider.ID.gitlab +const integrationID = Integration.ID.make("gitlab") export const GitLabPlugin = define({ id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { + const providers = yield* Provider.Service + const bus = yield* Bus.Service + const loading = Semaphore.makeUnsafe(1) + const loaded: { + models?: DiscoveredWorkflowModel[] + connection?: Effect.Success> + } = {} + + const load = Effect.fn("GitLabPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active(integrationID) + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) + : undefined + const provider = yield* providers.get(providerID) + const apiKey = + credential?.type === "oauth" + ? credential.access + : (credential?.key ?? + (typeof provider?.settings?.apiKey === "string" ? provider.settings.apiKey : process.env.GITLAB_TOKEN)) + if (!apiKey) { + loaded.models = undefined + loaded.connection = undefined + return + } + + const instanceUrl = + typeof provider?.settings?.instanceUrl === "string" + ? provider.settings.instanceUrl + : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com") + // The SDK owns project detection, GraphQL discovery, caching and token limits. + const remote = yield* Effect.tryPromise({ + try: async () => { + const { discoverWorkflowModels } = await import("gitlab-ai-provider") + return discoverWorkflowModels( + { + instanceUrl, + getHeaders: (): Record => + credential?.type === "oauth" ? { Authorization: `Bearer ${apiKey}` } : { "PRIVATE-TOKEN": apiKey }, + }, + { workingDirectory: ctx.location.directory }, + ) + }, + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to discover GitLab workflow models", { cause }).pipe(Effect.as(undefined)), + ), + ) + if ( + IntegrationConnection.key(connection) !== + IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID)) + ) + return + loaded.models = remote?.models + loaded.connection = connection + }) + + yield* ctx.provider.transform((editor) => { + const item = editor.get(providerID) + if (!item || !loaded.models?.length) return + editor.add({ + info: item.provider, + sourceConnection: loaded.connection, + models: [ + ...item.models.values(), + ...loaded.models + .filter((model) => !item.models.has(model.id)) + .map((model) => ({ + ...Model.Info.default(providerID, Model.ID.make(model.id)), + name: `Agent Platform (${model.name})`, + package: Provider.aisdk("gitlab-ai-provider"), + settings: { workflowRef: model.ref }, + capabilities: { tools: true, input: ["text", "image", "pdf"], output: ["text"] }, + limit: { context: model.context, output: model.output }, + })), + ], + }) + }) + const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload()))) + yield* bus.subscribe(Credential.Event.Switched).pipe( + Stream.filter((event) => event.data.integrationID === integrationID), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh().pipe(Effect.forkScoped) + yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { @@ -62,4 +158,4 @@ export const GitLabPlugin = define({ }), ) }), -}) +} satisfies PluginInternal.InternalPlugin) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index 03be47cc1fe9..b1d7b3a66468 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,17 +1,68 @@ import { AISDK } from "@opencode/core/aisdk" -import { describe, expect, mock } from "bun:test" +import { beforeEach, describe, expect, mock } from "bun:test" import { Effect } from "effect" +import type { WorkflowDiscoveryConfig, WorkflowDiscoveryOptions, WorkflowDiscoveryResult } from "gitlab-ai-provider" +import { Credential } from "@opencode/core/credential" +import { Integration } from "@opencode/core/integration" +import { Location } from "@opencode/core/location" import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { GitLabPlugin } from "@opencode/core/plugin/provider/gitlab" import { Provider } from "@opencode/core/provider" import { withEnv } from "../fixture/env" +import { drain } from "../lib/clock" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" const gitlabSDKOptions: Record[] = [] +const discoverWorkflowModels = mock( + async (_config: WorkflowDiscoveryConfig, _options: WorkflowDiscoveryOptions): Promise => ({ + models: [], + project: null, + }), +) const it = testEffect(PluginTestLayer) +const providerID = Provider.ID.gitlab +const integrationID = Integration.ID.make("gitlab") + +const discovered: WorkflowDiscoveryResult = { + project: null, + models: [ + { + id: "duo-workflow-new-model", + ref: "new_model_ref", + name: "New Model", + context: 128_000, + output: 16_000, + pinned: false, + }, + { + id: "duo-workflow-default", + ref: "__default__duo_agent_platform_agentic_chat", + name: "Default", + context: 200_000, + output: 64_000, + pinned: false, + }, + ], +} + +const discoveryFixture = Effect.gen(function* () { + const integrations = yield* Integration.Service + const providers = yield* Provider.Service + yield* integrations.transform((editor) => { + editor.method.update({ integrationID, method: { type: "key" } }) + editor.method.update({ integrationID, method: { type: "env", names: ["GITLAB_TOKEN"] } }) + }) + yield* providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.package = Provider.aisdk("gitlab-ai-provider") + }) + editor.models.update(providerID, Model.ID.make("duo-chat-sonnet-5"), () => {}) + }) + return { providers, models: yield* Model.Service, credentials: yield* Credential.Service } +}) const addPlugin = Effect.fn(function* () { const plugin = yield* Plugin.Service @@ -28,11 +79,144 @@ void mock.module("gitlab-ai-provider", () => ({ workflowChat: (id: string, options: unknown) => ({ id, options, type: "workflow" }), } }, - discoverWorkflowModels: async () => ({ models: [], project: undefined }), + discoverWorkflowModels, isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", })) describe("GitLabPlugin", () => { + beforeEach(() => { + discoverWorkflowModels.mockReset() + discoverWorkflowModels.mockResolvedValue({ models: [], project: null }) + }) + + it.effect("discovers workflow models for the location and exposes their refs in the model registry", () => + withEnv({ GITLAB_TOKEN: undefined }, () => + Effect.gen(function* () { + const fixture = yield* discoveryFixture + const location = yield* Location.Service + const aisdk = yield* AISDK.Service + yield* fixture.providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.settings = { instanceUrl: "https://configured.gitlab.example" } + }) + }) + yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "pat-token" } }) + discoverWorkflowModels.mockResolvedValue(discovered) + yield* addPlugin() + yield* drain + + expect(discoverWorkflowModels).toHaveBeenCalledTimes(1) + const [config, options] = discoverWorkflowModels.mock.calls[0]! + expect(options).toEqual({ workingDirectory: location.directory }) + expect(config.instanceUrl).toBe("https://configured.gitlab.example") + expect(config.getHeaders()).toEqual({ "PRIVATE-TOKEN": "pat-token" }) + expect((yield* fixture.models.available()).map((model) => model.id).sort()).toEqual([ + Model.ID.make("duo-chat-sonnet-5"), + Model.ID.make("duo-workflow-default"), + Model.ID.make("duo-workflow-new-model"), + ]) + const model = (yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-new-model")))! + expect(model).toMatchObject({ + name: "Agent Platform (New Model)", + package: "aisdk:gitlab-ai-provider", + settings: { workflowRef: "new_model_ref" }, + limit: { context: 128_000, output: 16_000 }, + capabilities: { tools: true }, + }) + const result = yield* aisdk.runLanguage({ + model, + options: {}, + sdk: { workflowChat: (id: string) => ({ id }) }, + }) + expect(result.language as unknown).toMatchObject({ id: "duo-workflow", selectedModelRef: "new_model_ref" }) + }), + ), + ) + + it.effect("uses OAuth access tokens for discovery", () => + withEnv({ GITLAB_TOKEN: undefined }, () => + Effect.gen(function* () { + const fixture = yield* discoveryFixture + yield* fixture.credentials.create({ + integrationID, + value: { + type: "oauth", + methodID: Integration.MethodID.make("oauth"), + access: "access-token", + refresh: "refresh-token", + expires: 0, + }, + }) + yield* addPlugin() + yield* drain + expect(discoverWorkflowModels).toHaveBeenCalledTimes(1) + expect(discoverWorkflowModels.mock.calls[0]![0].getHeaders()).toEqual({ Authorization: "Bearer access-token" }) + }), + ), + ) + + it.effect("discovers with environment credentials without replacing configured model definitions", () => + withEnv({ GITLAB_TOKEN: "env-token", GITLAB_INSTANCE_URL: "https://env.gitlab.example" }, () => + Effect.gen(function* () { + const fixture = yield* discoveryFixture + yield* fixture.providers.transform((editor) => { + editor.models.update(providerID, Model.ID.make("duo-workflow-new-model"), (model) => { + model.name = "Configured model" + model.limit.output = 42 + }) + }) + discoverWorkflowModels.mockResolvedValue(discovered) + yield* addPlugin() + yield* drain + expect(discoverWorkflowModels.mock.calls[0]![0].instanceUrl).toBe("https://env.gitlab.example") + expect(discoverWorkflowModels.mock.calls[0]![0].getHeaders()).toEqual({ "PRIVATE-TOKEN": "env-token" }) + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-new-model"))).toMatchObject({ + name: "Configured model", + limit: { output: 42 }, + }) + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-default"))).toBeDefined() + }), + ), + ) + + it.effect("skips unauthenticated discovery and refreshes after credential changes, including failures", () => + withEnv({ GITLAB_TOKEN: undefined }, () => + Effect.gen(function* () { + const fixture = yield* discoveryFixture + yield* addPlugin() + yield* drain + expect(discoverWorkflowModels).not.toHaveBeenCalled() + + discoverWorkflowModels.mockResolvedValue(discovered) + yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "first-token" } }) + yield* drain + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-new-model"))).toBeDefined() + + discoverWorkflowModels.mockRejectedValue(new Error("discovery unavailable")) + const credential = yield* fixture.credentials.create({ + integrationID, + value: { type: "key", key: "second-token" }, + }) + yield* drain + expect(discoverWorkflowModels.mock.calls.at(-1)![0].getHeaders()).toEqual({ "PRIVATE-TOKEN": "second-token" }) + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-new-model"))).toBeUndefined() + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-chat-sonnet-5"))).toBeDefined() + + discoverWorkflowModels.mockResolvedValue({ models: [discovered.models[1]!], project: null }) + const latest = yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "third-token" } }) + yield* drain + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-default"))).toBeDefined() + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-new-model"))).toBeUndefined() + + discoverWorkflowModels.mockResolvedValue({ models: [], project: null }) + yield* fixture.credentials.activate(credential.id) + yield* drain + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-default"))).toBeUndefined() + yield* fixture.credentials.remove(latest.id) + }), + ), + ) + it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => withEnv( { From 0f14fc6f949527ea982d1df3cc60f3872b874da0 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 21 Sep 2026 23:18:54 +0200 Subject: [PATCH 2/7] feat(core): add GitLab OAuth (PKCE) login to the built-in provider 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. --- packages/core/src/plugin/provider/gitlab.ts | 246 +++++++++++++- .../test/plugin/provider-gitlab-oauth.test.ts | 315 ++++++++++++++++++ packages/web/src/content/docs/providers.mdx | 15 +- 3 files changed, 562 insertions(+), 14 deletions(-) create mode 100644 packages/core/test/plugin/provider-gitlab-oauth.test.ts diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index be6d66425bbc..9122f9f821ab 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,24 +1,145 @@ import os from "os" import { App } from "../../app.js" -import { Effect, Semaphore, Stream } from "effect" +import { Clock, Deferred, Effect, Schema, Semaphore, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import type { ServerResponse } from "node:http" import { define } from "@opencode/plugin/effect/plugin" +import { Form } from "@opencode/schema/form" import type { DiscoveredWorkflowModel } from "gitlab-ai-provider" import { Bus } from "../../bus.js" import { Credential } from "../../credential.js" import { Integration } from "../../integration.js" import { IntegrationConnection } from "../../integration/connection.js" import { Model } from "../../model.js" +import { OauthCallbackPage } from "../../oauth/page.js" import { Provider } from "../../provider.js" import type { PluginInternal } from "../internal.js" const providerID = Provider.ID.gitlab const integrationID = Integration.ID.make("gitlab") +const methodID = Integration.MethodID.make("pkce") +// Instance-owned, trusted GitLab OAuth application for OpenCode. Registered with +// redirect URI http://127.0.0.1:8080/callback on gitlab.com. Self-managed instances +// need their own application; override with GITLAB_OAUTH_CLIENT_ID in that case. +const bundledClientID = "fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7" +const oauthScope = "api" +const callbackHost = "127.0.0.1" +const callbackPort = 8080 +const redirectURI = `http://${callbackHost}:${callbackPort}/callback` + +const Token = Schema.Struct({ + access_token: Schema.NonEmptyString, + refresh_token: Schema.NonEmptyString, + expires_in: Schema.optional(Schema.Number), +}) +const decodeError = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String) }), + ), +) + +function resolveClientID() { + return process.env.GITLAB_OAUTH_CLIENT_ID?.trim() || bundledClientID +} + +function resolveDefaultInstanceUrl() { + return process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com" +} + +function normalizeInstanceUrl(value: string | undefined) { + const raw = value?.trim() || resolveDefaultInstanceUrl() + const url = new URL(raw) + return `${url.protocol}//${url.host}` +} + +function instanceUrlField() { + const placeholder = resolveDefaultInstanceUrl() + return { + type: "string" as const, + key: "instanceUrl", + title: "GitLab instance URL", + description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", + placeholder, + default: placeholder, + pattern: "^https?://\\S+$", + } +} + +function credentialInstanceUrl(metadata: Readonly> | undefined) { + const value = metadata?.instanceUrl + return typeof value === "string" && value ? value : resolveDefaultInstanceUrl() +} + +// GitLab returns one generic `invalid_grant` for a reused/expired code, a PKCE +// verifier mismatch, a redirect-URI mismatch, and a missing client-secret alike. +// Surface enough context to tell those apart without leaking the verifier/code. +function describeGrantFailure(detail: string | undefined, clientID: string) { + if (!detail) return "GitLab token exchange failed" + if (!detail.includes("invalid_grant") && !detail.includes("invalid_client")) return detail + const custom = clientID !== bundledClientID + const hints = [ + "the authorization code was already used, or came from an older login attempt. Codes are" + + " single-use and bound to one PKCE verifier, so a stale browser tab or a reloaded callback" + + " page fails here. Start a completely fresh login.", + `redirect_uri sent: ${redirectURI} — the application must register this exactly`, + `client_id used: ${clientID.slice(0, 12)}...${custom ? " (from GITLAB_OAUTH_CLIENT_ID)" : " (bundled default)"}`, + ] + if (custom) { + hints.push( + "confirm that application registers the redirect URI above, grants the `api` scope, and is" + + ' NOT marked "Confidential" (PKCE requires a public client). Unset GITLAB_OAUTH_CLIENT_ID to' + + " fall back to the bundled application.", + ) + } + return `${detail}\n\nLikely causes:\n- ${hints.join("\n- ")}` +} + +function exchange(http: HttpClient.HttpClient, instanceUrl: string, clientID: string, body: Record) { + return Effect.gen(function* () { + const response = yield* http + .execute( + HttpClientRequest.post(`${instanceUrl}/oauth/token`).pipe( + HttpClientRequest.bodyUrlParams({ ...body, client_id: clientID }), + ), + ) + .pipe(Effect.mapError(() => new Error("GitLab token exchange request failed"))) + if (response.status < 200 || response.status >= 300) { + const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + const parsed = decodeError(text) + const detail = parsed._tag === "Some" ? parsed.value.error_description || parsed.value.error : undefined + return yield* Effect.fail(new Error(describeGrantFailure(detail ?? `HTTP ${response.status}`, clientID))) + } + return yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe( + Effect.mapError(() => new Error("Invalid GitLab token response")), + ) + }) +} + +function credential(instanceUrl: string, tokens: typeof Token.Type) { + return Effect.map(Clock.currentTimeMillis, (now) => + Credential.OAuth.make({ + type: "oauth", + methodID, + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: now + (tokens.expires_in ?? 7200) * 1000, + metadata: { instanceUrl }, + }), + ) +} + +function callbackError(params: URLSearchParams, state: string) { + if (params.get("state") !== state) return "Invalid OAuth state" + if (params.has("error")) return params.get("error_description") || params.get("error") || "Authorization denied" + return params.get("code")?.trim() ? undefined : "Missing authorization code" +} export const GitLabPlugin = define({ id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { const providers = yield* Provider.Service const bus = yield* Bus.Service + const http = yield* HttpClient.HttpClient const loading = Semaphore.makeUnsafe(1) const loaded: { models?: DiscoveredWorkflowModel[] @@ -42,10 +163,13 @@ export const GitLabPlugin = define({ return } + // An OAuth login pins the instance it was issued against; prefer it over ambient defaults. const instanceUrl = - typeof provider?.settings?.instanceUrl === "string" - ? provider.settings.instanceUrl - : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com") + credential?.type === "oauth" && typeof credential.metadata?.instanceUrl === "string" + ? credential.metadata.instanceUrl + : typeof provider?.settings?.instanceUrl === "string" + ? provider.settings.instanceUrl + : resolveDefaultInstanceUrl() // The SDK owns project detection, GraphQL discovery, caching and token limits. const remote = yield* Effect.tryPromise({ try: async () => { @@ -74,6 +198,120 @@ export const GitLabPlugin = define({ loaded.connection = connection }) + yield* ctx.integration.transform((editor) => { + editor.method.update({ + integrationID, + method: { + id: methodID, + type: "oauth", + label: "Login with GitLab (OAuth)", + form: Form.Fields.make([instanceUrlField()]), + }, + label: (value) => new URL(credentialInstanceUrl(value.metadata)).host, + refresh: (value) => + Effect.gen(function* () { + const instanceUrl = credentialInstanceUrl(value.metadata) + const clientID = resolveClientID() + const tokens = yield* exchange(http, instanceUrl, clientID, { + grant_type: "refresh_token", + refresh_token: value.refresh, + redirect_uri: redirectURI, + }) + return yield* credential(instanceUrl, tokens) + }), + authorize: (answer) => + Effect.gen(function* () { + // Capture once so the authorize request and the token exchange cannot + // disagree if the environment changes mid-flow. + const clientID = resolveClientID() + const instanceUrl = normalizeInstanceUrl( + typeof answer.instanceUrl === "string" ? answer.instanceUrl : undefined, + ) + const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") + const challenge = Buffer.from( + yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))), + ).toString("base64url") + const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") + + const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>() + const { createServer } = yield* Effect.promise(() => import("node:http")) + const { EventEmitter } = yield* Effect.promise(() => import("node:events")) + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://${callbackHost}`) + if (request.method !== "GET" || url.pathname !== "/callback") { + response.writeHead(404).end() + return + } + const error = callbackError(url.searchParams, state) + if (error) { + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "GitLab" })) + Effect.runSync(Deferred.fail(callback, new Error(error))) + return + } + if (!Effect.runSync(Deferred.succeed(callback, { code: url.searchParams.get("code") ?? "", response }))) + response.writeHead(409).end("OAuth callback already received") + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + server.close() + server.closeAllConnections() + }), + ) + // GitLab matches redirect_uri exactly, so the port cannot be ephemeral. + yield* Effect.tryPromise({ + try: () => EventEmitter.once(server.listen(callbackPort, callbackHost), "listening"), + catch: (cause) => + "code" in (cause as { code?: string }) && (cause as { code?: string }).code === "EADDRINUSE" + ? new Error( + `GitLab login needs local port ${callbackPort}, but it is already in use. Stop the process using that port and try again.`, + ) + : cause, + }) + + return { + mode: "auto" as const, + url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({ + client_id: clientID, + redirect_uri: redirectURI, + response_type: "code", + state, + scope: oauthScope, + code_challenge: challenge, + code_challenge_method: "S256", + }).toString()}`, + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Effect.gen(function* () { + const request = yield* Deferred.await(callback) + const respond = (error?: string) => + Effect.sync(() => + request.response + .writeHead(error ? 400 : 200, { "Content-Type": "text/html" }) + .end( + error + ? OauthCallbackPage.error(error, { provider: "GitLab" }) + : OauthCallbackPage.success({ provider: "GitLab" }), + ), + ) + return yield* exchange(http, instanceUrl, clientID, { + grant_type: "authorization_code", + code: request.code, + redirect_uri: redirectURI, + code_verifier: verifier, + }).pipe( + Effect.flatMap((tokens) => credential(instanceUrl, tokens)), + Effect.tap(() => respond()), + Effect.tapError((error) => respond(error.message)), + // Bun's server.closeAllConnections() leaves an unanswered callback response pending. + Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())), + ) + }), + } + }), + }) + }) + yield* ctx.provider.transform((editor) => { const item = editor.get(providerID) if (!item || !loaded.models?.length) return diff --git a/packages/core/test/plugin/provider-gitlab-oauth.test.ts b/packages/core/test/plugin/provider-gitlab-oauth.test.ts new file mode 100644 index 000000000000..1d2d25224078 --- /dev/null +++ b/packages/core/test/plugin/provider-gitlab-oauth.test.ts @@ -0,0 +1,315 @@ +import { describe, expect } from "bun:test" +import { Clock, Effect, Schedule } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { Credential } from "@opencode/core/credential" +import { Integration } from "@opencode/core/integration" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { GitLabPlugin } from "@opencode/core/plugin/provider/gitlab" +import { ProviderPlugins } from "@opencode/core/plugin/provider" +import { withEnv } from "../fixture/env" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) +const integrationID = Integration.ID.make("gitlab") +const methodID = Integration.MethodID.make("pkce") +const bundledClientID = "fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7" + +const fixture = Effect.fn(function* () { + const requests: Request[] = [] + const replies: (Response | Effect.Effect)[] = [] + const http = HttpClient.make((request) => + Effect.gen(function* () { + requests.push(yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)) + const response = replies.shift() + if (!response) throw new Error(`Unexpected request: ${request.url}`) + return HttpClientResponse.fromWeb(request, yield* Effect.isEffect(response) ? response : Effect.succeed(response)) + }), + ) + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + yield* integrations.transform((editor) => { + editor.method.update({ integrationID, method: { type: "key" } }) + }) + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* GitLabPlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http)) + const status = (attemptID: Integration.AttemptID) => + integrations.oauth + .status({ integrationID, attemptID }) + .pipe( + Effect.repeat({ until: (value) => value.status !== "pending", schedule: Schedule.spaced("1 millis") }), + Effect.timeout("3 seconds"), + ) + const connect = Effect.gen(function* () { + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "GitLab login" }) + const url = new URL(attempt.url) + const callback = new URL(url.searchParams.get("redirect_uri") ?? "") + callback.searchParams.set("state", url.searchParams.get("state") ?? "") + callback.searchParams.set("code", "auth-code") + return { attempt, url, callback } + }) + return { requests, replies, integrations, credentials, status, connect } +}) + +describe("GitLabPlugin OAuth", () => { + it.effect("is registered alongside the other provider plugins", () => + Effect.gen(function* () { + expect(ProviderPlugins).toContain(GitLabPlugin) + }), + ) + + it.effect("registers PKCE OAuth alongside the generic key/env methods from ModelsDevPlugin", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* integrations.transform((editor) => { + editor.method.update({ integrationID, method: { type: "key" } }) + editor.method.update({ integrationID, method: { type: "env", names: ["GITLAB_TOKEN"] } }) + }) + const test = yield* fixture() + expect((yield* integrations.get(integrationID))?.methods).toEqual([ + { type: "key" }, + { type: "env", names: ["GITLAB_TOKEN"] }, + { + id: methodID, + type: "oauth", + label: "Login with GitLab (OAuth)", + form: [ + { + type: "string", + key: "instanceUrl", + title: "GitLab instance URL", + description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", + placeholder: "https://gitlab.com", + default: "https://gitlab.com", + pattern: "^https?://\\S+$", + }, + ], + }, + ]) + expect(test.requests).toHaveLength(0) + }), + ) + + it.live("exchanges a PKCE code for a native GitLab OAuth credential against gitlab.com by default", () => + Effect.gen(function* () { + const test = yield* fixture() + const login = yield* test.connect + expect(login.url.origin + login.url.pathname).toBe("https://gitlab.com/oauth/authorize") + expect(Object.fromEntries(login.url.searchParams)).toMatchObject({ + response_type: "code", + client_id: bundledClientID, + scope: "api", + code_challenge_method: "S256", + }) + expect(login.url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback") + expect(login.callback.hostname).toBe("127.0.0.1") + expect(login.callback.port).toBe("8080") + expect(login.callback.pathname).toBe("/callback") + + const now = Date.now() + test.replies.push( + Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 7200 }), + ) + const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect(page.status).toBe(200) + expect(yield* Effect.promise(() => page.text())).toContain("Authorization successful") + expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete") + + const exchange = test.requests[0] + expect(exchange.url).toBe("https://gitlab.com/oauth/token") + expect(exchange.headers.get("content-type")).toContain("application/x-www-form-urlencoded") + const form = new URLSearchParams(yield* Effect.promise(() => exchange.text())) + expect(Object.fromEntries(form)).toMatchObject({ + grant_type: "authorization_code", + client_id: bundledClientID, + code: "auth-code", + redirect_uri: "http://127.0.0.1:8080/callback", + }) + expect(login.url.searchParams.get("code_challenge")).toBe( + Buffer.from( + yield* Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")), + ), + ).toString("base64url"), + ) + + const saved = (yield* test.credentials.list(integrationID))[0]?.value + if (saved?.type !== "oauth") throw new Error("Expected OAuth credential") + expect(saved.access).toBe("access-token") + expect(saved.refresh).toBe("refresh-token") + expect(saved.metadata).toEqual({ instanceUrl: "https://gitlab.com" }) + expect(saved.expires).toBeGreaterThanOrEqual(now + 7_200_000) + expect(saved.expires).toBeLessThanOrEqual(Date.now() + 7_200_000) + }), + ) + + it.live("uses the answered self-managed instance URL for authorize and token exchange", () => + Effect.gen(function* () { + const test = yield* fixture() + const integrations = yield* Integration.Service + const attempt = yield* integrations.oauth.connect({ + integrationID, + methodID, + answer: { instanceUrl: "https://gitlab.example.com/" }, + }) + const url = new URL(attempt.url) + expect(url.origin + url.pathname).toBe("https://gitlab.example.com/oauth/authorize") + const callback = new URL(url.searchParams.get("redirect_uri") ?? "") + callback.searchParams.set("state", url.searchParams.get("state") ?? "") + callback.searchParams.set("code", "auth-code") + test.replies.push( + Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }), + ) + yield* Effect.promise(() => fetch(callback, { headers: { Connection: "close" } })) + expect((yield* test.status(attempt.attemptID)).status).toBe("complete") + expect(test.requests[0]?.url).toBe("https://gitlab.example.com/oauth/token") + const saved = (yield* test.credentials.list(integrationID))[0]?.value + expect(saved?.metadata).toEqual({ instanceUrl: "https://gitlab.example.com" }) + }), + ) + + it.effect("labels a stored credential with the instance host", () => + Effect.gen(function* () { + yield* fixture() + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + const saved = yield* credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 0, + metadata: { instanceUrl: "https://gitlab.example.com" }, + }), + }) + const active = yield* integrations.connection.active(integrationID) + expect(active).toMatchObject({ type: "credential", id: saved.id, label: "default" }) + }), + ) + + it.effect("refreshes an OAuth credential using its stored instance URL and includes redirect_uri", () => + Effect.gen(function* () { + const test = yield* fixture() + const credential = Credential.OAuth.make({ + type: "oauth", + methodID, + access: "stale-access", + refresh: "stored-refresh", + expires: 1, + metadata: { instanceUrl: "https://gitlab.example.com" }, + }) + const saved = yield* test.credentials.create({ integrationID, value: credential }) + // Creating the credential also wakes workflow discovery, which resolves the same + // expired credential and races for a refresh. Answer every refresh identically and + // assert on the token requests rather than on a single queued reply. + const renewal = () => + Response.json({ access_token: "renewed-access", refresh_token: "renewed-refresh", expires_in: 3600 }) + test.replies.push(renewal(), renewal(), renewal()) + const connection = { type: "credential" as const, id: saved.id, label: saved.label } + const now = yield* Clock.currentTimeMillis + const resolved = yield* test.integrations.connection.resolve(connection) + if (resolved?.type !== "oauth") throw new Error("Expected OAuth credential") + expect(resolved.access).toBe("renewed-access") + expect(resolved.refresh).toBe("renewed-refresh") + expect(resolved.expires).toBeGreaterThanOrEqual(now + 3_600_000) + + const tokenRequest = test.requests.find((request) => request.url.endsWith("/oauth/token")) + expect(tokenRequest?.url).toBe("https://gitlab.example.com/oauth/token") + const refresh = new URLSearchParams(yield* Effect.promise(() => tokenRequest?.text() ?? Promise.resolve(""))) + expect(Object.fromEntries(refresh)).toMatchObject({ + grant_type: "refresh_token", + refresh_token: "stored-refresh", + client_id: bundledClientID, + redirect_uri: "http://127.0.0.1:8080/callback", + }) + }), + ) + + for (const invalid of [ + { params: { state: "" }, message: "Invalid OAuth state" }, + { params: { code: "" }, message: "Missing authorization code" }, + { params: { error: "access_denied", error_description: "User declined access" }, message: "User declined access" }, + { params: { error: "access_denied" }, message: "access_denied" }, + ]) { + it.live(`rejects invalid or denied callbacks (${JSON.stringify(invalid.params)})`, () => + Effect.gen(function* () { + const test = yield* fixture() + const login = yield* test.connect + Object.entries(invalid.params).forEach(([key, value]) => login.callback.searchParams.set(key, value)) + const response = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect(response.status).toBe(400) + expect(yield* Effect.promise(() => response.text())).toContain("Authorization failed") + expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ + status: "failed", + message: invalid.message, + }) + expect(test.requests).toHaveLength(0) + expect(yield* test.credentials.list(integrationID)).toEqual([]) + }), + ) + } + + for (const response of [ + { + status: 400, + body: JSON.stringify({ error: "invalid_grant", error_description: "Code expired" }), + contains: "Code expired", + }, + { + status: 400, + body: JSON.stringify({ error: "invalid_grant" }), + contains: "client_id used:", + }, + { status: 502, body: "Bad gateway", contains: "HTTP 502" }, + ]) { + it.live(`preserves the active connection after a failed token exchange (${response.status})`, () => + Effect.gen(function* () { + const test = yield* fixture() + yield* test.integrations.connection.key({ integrationID, key: "previous-key" }) + const previous = yield* test.integrations.connection.active(integrationID) + const saved = yield* test.credentials.list(integrationID) + const login = yield* test.connect + test.replies.push(new Response(response.body, { status: response.status })) + const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect(page.status).toBe(400) + const text = yield* Effect.promise(() => page.text()) + expect(text).toContain(response.contains) + expect(yield* test.credentials.list(integrationID)).toEqual(saved) + expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous) + }), + ) + } + + it.live("reports EADDRINUSE with a clear message when the callback port is occupied", () => + Effect.gen(function* () { + const { createServer } = yield* Effect.promise(() => import("node:http")) + const { EventEmitter } = yield* Effect.promise(() => import("node:events")) + const blocker = createServer() + yield* Effect.tryPromise(() => EventEmitter.once(blocker.listen(8080, "127.0.0.1"), "listening")) + yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close())) + const test = yield* fixture() + const error = yield* test.integrations.oauth.connect({ integrationID, methodID }).pipe(Effect.flip) + expect(error.message).toContain("port 8080") + expect(error.message).toContain("already in use") + }), + ) + + it.live("honors GITLAB_OAUTH_CLIENT_ID for self-managed applications", () => + withEnv({ GITLAB_OAUTH_CLIENT_ID: "custom-client-id" }, () => + Effect.gen(function* () { + const test = yield* fixture() + const login = yield* test.connect + expect(login.url.searchParams.get("client_id")).toBe("custom-client-id") + test.replies.push(Response.json({ access_token: "a", refresh_token: "r", expires_in: 3600 })) + yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete") + const form = new URLSearchParams(yield* Effect.promise(() => test.requests[0]?.text() ?? Promise.resolve(""))) + expect(form.get("client_id")).toBe("custom-client-id") + }), + ), + ) +}) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index a5a17de3a34d..fc04b213ec9c 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1009,22 +1009,17 @@ Your GitLab administrator must: ##### OAuth for Self-Hosted instances -In order to make Oauth working for your self-hosted instance, you need to create -a new application (Settings → Applications) with the -callback URL `http://127.0.0.1:8080/callback` and following scopes: +OpenCode's bundled GitLab OAuth application only exists on gitlab.com. To use +OAuth with a self-hosted instance, create your own application (Settings → +Applications) with the callback URL `http://127.0.0.1:8080/callback`, the +`api` scope, and **Confidential** unchecked (PKCE requires a public client). -- api (Access the API on your behalf) -- read_user (Read your personal information) -- read_repository (Allows read-only access to the repository) - -Then expose application ID as environment variable: +Then expose the application ID as an environment variable: ```bash export GITLAB_OAUTH_CLIENT_ID=your_application_id_here ``` -More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) homepage. - ##### Configuration Customize through `opencode.json`: From da6b0f1c4d930d82ff1193da3e740fd7f65eb178 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 22 Sep 2026 23:34:46 -0500 Subject: [PATCH 3/7] fix(core): include connection method in GitLab OAuth refresh test --- packages/core/test/plugin/provider-gitlab-oauth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/plugin/provider-gitlab-oauth.test.ts b/packages/core/test/plugin/provider-gitlab-oauth.test.ts index 1d2d25224078..e1119776ac45 100644 --- a/packages/core/test/plugin/provider-gitlab-oauth.test.ts +++ b/packages/core/test/plugin/provider-gitlab-oauth.test.ts @@ -209,7 +209,7 @@ describe("GitLabPlugin OAuth", () => { const renewal = () => Response.json({ access_token: "renewed-access", refresh_token: "renewed-refresh", expires_in: 3600 }) test.replies.push(renewal(), renewal(), renewal()) - const connection = { type: "credential" as const, id: saved.id, label: saved.label } + const connection = { type: "credential" as const, id: saved.id, label: saved.label, method: "oauth" as const } const now = yield* Clock.currentTimeMillis const resolved = yield* test.integrations.connection.resolve(connection) if (resolved?.type !== "oauth") throw new Error("Expected OAuth credential") From 0c45e5fd9c6c7af32fb1e9d70e5918c54b5f3a55 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Wed, 23 Sep 2026 12:02:15 +0200 Subject: [PATCH 4/7] fix(core): address GitLab OAuth and discovery review feedback --- packages/core/src/plugin/provider/gitlab.ts | 401 +++++++++++------- .../test/plugin/provider-gitlab-oauth.test.ts | 325 +++++++++----- .../core/test/plugin/provider-gitlab.test.ts | 65 ++- packages/web/src/content/docs/providers.mdx | 6 + 4 files changed, 544 insertions(+), 253 deletions(-) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9122f9f821ab..c94d98ef3b69 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,8 +1,8 @@ import os from "os" import { App } from "../../app.js" -import { Clock, Deferred, Effect, Schema, Semaphore, Stream } from "effect" +import { Clock, Deferred, Effect, Exit, Option, Schema, Semaphore, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import type { ServerResponse } from "node:http" +import type { Server, ServerResponse } from "node:http" import { define } from "@opencode/plugin/effect/plugin" import { Form } from "@opencode/schema/form" import type { DiscoveredWorkflowModel } from "gitlab-ai-provider" @@ -22,6 +22,11 @@ const methodID = Integration.MethodID.make("pkce") // redirect URI http://127.0.0.1:8080/callback on gitlab.com. Self-managed instances // need their own application; override with GITLAB_OAUTH_CLIENT_ID in that case. const bundledClientID = "fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7" +// Application used by every opencode-gitlab-auth release (and gitlab-ai-provider's +// OPENCODE_GITLAB_AUTH_CLIENT_ID). Refresh tokens stay bound to the issuing +// application, so credentials created by that plugin must refresh with it. +const legacyClientID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e" +const gitlabComHost = "gitlab.com" const oauthScope = "api" const callbackHost = "127.0.0.1" const callbackPort = 8080 @@ -37,102 +42,13 @@ const decodeError = Schema.decodeUnknownOption( Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String) }), ), ) - -function resolveClientID() { - return process.env.GITLAB_OAUTH_CLIENT_ID?.trim() || bundledClientID -} - -function resolveDefaultInstanceUrl() { - return process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com" -} - -function normalizeInstanceUrl(value: string | undefined) { - const raw = value?.trim() || resolveDefaultInstanceUrl() - const url = new URL(raw) - return `${url.protocol}//${url.host}` -} - -function instanceUrlField() { - const placeholder = resolveDefaultInstanceUrl() - return { - type: "string" as const, - key: "instanceUrl", - title: "GitLab instance URL", - description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", - placeholder, - default: placeholder, - pattern: "^https?://\\S+$", - } -} - -function credentialInstanceUrl(metadata: Readonly> | undefined) { - const value = metadata?.instanceUrl - return typeof value === "string" && value ? value : resolveDefaultInstanceUrl() -} - -// GitLab returns one generic `invalid_grant` for a reused/expired code, a PKCE -// verifier mismatch, a redirect-URI mismatch, and a missing client-secret alike. -// Surface enough context to tell those apart without leaking the verifier/code. -function describeGrantFailure(detail: string | undefined, clientID: string) { - if (!detail) return "GitLab token exchange failed" - if (!detail.includes("invalid_grant") && !detail.includes("invalid_client")) return detail - const custom = clientID !== bundledClientID - const hints = [ - "the authorization code was already used, or came from an older login attempt. Codes are" + - " single-use and bound to one PKCE verifier, so a stale browser tab or a reloaded callback" + - " page fails here. Start a completely fresh login.", - `redirect_uri sent: ${redirectURI} — the application must register this exactly`, - `client_id used: ${clientID.slice(0, 12)}...${custom ? " (from GITLAB_OAUTH_CLIENT_ID)" : " (bundled default)"}`, - ] - if (custom) { - hints.push( - "confirm that application registers the redirect URI above, grants the `api` scope, and is" + - ' NOT marked "Confidential" (PKCE requires a public client). Unset GITLAB_OAUTH_CLIENT_ID to' + - " fall back to the bundled application.", - ) - } - return `${detail}\n\nLikely causes:\n- ${hints.join("\n- ")}` -} - -function exchange(http: HttpClient.HttpClient, instanceUrl: string, clientID: string, body: Record) { - return Effect.gen(function* () { - const response = yield* http - .execute( - HttpClientRequest.post(`${instanceUrl}/oauth/token`).pipe( - HttpClientRequest.bodyUrlParams({ ...body, client_id: clientID }), - ), - ) - .pipe(Effect.mapError(() => new Error("GitLab token exchange request failed"))) - if (response.status < 200 || response.status >= 300) { - const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")) - const parsed = decodeError(text) - const detail = parsed._tag === "Some" ? parsed.value.error_description || parsed.value.error : undefined - return yield* Effect.fail(new Error(describeGrantFailure(detail ?? `HTTP ${response.status}`, clientID))) - } - return yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe( - Effect.mapError(() => new Error("Invalid GitLab token response")), - ) - }) -} - -function credential(instanceUrl: string, tokens: typeof Token.Type) { - return Effect.map(Clock.currentTimeMillis, (now) => - Credential.OAuth.make({ - type: "oauth", - methodID, - access: tokens.access_token, - refresh: tokens.refresh_token, - expires: now + (tokens.expires_in ?? 7200) * 1000, - metadata: { instanceUrl }, - }), - ) -} - -function callbackError(params: URLSearchParams, state: string) { - if (params.get("state") !== state) return "Invalid OAuth state" - if (params.has("error")) return params.get("error_description") || params.get("error") || "Authorization denied" - return params.get("code")?.trim() ? undefined : "Missing authorization code" -} +const discoveryTimeout = "10 seconds" +// GitLab rotates the refresh token on every refresh, so concurrent refreshes of the same +// credential (e.g. discovery in several Locations plus a request) must share one exchange. +// Settled outcomes are kept briefly: a caller that read the credential just before the +// rotated one was stored would otherwise spend the already-used refresh token again. +const refreshing = new Map; until: number }>() +const refreshRetention = 60_000 export const GitLabPlugin = define({ id: "opencode.provider.gitlab", @@ -148,43 +64,51 @@ export const GitLabPlugin = define({ const load = Effect.fn("GitLabPlugin.load")(function* () { const connection = yield* ctx.integration.connection.active(integrationID) - const credential = connection - ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) + // Like v1, only stored logins drive discovery; ambient GITLAB_TOKEN users are not probed. + const stored = connection?.type === "credential" ? connection : undefined + const credential = stored + ? yield* ctx.integration.connection.resolve(stored).pipe(Effect.orElseSucceed(() => undefined)) : undefined - const provider = yield* providers.get(providerID) - const apiKey = - credential?.type === "oauth" - ? credential.access - : (credential?.key ?? - (typeof provider?.settings?.apiKey === "string" ? provider.settings.apiKey : process.env.GITLAB_TOKEN)) - if (!apiKey) { + if (!stored || !credential) { loaded.models = undefined loaded.connection = undefined return } + const provider = yield* providers.get(providerID) // An OAuth login pins the instance it was issued against; prefer it over ambient defaults. const instanceUrl = - credential?.type === "oauth" && typeof credential.metadata?.instanceUrl === "string" + credential.type === "oauth" && typeof credential.metadata?.instanceUrl === "string" ? credential.metadata.instanceUrl : typeof provider?.settings?.instanceUrl === "string" ? provider.settings.instanceUrl : resolveDefaultInstanceUrl() + const headers: Record = + credential.type === "oauth" + ? { Authorization: `Bearer ${credential.access}` } + : { "PRIVATE-TOKEN": credential.key } // The SDK owns project detection, GraphQL discovery, caching and token limits. const remote = yield* Effect.tryPromise({ - try: async () => { + try: async (signal) => { const { discoverWorkflowModels } = await import("gitlab-ai-provider") return discoverWorkflowModels( { instanceUrl, - getHeaders: (): Record => - credential?.type === "oauth" ? { Authorization: `Bearer ${apiKey}` } : { "PRIVATE-TOKEN": apiKey }, + getHeaders: () => headers, + fetch: Object.assign( + (input: Parameters[0], init?: RequestInit) => + fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal }), + { preconnect: fetch.preconnect }, + ), }, - { workingDirectory: ctx.location.directory }, + { workingDirectory: ctx.location.directory, cacheKey: stored.id }, ) }, catch: (cause) => cause, }).pipe( + // Discovered models are tied to this connection, so an unresponsive host would + // otherwise keep the provider hidden and hold the permit for later switches. + Effect.timeout(discoveryTimeout), Effect.catch((cause) => Effect.logWarning("failed to discover GitLab workflow models", { cause }).pipe(Effect.as(undefined)), ), @@ -208,25 +132,53 @@ export const GitLabPlugin = define({ form: Form.Fields.make([instanceUrlField()]), }, label: (value) => new URL(credentialInstanceUrl(value.metadata)).host, - refresh: (value) => - Effect.gen(function* () { - const instanceUrl = credentialInstanceUrl(value.metadata) - const clientID = resolveClientID() - const tokens = yield* exchange(http, instanceUrl, clientID, { - grant_type: "refresh_token", - refresh_token: value.refresh, - redirect_uri: redirectURI, - }) - return yield* credential(instanceUrl, tokens) - }), + refresh: (value) => { + const instanceUrl = credentialInstanceUrl(value.metadata) + const refreshWith = (clientID: string) => + exchange( + http, + instanceUrl, + clientID, + { grant_type: "refresh_token", refresh_token: value.refresh, redirect_uri: redirectURI }, + describeRefreshFailure, + ).pipe(Effect.flatMap((tokens) => credential(instanceUrl, clientID, tokens))) + // A recorded client ID is the application that issued the token; always refresh with it. + const pinned = + (typeof value.metadata?.clientID === "string" ? value.metadata.clientID : undefined) || + process.env.GITLAB_OAUTH_CLIENT_ID?.trim() + // Credentials without a recorded client ID may come from opencode-gitlab-auth, whose + // application differs from the bundled one. A rejected refresh does not consume the + // refresh token, so retrying with the legacy application is safe. + return singleFlight( + `${instanceUrl}\0${value.refresh}`, + pinned + ? refreshWith(pinned) + : refreshWith(bundledClientID).pipe( + Effect.catchIf( + (error) => error.rejected, + () => refreshWith(legacyClientID), + ), + ), + ) + }, authorize: (answer) => Effect.gen(function* () { // Capture once so the authorize request and the token exchange cannot // disagree if the environment changes mid-flow. - const clientID = resolveClientID() + const override = process.env.GITLAB_OAUTH_CLIENT_ID?.trim() + const clientID = override || bundledClientID const instanceUrl = normalizeInstanceUrl( typeof answer.instanceUrl === "string" ? answer.instanceUrl : undefined, ) + const host = new URL(instanceUrl).host + if (!override && host !== gitlabComHost) + return yield* Effect.fail( + new Error( + `The bundled GitLab OAuth application only exists on ${gitlabComHost}. To sign in to ${host},` + + ` register an OAuth application there with redirect URI ${redirectURI} and the \`${oauthScope}\`` + + ` scope (not marked "Confidential"), then set GITLAB_OAUTH_CLIENT_ID to its application ID.`, + ), + ) const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") const challenge = Buffer.from( yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))), @@ -235,7 +187,6 @@ export const GitLabPlugin = define({ const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>() const { createServer } = yield* Effect.promise(() => import("node:http")) - const { EventEmitter } = yield* Effect.promise(() => import("node:events")) const server = createServer((request, response) => { const url = new URL(request.url ?? "/", `http://${callbackHost}`) if (request.method !== "GET" || url.pathname !== "/callback") { @@ -259,16 +210,7 @@ export const GitLabPlugin = define({ server.closeAllConnections() }), ) - // GitLab matches redirect_uri exactly, so the port cannot be ephemeral. - yield* Effect.tryPromise({ - try: () => EventEmitter.once(server.listen(callbackPort, callbackHost), "listening"), - catch: (cause) => - "code" in (cause as { code?: string }) && (cause as { code?: string }).code === "EADDRINUSE" - ? new Error( - `GitLab login needs local port ${callbackPort}, but it is already in use. Stop the process using that port and try again.`, - ) - : cause, - }) + yield* listen(server) return { mode: "auto" as const, @@ -294,13 +236,19 @@ export const GitLabPlugin = define({ : OauthCallbackPage.success({ provider: "GitLab" }), ), ) - return yield* exchange(http, instanceUrl, clientID, { - grant_type: "authorization_code", - code: request.code, - redirect_uri: redirectURI, - code_verifier: verifier, - }).pipe( - Effect.flatMap((tokens) => credential(instanceUrl, tokens)), + return yield* exchange( + http, + instanceUrl, + clientID, + { + grant_type: "authorization_code", + code: request.code, + redirect_uri: redirectURI, + code_verifier: verifier, + }, + describeGrantFailure, + ).pipe( + Effect.flatMap((tokens) => credential(instanceUrl, clientID, tokens)), Effect.tap(() => respond()), Effect.tapError((error) => respond(error.message)), // Bun's server.closeAllConnections() leaves an unanswered callback response pending. @@ -397,3 +345,172 @@ export const GitLabPlugin = define({ ) }), } satisfies PluginInternal.InternalPlugin) + +class TokenError extends Error { + /** GitLab rejected the grant or client, as opposed to a transport or server failure. */ + readonly rejected: boolean + + constructor(message: string, rejected: boolean) { + super(message) + this.rejected = rejected + } +} + +function resolveDefaultInstanceUrl() { + return process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com" +} + +// Keep the path so instances served under a relative URL root (e.g. /gitlab) work. +function normalizeInstanceUrl(value: string | undefined) { + const url = new URL(value?.trim() || resolveDefaultInstanceUrl()) + return `${url.origin}${url.pathname.replace(/\/+$/, "")}` +} + +function instanceUrlField() { + const placeholder = resolveDefaultInstanceUrl() + return { + type: "string" as const, + key: "instanceUrl", + title: "GitLab instance URL", + description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", + placeholder, + default: placeholder, + pattern: "^https?://\\S+$", + } +} + +function credentialInstanceUrl(metadata: Readonly> | undefined) { + const value = metadata?.instanceUrl + return typeof value === "string" && value ? value : resolveDefaultInstanceUrl() +} + +function describeClient(clientID: string) { + const source = + clientID === bundledClientID + ? "bundled default" + : clientID === legacyClientID + ? "opencode-gitlab-auth application" + : "from GITLAB_OAUTH_CLIENT_ID" + return `client_id used: ${clientID.slice(0, 12)}... (${source})` +} + +// GitLab returns one generic `invalid_grant` for a reused/expired code, a PKCE +// verifier mismatch, a redirect-URI mismatch, and a missing client-secret alike. +// Surface enough context to tell those apart without leaking the verifier/code. +function describeGrantFailure(detail: string, clientID: string) { + const custom = clientID !== bundledClientID + const hints = [ + "the authorization code was already used, or came from an older login attempt. Codes are" + + " single-use and bound to one PKCE verifier, so a stale browser tab or a reloaded callback" + + " page fails here. Start a completely fresh login.", + `redirect_uri sent: ${redirectURI} — the application must register this exactly`, + describeClient(clientID), + ] + if (custom) { + hints.push( + "confirm that application registers the redirect URI above, grants the `api` scope, and is" + + ' NOT marked "Confidential" (PKCE requires a public client). Unset GITLAB_OAUTH_CLIENT_ID to' + + " fall back to the bundled application.", + ) + } + return `${detail}\n\nLikely causes:\n- ${hints.join("\n- ")}` +} + +function describeRefreshFailure(detail: string, clientID: string) { + return ( + `${detail}\n\nThe GitLab refresh token was revoked, expired, or issued to a different OAuth` + + ` application (${describeClient(clientID)}). Sign in to GitLab again.` + ) +} + +function exchange( + http: HttpClient.HttpClient, + instanceUrl: string, + clientID: string, + body: Record, + describe: (detail: string, clientID: string) => string, +) { + return Effect.gen(function* () { + const response = yield* http + .execute( + HttpClientRequest.post(`${instanceUrl}/oauth/token`).pipe( + HttpClientRequest.bodyUrlParams({ ...body, client_id: clientID }), + ), + ) + .pipe(Effect.mapError(() => new TokenError("GitLab token exchange request failed", false))) + if (response.status < 200 || response.status >= 300) { + const parsed = Option.getOrUndefined(decodeError(yield* response.text.pipe(Effect.orElseSucceed(() => "")))) + const detail = parsed?.error_description || parsed?.error || `HTTP ${response.status}` + const rejected = parsed?.error === "invalid_grant" || parsed?.error === "invalid_client" + return yield* Effect.fail(new TokenError(rejected ? describe(detail, clientID) : detail, rejected)) + } + return yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe( + Effect.mapError(() => new TokenError("Invalid GitLab token response", false)), + ) + }) +} + +function credential(instanceUrl: string, clientID: string, tokens: typeof Token.Type) { + return Effect.map(Clock.currentTimeMillis, (now) => + Credential.OAuth.make({ + type: "oauth", + methodID, + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: now + (tokens.expires_in ?? 7200) * 1000, + metadata: { instanceUrl, clientID }, + }), + ) +} + +// Joins refreshes of the same refresh token onto one exchange. Successes and rejections are +// final for that token, so they are retained; transport failures and interrupts are retried. +function singleFlight(key: string, effect: Effect.Effect) { + return Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + refreshing.forEach((entry, entryKey) => { + if (entry.until < now) refreshing.delete(entryKey) + }) + const existing = refreshing.get(key) + if (existing) return yield* Deferred.await(existing.attempt) + const attempt = Deferred.makeUnsafe() + refreshing.set(key, { attempt, until: Number.POSITIVE_INFINITY }) + return yield* effect.pipe( + Effect.onExit((exit) => + Effect.map(Clock.currentTimeMillis, (settledAt) => { + const final = Exit.isSuccess(exit) || Option.exists(Exit.findErrorOption(exit), (error) => error.rejected) + const owned = refreshing.get(key)?.attempt === attempt + if (owned && final) refreshing.set(key, { attempt, until: settledAt + refreshRetention }) + if (owned && !final) refreshing.delete(key) + Deferred.doneUnsafe(attempt, exit) + }), + ), + ) + }) +} + +function callbackError(params: URLSearchParams, state: string) { + if (params.get("state") !== state) return "Invalid OAuth state" + if (params.has("error")) return params.get("error_description") || params.get("error") || "Authorization denied" + return params.get("code")?.trim() ? undefined : "Missing authorization code" +} + +// GitLab matches redirect_uri exactly, so the port cannot be ephemeral. +function listen(server: Server) { + return Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)) + server.once("error", onError) + server.listen(callbackPort, callbackHost, () => { + server.off("error", onError) + resume(Effect.void) + }) + }).pipe( + Effect.mapError((cause) => + "code" in cause && cause.code === "EADDRINUSE" + ? new Error( + `GitLab login needs local port ${callbackPort}, but it is already in use. Stop the process using that port and try again.`, + ) + : cause, + ), + ) +} diff --git a/packages/core/test/plugin/provider-gitlab-oauth.test.ts b/packages/core/test/plugin/provider-gitlab-oauth.test.ts index e1119776ac45..b5f6e79afc4e 100644 --- a/packages/core/test/plugin/provider-gitlab-oauth.test.ts +++ b/packages/core/test/plugin/provider-gitlab-oauth.test.ts @@ -15,6 +15,36 @@ const it = testEffect(PluginTestLayer) const integrationID = Integration.ID.make("gitlab") const methodID = Integration.MethodID.make("pkce") const bundledClientID = "fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7" +const legacyClientID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e" +const renewal = () => + Response.json({ access_token: "renewed-access", refresh_token: "renewed-refresh", expires_in: 3600 }) +// Refresh outcomes are shared per refresh token across the process, so every test spends its own. +const expired = (metadata: Record) => + Credential.OAuth.make({ + type: "oauth", + methodID, + access: "stale-access", + refresh: `refresh-${crypto.randomUUID()}`, + expires: 1, + metadata, + }) +const connectionOf = (saved: Credential.Info) => ({ + type: "credential" as const, + id: saved.id, + label: saved.label, + method: "oauth" as const, +}) +const tokenRequests = (requests: Request[]) => + Effect.promise(() => + Promise.all( + requests + .filter((request) => request.url.endsWith("/oauth/token")) + .map(async (request) => ({ + url: request.url, + form: Object.fromEntries(new URLSearchParams(await request.text())), + })), + ), + ) const fixture = Effect.fn(function* () { const requests: Request[] = [] @@ -93,139 +123,222 @@ describe("GitLabPlugin OAuth", () => { ) it.live("exchanges a PKCE code for a native GitLab OAuth credential against gitlab.com by default", () => + withEnv({ GITLAB_OAUTH_CLIENT_ID: undefined }, () => + Effect.gen(function* () { + const test = yield* fixture() + const login = yield* test.connect + expect(login.url.origin + login.url.pathname).toBe("https://gitlab.com/oauth/authorize") + expect(Object.fromEntries(login.url.searchParams)).toMatchObject({ + response_type: "code", + client_id: bundledClientID, + scope: "api", + code_challenge_method: "S256", + }) + expect(login.url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback") + expect(login.callback.hostname).toBe("127.0.0.1") + expect(login.callback.port).toBe("8080") + expect(login.callback.pathname).toBe("/callback") + + const now = Date.now() + test.replies.push( + Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 7200 }), + ) + const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) + expect(page.status).toBe(200) + expect(yield* Effect.promise(() => page.text())).toContain("Authorization successful") + expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete") + + const exchange = test.requests[0] + expect(exchange.url).toBe("https://gitlab.com/oauth/token") + expect(exchange.headers.get("content-type")).toContain("application/x-www-form-urlencoded") + const form = new URLSearchParams(yield* Effect.promise(() => exchange.text())) + expect(Object.fromEntries(form)).toMatchObject({ + grant_type: "authorization_code", + client_id: bundledClientID, + code: "auth-code", + redirect_uri: "http://127.0.0.1:8080/callback", + }) + expect(login.url.searchParams.get("code_challenge")).toBe( + Buffer.from( + yield* Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")), + ), + ).toString("base64url"), + ) + + const saved = (yield* test.credentials.list(integrationID))[0]?.value + if (saved?.type !== "oauth") throw new Error("Expected OAuth credential") + expect(saved.access).toBe("access-token") + expect(saved.refresh).toBe("refresh-token") + expect(saved.metadata).toEqual({ instanceUrl: "https://gitlab.com", clientID: bundledClientID }) + expect(saved.expires).toBeGreaterThanOrEqual(now + 7_200_000) + expect(saved.expires).toBeLessThanOrEqual(Date.now() + 7_200_000) + }), + ), + ) + + it.live("uses the answered self-managed instance URL, including a relative URL root", () => + withEnv({ GITLAB_OAUTH_CLIENT_ID: "self-managed-client" }, () => + Effect.gen(function* () { + const test = yield* fixture() + const attempt = yield* test.integrations.oauth.connect({ + integrationID, + methodID, + answer: { instanceUrl: "https://example.com/gitlab/" }, + }) + const url = new URL(attempt.url) + expect(url.origin + url.pathname).toBe("https://example.com/gitlab/oauth/authorize") + const callback = new URL(url.searchParams.get("redirect_uri") ?? "") + callback.searchParams.set("state", url.searchParams.get("state") ?? "") + callback.searchParams.set("code", "auth-code") + test.replies.push( + Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }), + ) + yield* Effect.promise(() => fetch(callback, { headers: { Connection: "close" } })) + expect((yield* test.status(attempt.attemptID)).status).toBe("complete") + expect(test.requests[0]?.url).toBe("https://example.com/gitlab/oauth/token") + const saved = (yield* test.credentials.list(integrationID))[0] + expect(saved?.value.metadata).toEqual({ + instanceUrl: "https://example.com/gitlab", + clientID: "self-managed-client", + }) + // Without an explicit label, the method's label hook names the credential after the host. + expect(saved?.label).toBe("example.com") + expect(yield* test.integrations.connection.active(integrationID)).toMatchObject({ + type: "credential", + label: "example.com", + }) + }), + ), + ) + + it.effect("fails early for self-managed instances without GITLAB_OAUTH_CLIENT_ID", () => + withEnv({ GITLAB_OAUTH_CLIENT_ID: undefined }, () => + Effect.gen(function* () { + const test = yield* fixture() + const error = yield* test.integrations.oauth + .connect({ integrationID, methodID, answer: { instanceUrl: "https://gitlab.example.com" } }) + .pipe(Effect.flip) + expect(error.message).toContain("only exists on gitlab.com") + expect(error.message).toContain("GITLAB_OAUTH_CLIENT_ID") + expect(error.message).toContain("gitlab.example.com") + expect(test.requests).toHaveLength(0) + }), + ), + ) + + it.effect("refreshes an OAuth credential using its stored instance URL and includes redirect_uri", () => Effect.gen(function* () { const test = yield* fixture() - const login = yield* test.connect - expect(login.url.origin + login.url.pathname).toBe("https://gitlab.com/oauth/authorize") - expect(Object.fromEntries(login.url.searchParams)).toMatchObject({ - response_type: "code", - client_id: bundledClientID, - scope: "api", - code_challenge_method: "S256", + // Creating the credential also wakes workflow discovery, which resolves the same expired + // credential. Both must share one refresh: GitLab rotates the refresh token on use. + test.replies.push(renewal()) + const saved = yield* test.credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID, + access: "stale-access", + refresh: "stored-refresh", + expires: 1, + metadata: { instanceUrl: "https://gitlab.example.com", clientID: bundledClientID }, + }), }) - expect(login.url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback") - expect(login.callback.hostname).toBe("127.0.0.1") - expect(login.callback.port).toBe("8080") - expect(login.callback.pathname).toBe("/callback") - - const now = Date.now() - test.replies.push( - Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 7200 }), - ) - const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } })) - expect(page.status).toBe(200) - expect(yield* Effect.promise(() => page.text())).toContain("Authorization successful") - expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete") + const now = yield* Clock.currentTimeMillis + const resolved = yield* test.integrations.connection.resolve(connectionOf(saved)) + if (resolved?.type !== "oauth") throw new Error("Expected OAuth credential") + expect(resolved.access).toBe("renewed-access") + expect(resolved.refresh).toBe("renewed-refresh") + expect(resolved.expires).toBeGreaterThanOrEqual(now + 3_600_000) + expect(resolved.metadata).toEqual({ instanceUrl: "https://gitlab.example.com", clientID: bundledClientID }) - const exchange = test.requests[0] - expect(exchange.url).toBe("https://gitlab.com/oauth/token") - expect(exchange.headers.get("content-type")).toContain("application/x-www-form-urlencoded") - const form = new URLSearchParams(yield* Effect.promise(() => exchange.text())) - expect(Object.fromEntries(form)).toMatchObject({ - grant_type: "authorization_code", + const requests = yield* tokenRequests(test.requests) + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe("https://gitlab.example.com/oauth/token") + expect(requests[0]?.form).toMatchObject({ + grant_type: "refresh_token", + refresh_token: "stored-refresh", client_id: bundledClientID, - code: "auth-code", redirect_uri: "http://127.0.0.1:8080/callback", }) - expect(login.url.searchParams.get("code_challenge")).toBe( - Buffer.from( - yield* Effect.promise(() => - crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")), - ), - ).toString("base64url"), - ) - - const saved = (yield* test.credentials.list(integrationID))[0]?.value - if (saved?.type !== "oauth") throw new Error("Expected OAuth credential") - expect(saved.access).toBe("access-token") - expect(saved.refresh).toBe("refresh-token") - expect(saved.metadata).toEqual({ instanceUrl: "https://gitlab.com" }) - expect(saved.expires).toBeGreaterThanOrEqual(now + 7_200_000) - expect(saved.expires).toBeLessThanOrEqual(Date.now() + 7_200_000) }), ) - it.live("uses the answered self-managed instance URL for authorize and token exchange", () => + it.live("shares one token exchange between concurrent refreshes of the same credential", () => Effect.gen(function* () { const test = yield* fixture() - const integrations = yield* Integration.Service - const attempt = yield* integrations.oauth.connect({ + test.replies.push(Effect.sleep("50 millis").pipe(Effect.as(renewal()))) + const saved = yield* test.credentials.create({ integrationID, - methodID, - answer: { instanceUrl: "https://gitlab.example.com/" }, + value: expired({ instanceUrl: "https://gitlab.com", clientID: bundledClientID }), }) - const url = new URL(attempt.url) - expect(url.origin + url.pathname).toBe("https://gitlab.example.com/oauth/authorize") - const callback = new URL(url.searchParams.get("redirect_uri") ?? "") - callback.searchParams.set("state", url.searchParams.get("state") ?? "") - callback.searchParams.set("code", "auth-code") - test.replies.push( - Response.json({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }), + const resolved = yield* Effect.all( + Array.from({ length: 3 }, () => test.integrations.connection.resolve(connectionOf(saved))), + { concurrency: "unbounded" }, ) - yield* Effect.promise(() => fetch(callback, { headers: { Connection: "close" } })) - expect((yield* test.status(attempt.attemptID)).status).toBe("complete") - expect(test.requests[0]?.url).toBe("https://gitlab.example.com/oauth/token") - const saved = (yield* test.credentials.list(integrationID))[0]?.value - expect(saved?.metadata).toEqual({ instanceUrl: "https://gitlab.example.com" }) + expect(resolved.map((value) => (value?.type === "oauth" ? value.access : undefined))).toEqual([ + "renewed-access", + "renewed-access", + "renewed-access", + ]) + expect(yield* tokenRequests(test.requests)).toHaveLength(1) }), ) - it.effect("labels a stored credential with the instance host", () => + it.effect("falls back to the opencode-gitlab-auth application for credentials without a client ID", () => + withEnv({ GITLAB_OAUTH_CLIENT_ID: undefined }, () => + Effect.gen(function* () { + const test = yield* fixture() + test.replies.push( + new Response(JSON.stringify({ error: "invalid_grant", error_description: "The provided grant is invalid" }), { + status: 400, + }), + renewal(), + ) + const saved = yield* test.credentials.create({ + integrationID, + value: expired({ instanceUrl: "https://gitlab.com" }), + }) + const resolved = yield* test.integrations.connection.resolve(connectionOf(saved)) + if (resolved?.type !== "oauth") throw new Error("Expected OAuth credential") + expect(resolved.access).toBe("renewed-access") + // The working application is recorded so later refreshes skip the fallback. + expect(resolved.metadata).toEqual({ instanceUrl: "https://gitlab.com", clientID: legacyClientID }) + const requests = yield* tokenRequests(test.requests) + expect(requests.map((request) => request.form.client_id)).toEqual([bundledClientID, legacyClientID]) + const refresh = saved.value.type === "oauth" ? saved.value.refresh : "" + expect(requests.map((request) => request.form.refresh_token)).toEqual([refresh, refresh]) + }), + ), + ) + + it.effect("refreshes only with the recorded client ID when the credential has one", () => Effect.gen(function* () { - yield* fixture() - const integrations = yield* Integration.Service - const credentials = yield* Credential.Service - const saved = yield* credentials.create({ + const test = yield* fixture() + test.replies.push(renewal()) + const saved = yield* test.credentials.create({ integrationID, - value: Credential.OAuth.make({ - type: "oauth", - methodID, - access: "access", - refresh: "refresh", - expires: 0, - metadata: { instanceUrl: "https://gitlab.example.com" }, - }), + value: expired({ instanceUrl: "https://gitlab.com", clientID: legacyClientID }), }) - const active = yield* integrations.connection.active(integrationID) - expect(active).toMatchObject({ type: "credential", id: saved.id, label: "default" }) + yield* test.integrations.connection.resolve(connectionOf(saved)) + const requests = yield* tokenRequests(test.requests) + expect(requests.map((request) => request.form.client_id)).toEqual([legacyClientID]) }), ) - it.effect("refreshes an OAuth credential using its stored instance URL and includes redirect_uri", () => + it.effect("reports a revoked refresh token with a sign-in-again hint instead of authorization-code hints", () => Effect.gen(function* () { const test = yield* fixture() - const credential = Credential.OAuth.make({ - type: "oauth", - methodID, - access: "stale-access", - refresh: "stored-refresh", - expires: 1, - metadata: { instanceUrl: "https://gitlab.example.com" }, - }) - const saved = yield* test.credentials.create({ integrationID, value: credential }) - // Creating the credential also wakes workflow discovery, which resolves the same - // expired credential and races for a refresh. Answer every refresh identically and - // assert on the token requests rather than on a single queued reply. - const renewal = () => - Response.json({ access_token: "renewed-access", refresh_token: "renewed-refresh", expires_in: 3600 }) - test.replies.push(renewal(), renewal(), renewal()) - const connection = { type: "credential" as const, id: saved.id, label: saved.label, method: "oauth" as const } - const now = yield* Clock.currentTimeMillis - const resolved = yield* test.integrations.connection.resolve(connection) - if (resolved?.type !== "oauth") throw new Error("Expected OAuth credential") - expect(resolved.access).toBe("renewed-access") - expect(resolved.refresh).toBe("renewed-refresh") - expect(resolved.expires).toBeGreaterThanOrEqual(now + 3_600_000) - - const tokenRequest = test.requests.find((request) => request.url.endsWith("/oauth/token")) - expect(tokenRequest?.url).toBe("https://gitlab.example.com/oauth/token") - const refresh = new URLSearchParams(yield* Effect.promise(() => tokenRequest?.text() ?? Promise.resolve(""))) - expect(Object.fromEntries(refresh)).toMatchObject({ - grant_type: "refresh_token", - refresh_token: "stored-refresh", - client_id: bundledClientID, - redirect_uri: "http://127.0.0.1:8080/callback", + test.replies.push(new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 })) + const saved = yield* test.credentials.create({ + integrationID, + value: expired({ instanceUrl: "https://gitlab.com", clientID: bundledClientID }), }) + const error = yield* test.integrations.connection.resolve(connectionOf(saved)).pipe(Effect.flip) + expect(error.message).toContain("refresh token was revoked, expired") + expect(error.message).toContain("Sign in to GitLab again") + expect(error.message).not.toContain("authorization code") + expect(yield* tokenRequests(test.requests)).toHaveLength(1) }), ) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index b1d7b3a66468..d557cd05ca31 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,6 +1,7 @@ import { AISDK } from "@opencode/core/aisdk" import { beforeEach, describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { TestClock } from "effect/testing" import type { WorkflowDiscoveryConfig, WorkflowDiscoveryOptions, WorkflowDiscoveryResult } from "gitlab-ai-provider" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" @@ -100,14 +101,14 @@ describe("GitLabPlugin", () => { provider.settings = { instanceUrl: "https://configured.gitlab.example" } }) }) - yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "pat-token" } }) + const saved = yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "pat-token" } }) discoverWorkflowModels.mockResolvedValue(discovered) yield* addPlugin() yield* drain expect(discoverWorkflowModels).toHaveBeenCalledTimes(1) const [config, options] = discoverWorkflowModels.mock.calls[0]! - expect(options).toEqual({ workingDirectory: location.directory }) + expect(options).toEqual({ workingDirectory: location.directory, cacheKey: saved.id }) expect(config.instanceUrl).toBe("https://configured.gitlab.example") expect(config.getHeaders()).toEqual({ "PRIVATE-TOKEN": "pat-token" }) expect((yield* fixture.models.available()).map((model) => model.id).sort()).toEqual([ @@ -155,8 +156,26 @@ describe("GitLabPlugin", () => { ), ) - it.effect("discovers with environment credentials without replacing configured model definitions", () => - withEnv({ GITLAB_TOKEN: "env-token", GITLAB_INSTANCE_URL: "https://env.gitlab.example" }, () => + it.effect("does not discover with only an ambient GITLAB_TOKEN or configured apiKey", () => + withEnv({ GITLAB_TOKEN: "env-token" }, () => + Effect.gen(function* () { + const fixture = yield* discoveryFixture + yield* fixture.providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.settings = { apiKey: "configured-token" } + }) + }) + discoverWorkflowModels.mockResolvedValue(discovered) + yield* addPlugin() + yield* drain + expect(discoverWorkflowModels).not.toHaveBeenCalled() + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-default"))).toBeUndefined() + }), + ), + ) + + it.effect("discovers with a stored login without replacing configured model definitions", () => + withEnv({ GITLAB_TOKEN: undefined, GITLAB_INSTANCE_URL: "https://env.gitlab.example" }, () => Effect.gen(function* () { const fixture = yield* discoveryFixture yield* fixture.providers.transform((editor) => { @@ -165,11 +184,12 @@ describe("GitLabPlugin", () => { model.limit.output = 42 }) }) + yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "stored-token" } }) discoverWorkflowModels.mockResolvedValue(discovered) yield* addPlugin() yield* drain expect(discoverWorkflowModels.mock.calls[0]![0].instanceUrl).toBe("https://env.gitlab.example") - expect(discoverWorkflowModels.mock.calls[0]![0].getHeaders()).toEqual({ "PRIVATE-TOKEN": "env-token" }) + expect(discoverWorkflowModels.mock.calls[0]![0].getHeaders()).toEqual({ "PRIVATE-TOKEN": "stored-token" }) expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-new-model"))).toMatchObject({ name: "Configured model", limit: { output: 42 }, @@ -179,6 +199,41 @@ describe("GitLabPlugin", () => { ), ) + it.effect("scopes the SDK discovery cache to the active account", () => + withEnv({ GITLAB_TOKEN: undefined }, () => + Effect.gen(function* () { + const fixture = yield* discoveryFixture + const first = yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "first" } }) + yield* addPlugin() + yield* drain + const second = yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "second" } }) + yield* drain + expect(discoverWorkflowModels.mock.calls.map(([, options]) => options.cacheKey)).toEqual([first.id, second.id]) + }), + ), + ) + + it.effect("gives up on unresponsive discovery so later account switches still load", () => + withEnv({ GITLAB_TOKEN: undefined }, () => + Effect.gen(function* () { + const fixture = yield* discoveryFixture + discoverWorkflowModels.mockImplementation(() => new Promise(() => {})) + yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "hanging" } }) + yield* addPlugin() + yield* drain + expect(discoverWorkflowModels).toHaveBeenCalledTimes(1) + yield* TestClock.adjust("10 seconds") + yield* drain + + discoverWorkflowModels.mockResolvedValue(discovered) + yield* fixture.credentials.create({ integrationID, value: { type: "key", key: "responsive" } }) + yield* drain + expect(discoverWorkflowModels).toHaveBeenCalledTimes(2) + expect(yield* fixture.models.get(providerID, Model.ID.make("duo-workflow-new-model"))).toBeDefined() + }), + ), + ) + it.effect("skips unauthenticated discovery and refreshes after credential changes, including failures", () => withEnv({ GITLAB_TOKEN: undefined }, () => Effect.gen(function* () { diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index fc04b213ec9c..b4fb6c301306 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1051,6 +1051,12 @@ When a `duo-workflow-*` model is selected, OpenCode will: Available DAP workflow models follow the `duo-workflow-*` naming convention and are dynamically discovered from your GitLab instance. +:::note +Workflow model discovery only runs for a login stored with `/connect` (OAuth or +a personal access token). A `GITLAB_TOKEN` environment variable on its own does +not trigger discovery. +::: + ##### GitLab API Tools (Optional, but highly recommended) To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.): From 66b7024606e46fbc3c8cd978d6c319eee07473e5 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Wed, 23 Sep 2026 12:19:13 +0200 Subject: [PATCH 5/7] chore(core): bump gitlab-ai-provider to 6.17.0 --- bun.lock | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 923902424c33..578dc02f5c62 100644 --- a/bun.lock +++ b/bun.lock @@ -368,7 +368,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.16.0", + "gitlab-ai-provider": "6.17.0", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", @@ -4114,7 +4114,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.16.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-HMC3sKgWYaYSsgm86Cnq2e6laHlYkhiFQ6rFD5qsVghv9//6h4Ofr7j5R2KjQx9Hl8EBercPsmzjoEjEN7dX5Q=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.17.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-/Vh31G4eqnl6ezTIvn2VPNSauwVbXhvbZqHl6l45/qh95o6glu3d2489iYze7Up2T0TLpMC8/jY0HEOstndtiA=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/core/package.json b/packages/core/package.json index e1b490bbe866..19a22ca9aa4d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -127,7 +127,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.16.0", + "gitlab-ai-provider": "6.17.0", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", From 6cd9c21c4be2628867e1c7ad0e20eec77e968c3d Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Wed, 23 Sep 2026 12:55:10 +0200 Subject: [PATCH 6/7] chore(core): bump gitlab-ai-provider to 6.18.0 --- bun.lock | 4 ++-- packages/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 578dc02f5c62..d1697949e2cc 100644 --- a/bun.lock +++ b/bun.lock @@ -368,7 +368,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.17.0", + "gitlab-ai-provider": "6.18.0", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", @@ -4114,7 +4114,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.17.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-/Vh31G4eqnl6ezTIvn2VPNSauwVbXhvbZqHl6l45/qh95o6glu3d2489iYze7Up2T0TLpMC8/jY0HEOstndtiA=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.18.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-dXTXkNt1SFCL7jGlqazHL6iUJEug21qq0tx2s+Tui7jWNUqIAHEzY9WY1+PnZlhxvB56jPPBcFAT0attpjnopg=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/core/package.json b/packages/core/package.json index 19a22ca9aa4d..a0143bf141c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -127,7 +127,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.17.0", + "gitlab-ai-provider": "6.18.0", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", From 0c40fffbb4bf893b100c08b84e603f60de476401 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 23 Sep 2026 22:25:55 -0500 Subject: [PATCH 7/7] refactor(core): simplify GitLab OAuth refresh handling --- packages/core/src/plugin/provider/gitlab.ts | 104 +++++++----------- .../test/plugin/provider-gitlab-oauth.test.ts | 19 +--- 2 files changed, 43 insertions(+), 80 deletions(-) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index c94d98ef3b69..4f50815434d2 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -45,9 +45,9 @@ const decodeError = Schema.decodeUnknownOption( const discoveryTimeout = "10 seconds" // GitLab rotates the refresh token on every refresh, so concurrent refreshes of the same // credential (e.g. discovery in several Locations plus a request) must share one exchange. -// Settled outcomes are kept briefly: a caller that read the credential just before the +// Successful refreshes are kept briefly: a caller that read the credential just before the // rotated one was stored would otherwise spend the already-used refresh token again. -const refreshing = new Map; until: number }>() +const refreshing = new Map; until: number }>() const refreshRetention = 60_000 export const GitLabPlugin = define({ @@ -129,36 +129,36 @@ export const GitLabPlugin = define({ id: methodID, type: "oauth", label: "Login with GitLab (OAuth)", - form: Form.Fields.make([instanceUrlField()]), + form: Form.Fields.make([ + { + type: "string", + key: "instanceUrl", + title: "GitLab instance URL", + description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", + placeholder: resolveDefaultInstanceUrl(), + default: resolveDefaultInstanceUrl(), + pattern: "^https?://\\S+$", + }, + ]), }, label: (value) => new URL(credentialInstanceUrl(value.metadata)).host, refresh: (value) => { const instanceUrl = credentialInstanceUrl(value.metadata) - const refreshWith = (clientID: string) => + // Refresh with the application that issued the token. Credentials without a recorded + // client ID were issued by opencode-gitlab-auth, using GITLAB_OAUTH_CLIENT_ID or its default. + const clientID = + (typeof value.metadata?.clientID === "string" ? value.metadata.clientID : undefined) || + process.env.GITLAB_OAUTH_CLIENT_ID?.trim() || + legacyClientID + return singleFlight( + `${instanceUrl}\0${value.refresh}`, exchange( http, instanceUrl, clientID, { grant_type: "refresh_token", refresh_token: value.refresh, redirect_uri: redirectURI }, describeRefreshFailure, - ).pipe(Effect.flatMap((tokens) => credential(instanceUrl, clientID, tokens))) - // A recorded client ID is the application that issued the token; always refresh with it. - const pinned = - (typeof value.metadata?.clientID === "string" ? value.metadata.clientID : undefined) || - process.env.GITLAB_OAUTH_CLIENT_ID?.trim() - // Credentials without a recorded client ID may come from opencode-gitlab-auth, whose - // application differs from the bundled one. A rejected refresh does not consume the - // refresh token, so retrying with the legacy application is safe. - return singleFlight( - `${instanceUrl}\0${value.refresh}`, - pinned - ? refreshWith(pinned) - : refreshWith(bundledClientID).pipe( - Effect.catchIf( - (error) => error.rejected, - () => refreshWith(legacyClientID), - ), - ), + ).pipe(Effect.flatMap((tokens) => credential(instanceUrl, clientID, tokens))), ) }, authorize: (answer) => @@ -167,14 +167,15 @@ export const GitLabPlugin = define({ // disagree if the environment changes mid-flow. const override = process.env.GITLAB_OAUTH_CLIENT_ID?.trim() const clientID = override || bundledClientID - const instanceUrl = normalizeInstanceUrl( - typeof answer.instanceUrl === "string" ? answer.instanceUrl : undefined, + const url = new URL( + (typeof answer.instanceUrl === "string" ? answer.instanceUrl.trim() : "") || resolveDefaultInstanceUrl(), ) - const host = new URL(instanceUrl).host - if (!override && host !== gitlabComHost) + // Keep the path so instances served under a relative URL root (e.g. /gitlab) work. + const instanceUrl = `${url.origin}${url.pathname.replace(/\/+$/, "")}` + if (!override && url.host !== gitlabComHost) return yield* Effect.fail( new Error( - `The bundled GitLab OAuth application only exists on ${gitlabComHost}. To sign in to ${host},` + + `The bundled GitLab OAuth application only exists on ${gitlabComHost}. To sign in to ${url.host},` + ` register an OAuth application there with redirect URI ${redirectURI} and the \`${oauthScope}\`` + ` scope (not marked "Confidential"), then set GITLAB_OAUTH_CLIENT_ID to its application ID.`, ), @@ -346,39 +347,10 @@ export const GitLabPlugin = define({ }), } satisfies PluginInternal.InternalPlugin) -class TokenError extends Error { - /** GitLab rejected the grant or client, as opposed to a transport or server failure. */ - readonly rejected: boolean - - constructor(message: string, rejected: boolean) { - super(message) - this.rejected = rejected - } -} - function resolveDefaultInstanceUrl() { return process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com" } -// Keep the path so instances served under a relative URL root (e.g. /gitlab) work. -function normalizeInstanceUrl(value: string | undefined) { - const url = new URL(value?.trim() || resolveDefaultInstanceUrl()) - return `${url.origin}${url.pathname.replace(/\/+$/, "")}` -} - -function instanceUrlField() { - const placeholder = resolveDefaultInstanceUrl() - return { - type: "string" as const, - key: "instanceUrl", - title: "GitLab instance URL", - description: "Leave the default to use gitlab.com, or enter your self-managed GitLab URL.", - placeholder, - default: placeholder, - pattern: "^https?://\\S+$", - } -} - function credentialInstanceUrl(metadata: Readonly> | undefined) { const value = metadata?.instanceUrl return typeof value === "string" && value ? value : resolveDefaultInstanceUrl() @@ -437,15 +409,15 @@ function exchange( HttpClientRequest.bodyUrlParams({ ...body, client_id: clientID }), ), ) - .pipe(Effect.mapError(() => new TokenError("GitLab token exchange request failed", false))) + .pipe(Effect.mapError(() => new Error("GitLab token exchange request failed"))) if (response.status < 200 || response.status >= 300) { const parsed = Option.getOrUndefined(decodeError(yield* response.text.pipe(Effect.orElseSucceed(() => "")))) const detail = parsed?.error_description || parsed?.error || `HTTP ${response.status}` const rejected = parsed?.error === "invalid_grant" || parsed?.error === "invalid_client" - return yield* Effect.fail(new TokenError(rejected ? describe(detail, clientID) : detail, rejected)) + return yield* Effect.fail(new Error(rejected ? describe(detail, clientID) : detail)) } return yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe( - Effect.mapError(() => new TokenError("Invalid GitLab token response", false)), + Effect.mapError(() => new Error("Invalid GitLab token response")), ) }) } @@ -463,9 +435,9 @@ function credential(instanceUrl: string, clientID: string, tokens: typeof Token. ) } -// Joins refreshes of the same refresh token onto one exchange. Successes and rejections are -// final for that token, so they are retained; transport failures and interrupts are retried. -function singleFlight(key: string, effect: Effect.Effect) { +// Joins refreshes of the same refresh token onto one exchange. Successes are retained so callers +// still holding the rotated token reuse the result; failures are dropped so the next caller retries. +function singleFlight(key: string, effect: Effect.Effect) { return Effect.gen(function* () { const now = yield* Clock.currentTimeMillis refreshing.forEach((entry, entryKey) => { @@ -473,15 +445,13 @@ function singleFlight(key: string, effect: Effect.Effect() + const attempt = Deferred.makeUnsafe() refreshing.set(key, { attempt, until: Number.POSITIVE_INFINITY }) return yield* effect.pipe( Effect.onExit((exit) => Effect.map(Clock.currentTimeMillis, (settledAt) => { - const final = Exit.isSuccess(exit) || Option.exists(Exit.findErrorOption(exit), (error) => error.rejected) - const owned = refreshing.get(key)?.attempt === attempt - if (owned && final) refreshing.set(key, { attempt, until: settledAt + refreshRetention }) - if (owned && !final) refreshing.delete(key) + if (Exit.isSuccess(exit)) refreshing.set(key, { attempt, until: settledAt + refreshRetention }) + if (Exit.isFailure(exit)) refreshing.delete(key) Deferred.doneUnsafe(attempt, exit) }), ), diff --git a/packages/core/test/plugin/provider-gitlab-oauth.test.ts b/packages/core/test/plugin/provider-gitlab-oauth.test.ts index b5f6e79afc4e..6699a077f49e 100644 --- a/packages/core/test/plugin/provider-gitlab-oauth.test.ts +++ b/packages/core/test/plugin/provider-gitlab-oauth.test.ts @@ -285,16 +285,11 @@ describe("GitLabPlugin OAuth", () => { }), ) - it.effect("falls back to the opencode-gitlab-auth application for credentials without a client ID", () => + it.effect("refreshes credentials without a client ID with the opencode-gitlab-auth application", () => withEnv({ GITLAB_OAUTH_CLIENT_ID: undefined }, () => Effect.gen(function* () { const test = yield* fixture() - test.replies.push( - new Response(JSON.stringify({ error: "invalid_grant", error_description: "The provided grant is invalid" }), { - status: 400, - }), - renewal(), - ) + test.replies.push(renewal()) const saved = yield* test.credentials.create({ integrationID, value: expired({ instanceUrl: "https://gitlab.com" }), @@ -302,12 +297,9 @@ describe("GitLabPlugin OAuth", () => { const resolved = yield* test.integrations.connection.resolve(connectionOf(saved)) if (resolved?.type !== "oauth") throw new Error("Expected OAuth credential") expect(resolved.access).toBe("renewed-access") - // The working application is recorded so later refreshes skip the fallback. expect(resolved.metadata).toEqual({ instanceUrl: "https://gitlab.com", clientID: legacyClientID }) const requests = yield* tokenRequests(test.requests) - expect(requests.map((request) => request.form.client_id)).toEqual([bundledClientID, legacyClientID]) - const refresh = saved.value.type === "oauth" ? saved.value.refresh : "" - expect(requests.map((request) => request.form.refresh_token)).toEqual([refresh, refresh]) + expect(requests.map((request) => request.form.client_id)).toEqual([legacyClientID]) }), ), ) @@ -329,7 +321,9 @@ describe("GitLabPlugin OAuth", () => { it.effect("reports a revoked refresh token with a sign-in-again hint instead of authorization-code hints", () => Effect.gen(function* () { const test = yield* fixture() - test.replies.push(new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 })) + // Rejections are not shared, so discovery and this resolve may each send one refresh. + const revoked = () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }) + test.replies.push(revoked(), revoked()) const saved = yield* test.credentials.create({ integrationID, value: expired({ instanceUrl: "https://gitlab.com", clientID: bundledClientID }), @@ -338,7 +332,6 @@ describe("GitLabPlugin OAuth", () => { expect(error.message).toContain("refresh token was revoked, expired") expect(error.message).toContain("Sign in to GitLab again") expect(error.message).not.toContain("authorization code") - expect(yield* tokenRequests(test.requests)).toHaveLength(1) }), )