diff --git a/bun.lock b/bun.lock index 923902424c33..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.16.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.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.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 e1b490bbe866..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.16.0", + "gitlab-ai-provider": "6.18.0", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 6d0e52b33054..4f50815434d2 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,12 +1,295 @@ import os from "os" import { App } from "../../app.js" -import { Effect } from "effect" +import { Clock, Deferred, Effect, Exit, Option, Schema, Semaphore, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/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" +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" +// 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 +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) }), + ), +) +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. +// 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 refreshRetention = 60_000 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[] + connection?: Effect.Success> + } = {} + + const load = Effect.fn("GitLabPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active(integrationID) + // 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 + 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.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 (signal) => { + const { discoverWorkflowModels } = await import("gitlab-ai-provider") + return discoverWorkflowModels( + { + instanceUrl, + 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, 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)), + ), + ) + if ( + IntegrationConnection.key(connection) !== + IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID)) + ) + return + loaded.models = remote?.models + 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([ + { + 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) + // 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))), + ) + }, + authorize: (answer) => + Effect.gen(function* () { + // Capture once so the authorize request and the token exchange cannot + // disagree if the environment changes mid-flow. + const override = process.env.GITLAB_OAUTH_CLIENT_ID?.trim() + const clientID = override || bundledClientID + const url = new URL( + (typeof answer.instanceUrl === "string" ? answer.instanceUrl.trim() : "") || resolveDefaultInstanceUrl(), + ) + // 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 ${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.`, + ), + ) + 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 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() + }), + ) + yield* listen(server) + + 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, + }, + 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. + Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())), + ) + }), + } + }), + }) + }) + + 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 +345,142 @@ export const GitLabPlugin = define({ }), ) }), -}) +} satisfies PluginInternal.InternalPlugin) + +function resolveDefaultInstanceUrl() { + return process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com" +} + +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 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 Error(rejected ? describe(detail, clientID) : detail)) + } + return yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe( + Effect.mapError(() => new Error("Invalid GitLab token response")), + ) + }) +} + +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 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) => { + 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) => { + if (Exit.isSuccess(exit)) refreshing.set(key, { attempt, until: settledAt + refreshRetention }) + if (Exit.isFailure(exit)) 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 new file mode 100644 index 000000000000..6699a077f49e --- /dev/null +++ b/packages/core/test/plugin/provider-gitlab-oauth.test.ts @@ -0,0 +1,421 @@ +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 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[] = [] + 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", () => + 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() + // 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 }, + }), + }) + 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 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, + redirect_uri: "http://127.0.0.1:8080/callback", + }) + }), + ) + + it.live("shares one token exchange between concurrent refreshes of the same credential", () => + Effect.gen(function* () { + const test = yield* fixture() + test.replies.push(Effect.sleep("50 millis").pipe(Effect.as(renewal()))) + const saved = yield* test.credentials.create({ + integrationID, + value: expired({ instanceUrl: "https://gitlab.com", clientID: bundledClientID }), + }) + const resolved = yield* Effect.all( + Array.from({ length: 3 }, () => test.integrations.connection.resolve(connectionOf(saved))), + { concurrency: "unbounded" }, + ) + 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("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(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") + 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([legacyClientID]) + }), + ), + ) + + it.effect("refreshes only with the recorded client ID when the credential has one", () => + Effect.gen(function* () { + const test = yield* fixture() + test.replies.push(renewal()) + const saved = yield* test.credentials.create({ + integrationID, + value: expired({ instanceUrl: "https://gitlab.com", clientID: legacyClientID }), + }) + 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("reports a revoked refresh token with a sign-in-again hint instead of authorization-code hints", () => + Effect.gen(function* () { + const test = yield* fixture() + // 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 }), + }) + 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") + }), + ) + + 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/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index 03be47cc1fe9..d557cd05ca31 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,17 +1,69 @@ 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 { 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" +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 +80,198 @@ 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" } + }) + }) + 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, 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([ + 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("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) => { + editor.models.update(providerID, Model.ID.make("duo-workflow-new-model"), (model) => { + model.name = "Configured model" + 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": "stored-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("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* () { + 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( { diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index a5a17de3a34d..b4fb6c301306 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`: @@ -1056,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.):