mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(core): restore GitLab workflow discovery and add OAuth login (#50422)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
committed by
GitHub
parent
b7d707cd64
commit
f0aff6cfe8
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, { attempt: Deferred.Deferred<Credential.OAuth, Error>; 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<ReturnType<typeof ctx.integration.connection.active>>
|
||||
} = {}
|
||||
|
||||
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<string, string> =
|
||||
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<typeof fetch>[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<Record<string, unknown>> | 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<string, string>,
|
||||
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<Credential.OAuth, Error>) {
|
||||
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<Credential.OAuth, Error>()
|
||||
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<void, Error>((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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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<Response>)[] = []
|
||||
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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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<string, unknown>[] = []
|
||||
const discoverWorkflowModels = mock(
|
||||
async (_config: WorkflowDiscoveryConfig, _options: WorkflowDiscoveryOptions): Promise<WorkflowDiscoveryResult> => ({
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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.):
|
||||
|
||||
Reference in New Issue
Block a user