diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 28c06cbf04ff..9115cfb4d590 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -406,6 +406,7 @@ export type Endpoint10_2Input = { readonly integrationID: Endpoint10_2Request["params"]["integrationID"] readonly location?: Endpoint10_2Request["query"]["location"] readonly key: Endpoint10_2Request["payload"]["key"] + readonly inputs?: Endpoint10_2Request["payload"]["inputs"] readonly label?: Endpoint10_2Request["payload"]["label"] } export type Endpoint10_2Output = EffectValue> diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 1f5871711356..6c2ae1ee4175 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -504,13 +504,14 @@ type Endpoint10_2Input = { readonly integrationID: Endpoint10_2Request["params"]["integrationID"] readonly location?: Endpoint10_2Request["query"]["location"] readonly key: Endpoint10_2Request["payload"]["key"] + readonly inputs?: Endpoint10_2Request["payload"]["inputs"] readonly label?: Endpoint10_2Request["payload"]["label"] } const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, - payload: { key: input["key"], label: input["label"] }, + payload: { key: input["key"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) type Endpoint10_3Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index bed325d97689..d0aae394721d 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -880,7 +880,7 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, query: { location: input["location"] }, - body: { key: input["key"], label: input["label"] }, + body: { key: input["key"], inputs: input["inputs"], label: input["label"] }, successStatus: 204, declaredStatuses: [400, 401], empty: true, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 255beaa1bb9d..904cfe3c5c90 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -183,8 +183,6 @@ export type ProviderV2Info = { export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string } -export type IntegrationKeyMethod = { type: "key"; label?: string } - export type IntegrationEnvMethod = { type: "env"; names: Array } export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string } @@ -1868,6 +1866,12 @@ export type IntegrationOAuthMethod = { prompts?: Array } +export type IntegrationKeyMethod = { + type: "key" + label?: string + prompts?: Array +} + export type FormField = | FormStringField | FormNumberField @@ -3276,8 +3280,21 @@ export type IntegrationConnectKeyInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] - readonly key: { readonly key: string; readonly label?: string | undefined }["key"] - readonly label?: { readonly key: string; readonly label?: string | undefined }["label"] + readonly key: { + readonly key: string + readonly inputs?: { readonly [x: string]: string } | undefined + readonly label?: string | undefined + }["key"] + readonly inputs?: { + readonly key: string + readonly inputs?: { readonly [x: string]: string } | undefined + readonly label?: string | undefined + }["inputs"] + readonly label?: { + readonly key: string + readonly inputs?: { readonly [x: string]: string } | undefined + readonly label?: string | undefined + }["label"] } export type IntegrationConnectKeyOutput = void diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 236d027f3f88..842c19e1173a 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -85,6 +85,7 @@ export interface OAuthImplementation { export interface KeyImplementation { readonly integrationID: ID readonly method: KeyMethod + readonly authorize?: (key: string, inputs: Inputs) => Effect.Effect } export interface EnvImplementation { @@ -119,6 +120,7 @@ type Entry = { ref: Types.DeepMutable methods: Types.DeepMutable[] implementations: Map> + key?: Types.DeepMutable } type Data = { @@ -156,6 +158,8 @@ export interface Interface extends State.Transformable { readonly integrationID: ID /** Secret entered by the user. */ readonly key: string + /** Answers to the method's optional prompts. */ + readonly inputs?: Inputs /** User-facing label for the stored credential. */ readonly label?: string }) => Effect.Effect @@ -237,6 +241,7 @@ const layer = Layer.effect( ref: { id, name: id }, methods: [], implementations: new Map(), + key: undefined, } if (!draft.integrations.has(id)) draft.integrations.set(id, current) update(current.ref) @@ -253,6 +258,7 @@ const layer = Layer.effect( }, methods: [], implementations: new Map>(), + key: undefined, } if (!draft.integrations.has(implementation.integrationID)) { draft.integrations.set(implementation.integrationID, current) @@ -270,6 +276,9 @@ const layer = Layer.effect( implementation as Types.DeepMutable, ) } + if (implementation.method.type === "key") { + current.key = implementation as Types.DeepMutable + } }, remove: (integrationID, method) => { const current = draft.integrations.get(integrationID) @@ -281,6 +290,7 @@ const layer = Layer.effect( }) if (index !== -1) current.methods.splice(index, 1) if (method.type === "oauth") current.implementations.delete(method.id) + if (method.type === "key") current.key = undefined }, }, }), @@ -365,10 +375,10 @@ const layer = Layer.effect( ? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention } : { status: "failed", - message: message(persistence.cause), - time: attempt.time, - removeAt: settledAt + terminalRetention, - } + message: message(persistence.cause), + time: attempt.time, + removeAt: settledAt + terminalRetention, + } // Persisting attempts cannot be cancelled, expired, or claimed again. yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal)) if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause) @@ -438,15 +448,16 @@ const layer = Layer.effect( return value }), key: Effect.fn("Integration.connection.key")(function* (input) { - const method = state - .get() - .integrations.get(input.integrationID) - ?.methods.some((method) => method.type === "key") - if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`)) + const entry = state.get().integrations.get(input.integrationID) + const method = entry?.methods.some((method) => method.type === "key") + if (!entry || !method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`)) + const value = entry.key?.authorize + ? yield* authorize(entry.key.authorize(input.key, input.inputs ?? {})) + : Credential.Key.make({ type: "key", key: input.key }) yield* credentials.create({ integrationID: input.integrationID, label: input.label, - value: Credential.Key.make({ type: "key", key: input.key }), + value, }) yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID }) yield* events.publish(Event.Updated, {}) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 007a53ed70ec..133ef2afed7b 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,11 @@ export * as PluginHost from "./host" import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { + IntegrationInputs, + IntegrationKeyMethodRegistration, + IntegrationOAuthMethodRegistration, +} from "@opencode-ai/plugin/v2/effect/integration" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" @@ -171,6 +176,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int integration.connection.key({ integrationID: Integration.ID.make(input.integrationID), key: input.key, + inputs: input.inputs, label: input.label, }), oauth: (input) => @@ -207,14 +213,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int method: { list: (id) => mutable(draft.method.list(Integration.ID.make(id))), update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh + if (input.method.type === "oauth") { + const oauth = input as IntegrationOAuthMethodRegistration + const methodID = Integration.MethodID.make(oauth.method.id) + const refresh = oauth.refresh draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( + integrationID: Integration.ID.make(oauth.integrationID), + method: { ...oauth.method, id: methodID }, + authorize: (inputs: IntegrationInputs) => + oauth.authorize(inputs).pipe( Effect.map((authorization) => { if (authorization.mode === "auto") { return { @@ -256,7 +263,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), } : {}), - ...(input.label ? { label: input.label } : {}), + ...(oauth.label ? { label: oauth.label } : {}), }) return } @@ -267,9 +274,18 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) return } + const registration = input as IntegrationKeyMethodRegistration draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { type: "key", label: input.method.label }, + integrationID: Integration.ID.make(registration.integrationID), + method: { type: "key", label: registration.method.label, prompts: registration.method.prompts }, + ...(registration.authorize + ? { + authorize: (key, inputs) => + registration.authorize!(key, inputs).pipe( + Effect.map((credential) => Schema.decodeUnknownSync(Credential.Key)(credential)), + ), + } + : {}), }) }, remove: (id, method) => diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 7c17bcc4c74e..e2bc4fb2157a 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -7,6 +7,7 @@ import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway" import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai" import { CoherePlugin } from "./provider/cohere" import { DeepInfraPlugin } from "./provider/deepinfra" +import { DigitalOceanPlugin } from "./provider/digitalocean" import { DynamicProviderPlugin } from "./provider/dynamic" import { GatewayPlugin } from "./provider/gateway" import { GithubCopilotPlugin } from "./provider/github-copilot" @@ -24,6 +25,7 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible" import { OpencodePlugin } from "./provider/opencode" import { OpenRouterPlugin } from "./provider/openrouter" import { PerplexityPlugin } from "./provider/perplexity" +import { PoePlugin } from "./provider/poe" import { SapAICorePlugin } from "./provider/sap-ai-core" import { TogetherAIPlugin } from "./provider/togetherai" import { VercelPlugin } from "./provider/vercel" @@ -43,6 +45,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ CloudflareWorkersAIPlugin, CoherePlugin, DeepInfraPlugin, + DigitalOceanPlugin, GatewayPlugin, GithubCopilotPlugin, GitLabPlugin, @@ -60,6 +63,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ OpenAIPlugin, OpenRouterPlugin, PerplexityPlugin, + PoePlugin, SapAICorePlugin, TogetherAIPlugin, VercelPlugin, diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index f50bbdb1ba22..9d225a6e4cf8 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,7 @@ import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Credential } from "../../credential" +import { Integration } from "../../integration" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -13,6 +15,34 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { export const AzurePlugin = define({ id: "opencode.provider.azure", effect: Effect.fn(function* (ctx) { + const integration = yield* Integration.Service + yield* integration.transform((draft) => { + draft.method.update({ + integrationID: Integration.ID.make("azure"), + method: { + type: "key", + label: "API key", + prompts: process.env.AZURE_RESOURCE_NAME + ? [] + : [ + { + type: "text", + key: "resourceName", + message: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + }, + ], + }, + authorize: (key, inputs) => + Effect.succeed( + Credential.Key.make({ + type: "key", + key, + ...(inputs.resourceName ? { metadata: { resourceName: inputs.resourceName } } : {}), + }), + ), + }) + }) yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { if (!ProviderV2.isAISDK(item.provider.package)) continue diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index 06025496145d..9be0563235bb 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -2,10 +2,54 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Credential } from "../../credential" +import { Integration } from "../../integration" + +const integrationID = Integration.ID.make("cloudflare-ai-gateway") export const CloudflareAIGatewayPlugin = define({ id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { + const integration = yield* Integration.Service + yield* integration.transform((draft) => { + draft.method.update({ + integrationID, + method: { + type: "key", + label: "Gateway API token", + prompts: [ + ...(process.env.CLOUDFLARE_ACCOUNT_ID + ? [] + : [ + { + type: "text" as const, + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ]), + ...(process.env.CLOUDFLARE_GATEWAY_ID + ? [] + : [ + { + type: "text" as const, + key: "gatewayId", + message: "Enter your Cloudflare AI Gateway ID", + placeholder: "e.g. my-gateway", + }, + ]), + ], + }, + authorize: (key, inputs) => + Effect.succeed( + Credential.Key.make({ + type: "key", + key, + ...(Object.keys(inputs).length ? { metadata: inputs } : {}), + }), + ), + }) + }) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index c3aa50fbb468..e7ce9f405a7e 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -2,13 +2,44 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Credential } from "../../credential" +import { Integration } from "../../integration" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") +const integrationID = Integration.ID.make("cloudflare-workers-ai") export const CloudflareWorkersAIPlugin = define({ id: "opencode.provider.cloudflare-workers-ai", effect: Effect.fn(function* (ctx) { + const integration = yield* Integration.Service + yield* integration.transform((draft) => { + draft.method.update({ + integrationID, + method: { + type: "key", + label: "API key", + prompts: process.env.CLOUDFLARE_ACCOUNT_ID + ? [] + : [ + { + type: "text", + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ], + }, + authorize: (key, inputs) => + Effect.succeed( + Credential.Key.make({ + type: "key", + key, + ...(inputs.accountId ? { metadata: { accountId: inputs.accountId } } : {}), + }), + ), + }) + }) yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(providerID) if (!item) return diff --git a/packages/core/src/plugin/provider/digitalocean.ts b/packages/core/src/plugin/provider/digitalocean.ts new file mode 100644 index 000000000000..cb76f27603d8 --- /dev/null +++ b/packages/core/src/plugin/provider/digitalocean.ts @@ -0,0 +1,199 @@ +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" +import { Credential } from "../../credential" +import { EventV2 } from "../../event" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" +import { ModelV2 } from "../../model" +import { OauthCallbackPage } from "../../oauth/page" +import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" + +const clientID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82" +const authorizeURL = "https://cloud.digitalocean.com/v1/oauth/authorize" +const genaiURL = "https://api.digitalocean.com/v2/gen-ai" +const inferenceURL = "https://inference.do-ai.run/v1" +const callbackPort = 1456 +const callbackPath = "/auth/callback" +const tokenPath = "/auth/token" +const scopes = "genai:read inference:query" +const refreshInterval = 5 * 60 * 1000 +const integrationID = Integration.ID.make("digitalocean") +const methodID = Integration.MethodID.make("implicit") + +const Token = Schema.Struct({ + access_token: Schema.String, + expires_in: Schema.NumberFromString, + state: Schema.String, +}) +const Router = Schema.Struct({ + name: Schema.String, + uuid: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), +}) +type Router = typeof Router.Type +const RouterResponse = Schema.Struct({ model_routers: Schema.optional(Schema.Array(Router)) }) + +const oauth = { + integrationID, + method: { + id: methodID, + type: "oauth", + label: "Login with DigitalOcean", + }, + authorize: () => + Effect.gen(function* () { + const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex") + const token = yield* Deferred.make() + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + if (request.method === "GET" && url.pathname === callbackPath) { + response + .writeHead(200, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.bootstrap({ tokenPath, provider: "DigitalOcean" })) + return + } + if (request.method !== "POST" || url.pathname !== tokenPath) { + response.writeHead(404).end("Not found") + return + } + const chunks: Buffer[] = [] + request.on("data", (chunk: Buffer) => chunks.push(chunk)) + request.on("end", () => { + const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Token))(Buffer.concat(chunks).toString()) + if (Option.isNone(decoded) || decoded.value.state !== state) { + const error = Option.isNone(decoded) ? "Invalid OAuth callback" : "Invalid OAuth state" + Effect.runFork(Deferred.fail(token, new Error(error))) + response.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error })) + return + } + Effect.runFork(Deferred.succeed(token, decoded.value)) + response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ ok: true })) + }) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, "localhost", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + const redirect = `http://localhost:${callbackPort}${callbackPath}` + return { + mode: "auto" as const, + url: `${authorizeURL}?${new URLSearchParams({ + response_type: "token", + client_id: clientID, + redirect_uri: redirect, + scope: scopes, + state, + })}`, + instructions: + "Sign in to DigitalOcean in your browser. OpenCode will use the resulting token for inference and load your Inference Routers.", + callback: Deferred.await(token).pipe( + Effect.flatMap((value) => + listRouters(value.access_token).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to sync DigitalOcean inference routers", { cause }).pipe(Effect.as([])), + ), + Effect.map((routers) => + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: value.access_token, + access: value.access_token, + expires: Date.now() + value.expires_in * 1000, + metadata: { routers, routersFetchedAt: Date.now() }, + }), + ), + ), + ), + ), + } + }), +} satisfies IntegrationOAuthMethodRegistration + +export const DigitalOceanPlugin = define({ + id: "opencode.provider.digitalocean", + effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service + const loading = Semaphore.makeUnsafe(1) + const loaded: { routers: readonly Router[] } = { routers: [] } + + const load = Effect.fn("DigitalOceanPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active(integrationID) + const saved = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + if (saved?.type !== "oauth") { + loaded.routers = [] + return + } + const cached = Schema.decodeUnknownOption(Schema.Array(Router))(saved.metadata?.routers) + loaded.routers = cached._tag === "Some" ? cached.value : [] + const fetchedAt = saved.metadata?.routersFetchedAt + if (typeof fetchedAt === "number" && Date.now() - fetchedAt <= refreshInterval) return + if (saved.expires <= Date.now()) return + const routers = yield* listRouters(saved.access).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to refresh DigitalOcean inference routers", { cause }).pipe(Effect.as(undefined)), + ), + ) + if (!routers) return + loaded.routers = routers + }) + + yield* ctx.integration.transform((draft) => { + draft.update(integrationID, (integration) => { + integration.name = "DigitalOcean" + }) + draft.method.update(oauth) + draft.method.update({ + integrationID, + method: { type: "key", label: "Paste Model Access Key" }, + }) + }) + yield* ctx.catalog.transform((catalog) => { + if (!catalog.provider.get(ProviderV2.ID.make(integrationID))) return + for (const router of loaded.routers) { + const id = ModelV2.ID.make(`router:${router.name}`) + catalog.model.update(ProviderV2.ID.make(integrationID), id, (model) => { + model.modelID = id + model.name = router.name + model.family = ModelV2.Family.make("digitalocean-inference-routers") + model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: inferenceURL }) + model.capabilities = { tools: true, input: ["text"], output: ["text"] } + model.limit = { context: 128_000, output: 8_192 } + }) + } + }) + + const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === integrationID), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh() + }), +} satisfies PluginInternal.InternalPlugin) + +function listRouters(access: string) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(`${genaiURL}/models/routers`, { + headers: { + Authorization: `Bearer ${access}`, + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + signal, + }) + if (!response.ok) throw new Error(`DigitalOcean router request failed: ${response.status}`) + const body = Schema.decodeUnknownSync(RouterResponse)(await response.json()) + return body.model_routers ?? [] + }, + catch: (cause) => cause, + }) +} diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index a413c85d7dba..2546a03213a3 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,12 +1,153 @@ +import { createServer } from "node:http" import os from "os" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { InstallationVersion } from "../../installation/version" -import { Effect } from "effect" +import { Deferred, Effect, Schema } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Credential } from "../../credential" +import { Integration } from "../../integration" +import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" +const defaultInstanceUrl = "https://gitlab.com" +const bundledClientID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e" +const callbackHost = "127.0.0.1" +const callbackPort = 8080 +const callbackPath = "/callback" +const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}` +const methodID = Integration.MethodID.make("oauth") + +const Token = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), +}) +type Token = typeof Token.Type + +const oauth = { + integrationID: Integration.ID.make("gitlab"), + method: { + id: methodID, + type: "oauth", + label: "GitLab OAuth", + prompts: [ + { + type: "text", + key: "instanceUrl", + message: "GitLab instance URL", + placeholder: defaultInstanceUrl, + }, + ], + }, + authorize: (inputs) => + Effect.gen(function* () { + const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl) + const pkce = yield* Effect.promise(generatePKCE) + const state = randomString(32) + const code = yield* Deferred.make() + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", redirectURI) + if (url.pathname !== callbackPath) { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "GitLab" })) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "GitLab" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "GitLab" })) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, callbackHost, () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({ + client_id: clientID(), + redirect_uri: redirectURI, + response_type: "code", + state, + scope: "api", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + })}`, + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => + token(instanceUrl, { + grant_type: "authorization_code", + code: value, + redirect_uri: redirectURI, + client_id: clientID(), + code_verifier: pkce.verifier, + }), + ), + Effect.flatMap((value) => credential(value, instanceUrl)), + ), + } + }), + refresh: (value) => { + const instanceUrl = normalizeMetadataInstanceUrl(value.metadata?.instanceUrl) + return token(instanceUrl, { + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID(), + }).pipe(Effect.flatMap((next) => credential(next, instanceUrl, next.refresh_token ?? value.refresh))) + }, + label: (value) => (typeof value.metadata?.instanceUrl === "string" ? value.metadata.instanceUrl : undefined), +} satisfies IntegrationOAuthMethodRegistration + export const GitLabPlugin = define({ id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.method.update(oauth) + draft.method.update({ + integrationID: Integration.ID.make("gitlab"), + method: { + type: "key", + label: "GitLab Personal Access Token", + prompts: [ + { + type: "text", + key: "instanceUrl", + message: "GitLab instance URL", + placeholder: defaultInstanceUrl, + }, + ], + }, + authorize: (key, inputs) => + Effect.gen(function* () { + const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl) + const response = yield* send(`${instanceUrl}/api/v4/user`, { + headers: { Authorization: `Bearer ${key}` }, + }) + if (!response.ok) + return yield* Effect.fail(new Error(`GitLab token validation failed (${response.status})`)) + return Credential.Key.make({ type: "key", key, metadata: { instanceUrl } }) + }), + }) + draft.method.update({ + integrationID: Integration.ID.make("gitlab"), + method: { type: "env", names: ["GITLAB_TOKEN"] }, + }) + }) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { @@ -63,3 +204,79 @@ export const GitLabPlugin = define({ ) }), }) + +function clientID() { + return process.env.GITLAB_OAUTH_CLIENT_ID ?? bundledClientID +} + +function normalizeInstanceUrl(value?: string) { + const input = value?.trim() || process.env.GITLAB_INSTANCE_URL || defaultInstanceUrl + return normalizeURL(input) +} + +function normalizeMetadataInstanceUrl(value: unknown) { + if (typeof value !== "string" || !value) throw new Error("GitLab OAuth credential is missing instanceUrl metadata") + return normalizeURL(value) +} + +function normalizeURL(value: string) { + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("GitLab instance URL must use http or https") + } + return `${url.protocol}//${url.host}` +} + +function token(instanceUrl: string, body: Record) { + return send(`${instanceUrl}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams(body).toString(), + }).pipe( + Effect.flatMap((response) => { + if (response.ok) { + return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token))) + } + return Effect.promise(() => response.text()).pipe( + Effect.flatMap((detail) => + Effect.fail(new Error(`GitLab token request failed (${response.status})${detail ? `: ${detail}` : ""}`)), + ), + ) + }), + ) +} + +function send(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: (signal) => fetch(url, { ...init, signal }), + catch: (cause) => cause, + }) +} + +function credential(tokens: Token, instanceUrl: string, currentRefresh?: string) { + const refresh = tokens.refresh_token ?? currentRefresh + if (!refresh) return Effect.fail(new Error("GitLab token response is missing refresh_token")) + return Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + access: tokens.access_token, + refresh, + expires: Date.now() + (tokens.expires_in ?? 7200) * 1000, + metadata: { instanceUrl }, + }), + ) +} + +async function generatePKCE() { + const verifier = randomString(64) + const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString( + "base64url", + ) + return { verifier, challenge } +} + +function randomString(length: number) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("") +} diff --git a/packages/core/src/plugin/provider/poe.ts b/packages/core/src/plugin/provider/poe.ts new file mode 100644 index 000000000000..c0f25d0654e6 --- /dev/null +++ b/packages/core/src/plugin/provider/poe.ts @@ -0,0 +1,141 @@ +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Deferred, Effect, Schema } from "effect" +import { Credential } from "../../credential" +import { Integration } from "../../integration" +import { OauthCallbackPage } from "../../oauth/page" + +const clientID = "client_728290227fc048cc9262091a1ea197ea" +const authorizationEndpoint = "https://poe.com/oauth/authorize" +const tokenEndpoint = "https://api.poe.com/token" +const callbackPath = "/callback" +const integrationID = Integration.ID.make("poe") +const methodID = Integration.MethodID.make("browser") + +const Token = Schema.Struct({ + api_key: Schema.String, + api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Number)), +}) + +const oauth = { + integrationID, + method: { + id: methodID, + type: "oauth", + label: "Login with Poe (browser)", + }, + authorize: () => + Effect.gen(function* () { + 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 code = yield* Deferred.make() + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1") + if (url.pathname !== callbackPath) { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error") + if (error) { + const description = url.searchParams.get("error_description") ?? error + Effect.runFork(Deferred.fail(code, new Error(`OAuth authorization failed: ${error} - ${description}`))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(description, { provider: "Poe" })) + return + } + const value = url.searchParams.get("code") + if (!value) { + Effect.runFork(Deferred.fail(code, new Error("OAuth callback missing authorization code"))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error("Missing authorization code", { provider: "Poe" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "Poe" })) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(0, "127.0.0.1", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + server.closeAllConnections() + server.close() + }), + ) + const address = server.address() as AddressInfo + const redirectURI = `http://127.0.0.1:${address.port}${callbackPath}` + return { + mode: "auto" as const, + url: `${authorizationEndpoint}?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + scope: "apikey:create", + code_challenge: challenge, + code_challenge_method: "S256", + redirect_uri: redirectURI, + })}`, + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => + Effect.tryPromise({ + try: (signal) => + fetch(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: value, + code_verifier: verifier, + client_id: clientID, + redirect_uri: redirectURI, + }).toString(), + signal, + }), + catch: (cause) => cause, + }), + ), + Effect.flatMap((response) => { + if (response.ok) + return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token))) + return Effect.promise(() => response.text()).pipe( + Effect.flatMap((detail) => + Effect.fail(new Error(`Poe token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)), + ), + ) + }), + Effect.map((token) => + Credential.OAuth.make({ + type: "oauth", + methodID, + access: token.api_key, + refresh: token.api_key, + expires: + token.api_key_expires_in == null + ? Number.MAX_SAFE_INTEGER + : Date.now() + token.api_key_expires_in * 1000, + }), + ), + ), + } + }), +} satisfies IntegrationOAuthMethodRegistration + +export const PoePlugin = define({ + id: "opencode.provider.poe", + effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.update(integrationID, (integration) => { + integration.name = "Poe" + }) + draft.method.update(oauth) + draft.method.update({ integrationID, method: { type: "key", label: "Manually enter API Key" } }) + }) + }), +}) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 45c5f095ee17..6188551e94c7 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,12 +1,46 @@ -import { Effect } from "effect" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Deferred, Effect, Schema } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" +import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise +const integrationID = Integration.ID.make("snowflake-cortex") +const browserMethodID = Integration.MethodID.make("snowflake-browser") +const clientID = "LOCAL_APPLICATION" +const callbackHost = "127.0.0.1" + +type TokenResponse = { + access_token: string + refresh_token?: string + expires_in?: number +} + +const TokenResponse = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), +}) + +export function oauthScope(role: string | undefined) { + if (!role) return "refresh_token" + return /^[-_A-Za-z0-9]+$/.test(role) + ? `refresh_token session:role:${role}` + : `refresh_token session:role-encoded:${encodeURIComponent(role)}` +} + // Exported for testing: intercepts Cortex-specific request/response quirks. export function cortexFetch(upstream: FetchLike = fetch) { return async (url: string | URL | Request, init?: RequestInit): Promise => { + const headers = new Headers(url instanceof Request ? url.headers : undefined) + if (init?.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value)) + headers.set("User-Agent", `opencode/${InstallationVersion}`) + init = { ...init, headers } if (init?.body && typeof init.body === "string") { try { const body = JSON.parse(init.body) @@ -67,10 +101,24 @@ export function cortexFetch(upstream: FetchLike = fetch) { export const SnowflakeCortexPlugin = define({ id: "opencode.provider.snowflake-cortex", effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.method.update(browser) + draft.method.update({ + integrationID: "snowflake-cortex", + method: { type: "key", label: "Paste PAT or bearer token manually" }, + }) + draft.method.update({ + integrationID: "snowflake-cortex", + method: { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] }, + }) + }) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return + const account = normalizeAccount( + process.env.SNOWFLAKE_ACCOUNT ?? (typeof evt.options.account === "string" ? evt.options.account : ""), + ) const token = process.env.SNOWFLAKE_CORTEX_TOKEN ?? process.env.SNOWFLAKE_CORTEX_PAT ?? @@ -78,6 +126,7 @@ export const SnowflakeCortexPlugin = define({ (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined) const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined if (evt.options.includeUsage !== false) evt.options.includeUsage = true + if (account) evt.options.baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1` const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible({ ...evt.options, @@ -88,3 +137,180 @@ export const SnowflakeCortexPlugin = define({ ) }), }) + +const accountPrompt = { + type: "text" as const, + key: "account", + message: "Snowflake Account Identifier", + placeholder: "myorg-myaccount", +} + +const browser = { + integrationID, + method: { + id: browserMethodID, + type: "oauth", + label: "Login with Snowflake (External Browser)", + prompts: [ + accountPrompt, + { + type: "text", + key: "role", + message: "Snowflake Role (optional)", + placeholder: "PUBLIC", + }, + ], + }, + authorize: (inputs) => + Effect.gen(function* () { + const account = normalizeAccount(inputs.account ?? "") + if (!account) return yield* Effect.fail(new Error("Snowflake account is required")) + + const pkce = yield* Effect.promise(generatePKCE) + const state = randomString(64) + const code = yield* Deferred.make() + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://${callbackHost}`) + if (url.pathname !== "/") { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "Snowflake" })) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid state - potential CSRF attack" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "Snowflake" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response + .writeHead(200, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.success({ provider: "Snowflake" })) + }) + const port = yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(0, callbackHost, () => { + const address = server.address() + if (!address || typeof address === "string") { + resume(Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port"))) + return + } + resume(Effect.succeed(address.port)) + }) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + const redirect = `http://${callbackHost}:${port}/` + const role = inputs.role?.trim() || undefined + const url = `https://${account}.snowflakecomputing.com/oauth/authorize?${new URLSearchParams({ + client_id: clientID, + response_type: "code", + redirect_uri: redirect, + scope: oauthScope(role), + state, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + })}` + return { + mode: "auto" as const, + url, + instructions: + "Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => + token(account, { + grant_type: "authorization_code", + code: value, + redirect_uri: redirect, + client_id: clientID, + code_verifier: pkce.verifier, + }), + ), + Effect.flatMap((value) => + value.refresh_token + ? Effect.succeed(credential(value, account, value.refresh_token)) + : Effect.fail( + new Error( + "Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.", + ), + ), + ), + ), + } + }), + refresh: (value) => { + const account = typeof value.metadata?.account === "string" ? normalizeAccount(value.metadata.account) : "" + if (!account) return Effect.fail(new Error("Snowflake OAuth credential is missing account metadata")) + return token(account, { + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).pipe(Effect.map((next) => credential(next, account, next.refresh_token ?? value.refresh))) + }, + label: (value) => (typeof value.metadata?.account === "string" ? value.metadata.account : undefined), +} satisfies IntegrationOAuthMethodRegistration + +function token(account: string, body: Record) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`, + "User-Agent": `opencode/${InstallationVersion}`, + }, + body: new URLSearchParams(body).toString(), + signal, + }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`Snowflake token request failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + return Schema.decodeUnknownSync(TokenResponse)(await response.json()) + }, + catch: (cause) => cause, + }) +} + +function credential(tokens: TokenResponse, account: string, refresh: string) { + return Credential.OAuth.make({ + type: "oauth", + methodID: browserMethodID, + access: tokens.access_token, + refresh, + expires: Date.now() + (tokens.expires_in ?? 600) * 1000, + metadata: { account }, + }) +} + +function normalizeAccount(input: string) { + return input + .trim() + .replace(/^https?:\/\//, "") + .replace(/\.snowflakecomputing\.com\/?$/, "") + .replace(/\/+$/, "") +} + +async function generatePKCE() { + const verifier = randomString(64) + const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString( + "base64url", + ) + return { verifier, challenge } +} + +function randomString(length: number) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("") +} diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 724a3d2567db..aed559aacca9 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,10 +1,146 @@ -import { Effect } from "effect" +import { createServer } from "node:http" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Clock, Deferred, Effect, Option, Schema } from "effect" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" +import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" +const clientID = "b1a00492-073a-47ea-816f-4c329264a828" +const issuer = "https://auth.x.ai/oauth2" +const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code" +const scope = "openid profile email offline_access grok-cli:access api:access" +const callbackHost = "127.0.0.1" +const callbackPort = 56121 +const callbackPath = "/callback" +const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}` +const pollingSafetyMargin = 3000 +const browserMethodID = Integration.MethodID.make("browser") +const deviceMethodID = Integration.MethodID.make("device") + +type Pkce = { + verifier: string + challenge: string +} + +const Token = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), +}) +type Token = typeof Token.Type + +const Device = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.String, + verification_uri: Schema.String, + verification_uri_complete: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), +}) + +const DeviceError = Schema.Struct({ + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}) +const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError)) + +const browser = { + integrationID: Integration.ID.make("xai"), + method: { + id: browserMethodID, + type: "oauth", + label: "xAI Grok OAuth (SuperGrok Subscription)", + }, + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = randomString(32) + const code = yield* Deferred.make() + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", redirectURI) + if (url.pathname !== callbackPath) { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "xAI" })) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "xAI" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" })) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, callbackHost, () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: authorizeURL(pkce, state, randomString(32)), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, pkce)), + Effect.flatMap((tokens) => credential(browserMethodID, tokens)), + ), + } + }), + refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })), +} satisfies IntegrationOAuthMethodRegistration + +const device = { + integrationID: Integration.ID.make("xai"), + method: { + id: deviceMethodID, + type: "oauth", + label: "xAI Grok OAuth (Headless / Remote / VPS)", + }, + authorize: () => + request( + `${issuer}/device/code`, + { + method: "POST", + headers: headers(), + body: new URLSearchParams({ client_id: clientID, scope }).toString(), + }, + Device, + ).pipe( + Effect.map((value) => ({ + mode: "auto" as const, + url: value.verification_uri_complete ?? value.verification_uri, + instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`, + callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), + })), + ), + refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })), +} satisfies IntegrationOAuthMethodRegistration + export const XAIPlugin = define({ id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.update("xai", (integration) => { + integration.name = "xAI" + }) + draft.method.update(browser) + draft.method.update(device) + draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } }) + }) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { @@ -22,3 +158,167 @@ export const XAIPlugin = define({ ) }), }) + +function exchange(code: string, pkce: Pkce) { + return request( + `${issuer}/token`, + { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectURI, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }, + Token, + ) +} + +function refresh(methodID: Integration.MethodID, value: Credential.OAuth) { + return request( + `${issuer}/token`, + { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }, + Token, + ).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata))) +} + +function poll(device: typeof Device.Type): Effect.Effect { + return Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis + const expires = started + positiveSeconds(device.expires_in, 300) * 1000 + const loop = (interval: number): Effect.Effect => + Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) >= expires) { + return yield* Effect.fail(new Error("xAI device authorization timed out")) + } + const response = yield* send(`${issuer}/token`, { + method: "POST", + headers: headers(), + body: new URLSearchParams({ + grant_type: deviceGrant, + client_id: clientID, + device_code: device.device_code, + }).toString(), + }) + if (response.ok) return yield* decode(response, Token) + const error = yield* Effect.promise(() => response.text()).pipe( + Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))), + Effect.catch(() => Effect.succeed(undefined)), + ) + if (error?.error === "authorization_pending") { + return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval))) + } + if (error?.error === "slow_down") { + const next = interval + 5000 + return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next))) + } + if (error?.error === "access_denied" || error?.error === "authorization_denied") { + return yield* Effect.fail(new Error("xAI device authorization was denied")) + } + if (error?.error === "expired_token") { + return yield* Effect.fail(new Error("xAI device code expired - please re-run login")) + } + const detail = error?.error_description ?? error?.error + return yield* Effect.fail( + new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`), + ) + }) + return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000)) + }) +} + +function request>(url: string, init: RequestInit, schema: S) { + return send(url, init).pipe( + Effect.flatMap((response) => { + if (response.ok) return decode(response, schema) + return Effect.promise(() => response.text()).pipe( + Effect.flatMap((detail) => + Effect.fail(new Error(`xAI request failed (${response.status})${detail ? `: ${detail}` : ""}`)), + ), + ) + }), + ) +} + +function send(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: (signal) => fetch(url, { ...init, signal }), + catch: (cause) => cause, + }) +} + +function decode>(response: Response, schema: S) { + return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema))) +} + +function credential( + methodID: Integration.MethodID, + tokens: Token, + currentRefresh?: string, + metadata?: Readonly>, +) { + const refresh = tokens.refresh_token ?? currentRefresh + if (!refresh) return Effect.fail(new Error("xAI token response is missing refresh_token")) + return Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh, + access: tokens.access_token, + expires: Date.now() + positiveSeconds(tokens.expires_in, 3600) * 1000, + metadata, + }), + ) +} + +function headers() { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +function positiveSeconds(value: unknown, fallback: number) { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback +} + +async function generatePKCE(): Promise { + const verifier = randomString(64) + const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString( + "base64url", + ) + return { verifier, challenge } +} + +function randomString(length: number) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("") +} + +function authorizeURL(pkce: Pkce, state: string, nonce: string) { + return `${issuer}/authorize?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirectURI, + scope, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + state, + nonce, + plan: "generic", + referrer: "opencode", + })}` +} diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 679624ee5791..58697ed3e99f 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -218,14 +218,16 @@ export function integrationHost(integration: Integration.Interface): PluginConte method: { list: (id) => draft.method.list(Integration.ID.make(id)).map(method), update: (input) => { - if ("authorize" in input) { - const methodID = Integration.MethodID.make(input.method.id) - const refresh = input.refresh + if (input.method.type === "oauth") { + const oauth = + input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationOAuthMethodRegistration + const methodID = Integration.MethodID.make(oauth.method.id) + const refresh = oauth.refresh draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: { ...input.method, id: methodID }, - authorize: (inputs) => - input.authorize(inputs).pipe( + integrationID: Integration.ID.make(oauth.integrationID), + method: { ...oauth.method, id: methodID }, + authorize: (inputs: import("@opencode-ai/plugin/v2/effect/integration").IntegrationInputs) => + oauth.authorize(inputs).pipe( Effect.map((authorization) => { if (authorization.mode === "auto") { return { @@ -267,7 +269,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte ), } : {}), - ...(input.label ? { label: input.label } : {}), + ...(oauth.label ? { label: oauth.label } : {}), }) return } @@ -278,9 +280,19 @@ export function integrationHost(integration: Integration.Interface): PluginConte }) return } + const registration = + input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationKeyMethodRegistration draft.method.update({ - integrationID: Integration.ID.make(input.integrationID), - method: input.method, + integrationID: Integration.ID.make(registration.integrationID), + method: registration.method, + ...(registration.authorize + ? { + authorize: (key, inputs) => + registration.authorize!(key, inputs).pipe( + Effect.map((credential) => Credential.Key.make(credential)), + ), + } + : {}), }) }, remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)), diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 6cf515c0c608..4ecae92fa807 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" @@ -60,6 +61,55 @@ function fakeSelectorSdk(calls: string[]) { } describe("AzurePlugin", () => { + it.effect("registers an API key method and stores prompted resource metadata", () => + withEnv({ AZURE_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* addPlugin() + expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toEqual([ + { + type: "key", + label: "API key", + prompts: [ + { + type: "text", + key: "resourceName", + message: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + }, + ], + }, + ]) + + yield* integrations.connection.key({ + integrationID: Integration.ID.make("azure"), + key: "secret", + inputs: { resourceName: "my-models" }, + }) + const connection = required(yield* integrations.connection.active(Integration.ID.make("azure"))) + expect(yield* integrations.connection.resolve(connection)).toMatchObject({ + type: "key", + key: "secret", + metadata: { resourceName: "my-models" }, + }) + }), + ), + ) + + it.effect("omits the resource prompt when Azure resource env is configured", () => + withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* addPlugin() + expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toContainEqual({ + type: "key", + label: "API key", + prompts: [], + }) + }), + ), + ) + it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index cbd2b95c9eba..7b3a27bbd190 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,6 +1,7 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" @@ -102,6 +103,69 @@ mock.module("ai-gateway-provider/providers/unified", () => ({ })) describe("CloudflareAIGatewayPlugin", () => { + it.effect("registers a gateway token method and stores prompted gateway metadata", () => + withEnv( + cloudflareEnv({ + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_GATEWAY_ID: undefined, + CLOUDFLARE_API_TOKEN: undefined, + }), + () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* addPlugin() + const integrationID = Integration.ID.make("cloudflare-ai-gateway") + expect((yield* integrations.get(integrationID))?.methods).toEqual([ + { + type: "key", + label: "Gateway API token", + prompts: [ + { + type: "text", + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + { + type: "text", + key: "gatewayId", + message: "Enter your Cloudflare AI Gateway ID", + placeholder: "e.g. my-gateway", + }, + ], + }, + ]) + + yield* integrations.connection.key({ + integrationID, + key: "secret", + inputs: { accountId: "acct", gatewayId: "gateway" }, + }) + const connection = yield* integrations.connection.active(integrationID) + if (!connection) throw new Error("Expected connection") + expect(yield* integrations.connection.resolve(connection)).toMatchObject({ + type: "key", + key: "secret", + metadata: { accountId: "acct", gatewayId: "gateway" }, + }) + }), + ), + ) + + it.effect("omits gateway prompts backed by Cloudflare env", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* addPlugin() + expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({ + type: "key", + label: "Gateway API token", + prompts: [], + }) + }), + ), + ) + it.effect("requires account, gateway, and token before creating the unified SDK", () => withEnv( { diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d67b8d91cc2e..ac0108c15f2b 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -2,6 +2,7 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" @@ -79,6 +80,56 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") { } describe("CloudflareWorkersAIPlugin", () => { + it.effect("registers an API key method and stores prompted account metadata", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* addPlugin() + const integrationID = Integration.ID.make("cloudflare-workers-ai") + expect((yield* integrations.get(integrationID))?.methods).toEqual([ + { + type: "key", + label: "API key", + prompts: [ + { + type: "text", + key: "accountId", + message: "Enter your Cloudflare Account ID", + placeholder: "e.g. 1234567890abcdef1234567890abcdef", + }, + ], + }, + ]) + + yield* integrations.connection.key({ + integrationID, + key: "secret", + inputs: { accountId: "acct" }, + }) + const connection = required(yield* integrations.connection.active(integrationID)) + expect(yield* integrations.connection.resolve(connection)).toMatchObject({ + type: "key", + key: "secret", + metadata: { accountId: "acct" }, + }) + }), + ), + ) + + it.effect("omits the account prompt when Cloudflare account env is configured", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* addPlugin() + expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({ + type: "key", + label: "API key", + prompts: [], + }) + }), + ), + ) + it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { diff --git a/packages/core/test/plugin/provider-digitalocean.test.ts b/packages/core/test/plugin/provider-digitalocean.test.ts new file mode 100644 index 000000000000..d4148561d3e4 --- /dev/null +++ b/packages/core/test/plugin/provider-digitalocean.test.ts @@ -0,0 +1,95 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { DigitalOceanPlugin } from "@opencode-ai/core/plugin/provider/digitalocean" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) +const integrationID = Integration.ID.make("digitalocean") +const providerID = ProviderV2.ID.make("digitalocean") + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* DigitalOceanPlugin.effect(host) +}) + +describe("DigitalOceanPlugin", () => { + it.effect("registers implicit OAuth and manual model access keys", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(integrationID))?.methods).toEqual([ + { + id: Integration.MethodID.make("implicit"), + type: "oauth", + label: "Login with DigitalOcean", + }, + { type: "key", label: "Paste Model Access Key" }, + ]) + }), + ) + + it.effect("adds cached inference routers to the DigitalOcean catalog", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + yield* catalog.transform((draft) => { + draft.provider.update(providerID, (provider) => { + provider.name = "DigitalOcean" + }) + }) + yield* credentials.create({ + integrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("implicit"), + refresh: "token", + access: "token", + expires: Date.now() + 60_000, + metadata: { + routers: [ + { name: "production", uuid: "router-1" }, + { name: "support", description: "Support router" }, + ], + routersFetchedAt: Date.now(), + }, + }), + }) + + yield* addPlugin() + + const model = yield* catalog.model.get(providerID, ModelV2.ID.make("router:production")) + expect(model).toMatchObject({ + id: "router:production", + modelID: "router:production", + providerID: "digitalocean", + name: "production", + family: "digitalocean-inference-routers", + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://inference.do-ai.run/v1" }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: { context: 128_000, output: 8_192 }, + }) + expect(yield* catalog.model.get(providerID, ModelV2.ID.make("router:support"))).toBeDefined() + }), + ) + + it.effect("stores a manually registered model access key", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + yield* integrations.connection.key({ integrationID, key: "model-access-key" }) + expect((yield* (yield* Credential.Service).list(integrationID))[0]?.value).toEqual({ + type: "key", + key: "model-access-key", + }) + }), + ) +}) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index ae66a61aa81e..d9fdbd07e04e 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,4 +1,6 @@ import { AISDK } from "@opencode-ai/core/aisdk" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -41,6 +43,20 @@ function withEnv(vars: Record, effect: () = ) } +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", createGitLab: (options: Record) => { @@ -55,6 +71,155 @@ void mock.module("gitlab-ai-provider", () => ({ })) describe("GitLabPlugin", () => { + it.effect("registers OAuth, PAT, and environment methods", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("gitlab")))?.methods).toEqual([ + { + id: Integration.MethodID.make("oauth"), + type: "oauth", + label: "GitLab OAuth", + prompts: [ + { + type: "text", + key: "instanceUrl", + message: "GitLab instance URL", + placeholder: "https://gitlab.com", + }, + ], + }, + { + type: "key", + label: "GitLab Personal Access Token", + prompts: [ + { + type: "text", + key: "instanceUrl", + message: "GitLab instance URL", + placeholder: "https://gitlab.com", + }, + ], + }, + { type: "env", names: ["GITLAB_TOKEN"] }, + ]) + }), + ) + + it.effect("validates PATs and stores normalized instance URL metadata", () => + Effect.gen(function* () { + yield* addPlugin() + const original = globalThis.fetch + const calls: [string, RequestInit | undefined][] = [] + globalThis.fetch = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + calls.push([String(input), init]) + return new Response(JSON.stringify({ id: 1 }), { status: 200 }) + }, + { preconnect: original.preconnect }, + ) + yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original))) + const integrations = yield* Integration.Service + yield* integrations.connection.key({ + integrationID: Integration.ID.make("gitlab"), + key: "glpat-test", + inputs: { instanceUrl: "https://gitlab.example/path/" }, + }) + expect(calls).toHaveLength(1) + expect(calls[0]?.[0]).toBe("https://gitlab.example/api/v4/user") + expect(calls[0]?.[1]?.headers).toEqual({ Authorization: "Bearer glpat-test" }) + expect((yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]?.value).toEqual({ + type: "key", + key: "glpat-test", + metadata: { instanceUrl: "https://gitlab.example" }, + }) + }), + ) + + it.effect("rejects invalid PAT instance URLs before validation", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const exit = yield* integrations.connection + .key({ + integrationID: Integration.ID.make("gitlab"), + key: "glpat-test", + inputs: { instanceUrl: "file:///tmp/gitlab" }, + }) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + expect(yield* (yield* Credential.Service).list(Integration.ID.make("gitlab"))).toHaveLength(0) + }), + ) + + it.effect("completes OAuth PKCE and refreshes with instance metadata", () => + withEnv({ GITLAB_OAUTH_CLIENT_ID: "test-client" }, () => + Effect.gen(function* () { + yield* addPlugin() + const original = globalThis.fetch + const tokenBodies: URLSearchParams[] = [] + globalThis.fetch = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).startsWith("http://127.0.0.1:8080/")) return original(input, init) + tokenBodies.push(new URLSearchParams(String(init?.body))) + return Response.json({ + access_token: tokenBodies.length === 1 ? "access" : "refreshed-access", + refresh_token: tokenBodies.length === 1 ? "refresh" : "rotated-refresh", + expires_in: 1, + }) + }, + { preconnect: original.preconnect }, + ) + yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original))) + + const integrations = yield* Integration.Service + const attempt = yield* integrations.connection.oauth({ + integrationID: Integration.ID.make("gitlab"), + methodID: Integration.MethodID.make("oauth"), + inputs: { instanceUrl: "http://gitlab.example/path" }, + }) + const authorize = new URL(attempt.url) + expect(authorize.origin).toBe("http://gitlab.example") + expect(authorize.pathname).toBe("/oauth/authorize") + expect(authorize.searchParams.get("client_id")).toBe("test-client") + expect(authorize.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback") + expect(authorize.searchParams.get("code_challenge_method")).toBe("S256") + expect(authorize.searchParams.get("code_challenge")).toBeTruthy() + yield* Effect.promise(() => + fetch(`http://127.0.0.1:8080/callback?code=test-code&state=${authorize.searchParams.get("state")}`), + ) + yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete") + + const saved = (yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0] + expect(saved?.value).toEqual({ + type: "oauth", + methodID: Integration.MethodID.make("oauth"), + access: "access", + refresh: "refresh", + expires: expect.any(Number), + metadata: { instanceUrl: "http://gitlab.example" }, + }) + expect(tokenBodies[0]?.get("grant_type")).toBe("authorization_code") + expect(tokenBodies[0]?.get("code_verifier")).toBeTruthy() + + if (saved?.value.type !== "oauth") throw new Error("Expected OAuth credential") + yield* (yield* Credential.Service).update(saved.id, { value: { ...saved.value, expires: 0 } }) + const resolved = yield* integrations.connection.resolve({ + type: "credential", + id: saved!.id, + label: saved!.label, + }) + expect(resolved).toMatchObject({ + type: "oauth", + access: "refreshed-access", + refresh: "rotated-refresh", + metadata: { instanceUrl: "http://gitlab.example" }, + }) + expect(tokenBodies[1]?.get("grant_type")).toBe("refresh_token") + expect(tokenBodies[1]?.get("refresh_token")).toBe("refresh") + }), + ), + ) + it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => withEnv( { diff --git a/packages/core/test/plugin/provider-poe.test.ts b/packages/core/test/plugin/provider-poe.test.ts new file mode 100644 index 000000000000..e8e3c008137d --- /dev/null +++ b/packages/core/test/plugin/provider-poe.test.ts @@ -0,0 +1,66 @@ +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PoePlugin } from "@opencode-ai/core/plugin/provider/poe" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) +const integrationID = Integration.ID.make("poe") +const methodID = Integration.MethodID.make("browser") + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* PoePlugin.effect(host) +}) + +describe("PoePlugin", () => { + it.effect("registers browser OAuth and manual API key methods", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const integration = yield* integrations.get(integrationID) + expect(integration?.name).toBe("Poe") + expect(integration?.methods).toEqual([ + { id: methodID, type: "oauth", label: "Login with Poe (browser)" }, + { type: "key", label: "Manually enter API Key" }, + ]) + }), + ) + + it.effect("stores manually entered Poe API keys", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + yield* integrations.connection.key({ integrationID, key: "poe-test" }) + expect((yield* credentials.list(integrationID))[0]?.value).toEqual({ type: "key", key: "poe-test" }) + }), + ) + + it.effect("starts a PKCE browser authorization on an ephemeral loopback server", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const url = new URL(attempt.url) + const redirect = new URL(url.searchParams.get("redirect_uri") ?? "") + + expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize") + expect(url.searchParams.get("response_type")).toBe("code") + expect(url.searchParams.get("client_id")).toBe("client_728290227fc048cc9262091a1ea197ea") + expect(url.searchParams.get("scope")).toBe("apikey:create") + expect(url.searchParams.get("code_challenge_method")).toBe("S256") + expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(redirect.hostname).toBe("127.0.0.1") + expect(redirect.pathname).toBe("/callback") + expect(Number(redirect.port)).toBeGreaterThan(0) + + yield* integrations.attempt.cancel(attempt.attemptID) + }), + ) +}) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 92af6237566d..071e7e10ab0f 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,10 +1,11 @@ import { AISDK } from "@opencode-ai/core/aisdk" +import { Integration } from "@opencode-ai/core/integration" import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" -import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" +import { SnowflakeCortexPlugin, cortexFetch, oauthScope } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" @@ -41,6 +42,61 @@ function withEnv(vars: Record, effect: () = } describe("SnowflakeCortexPlugin", () => { + it.effect("registers browser OAuth, key, and environment methods", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("snowflake-cortex")))?.methods).toEqual([ + { + id: Integration.MethodID.make("snowflake-browser"), + type: "oauth", + label: "Login with Snowflake (External Browser)", + prompts: [ + { + type: "text", + key: "account", + message: "Snowflake Account Identifier", + placeholder: "myorg-myaccount", + }, + { + type: "text", + key: "role", + message: "Snowflake Role (optional)", + placeholder: "PUBLIC", + }, + ], + }, + { type: "key", label: "Paste PAT or bearer token manually" }, + { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] }, + ]) + }), + ) + + it.effect("uses account metadata to derive the Cortex endpoint", () => + withEnv({ SNOWFLAKE_ACCOUNT: undefined }, () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* addPlugin() + const result = yield* aisdk.runSDK({ + model: ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + package: "aisdk:test-provider", + }), + package: "@ai-sdk/openai-compatible", + options: { account: "https://myorg-myaccount.snowflakecomputing.com/", apiKey: "test-pat" }, + }) + expect(result.options.baseURL).toBe("https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1") + }), + ), + ) + + it.effect("uses Snowflake-compatible OAuth scopes", () => + Effect.sync(() => { + expect(oauthScope(undefined)).toBe("refresh_token") + expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC") + expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE") + }), + ) + it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex") @@ -194,6 +250,7 @@ describe("cortexFetch", () => { const body = JSON.parse(captured[0].body as string) expect(body.max_completion_tokens).toBe(1024) expect(body.max_tokens).toBeUndefined() + expect(new Headers(captured[0].headers).get("User-Agent")).toMatch(/^opencode\//) }) bun_it("preserves body when max_tokens is absent", async () => { diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 94824af3396f..5ea116501d38 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,4 +1,6 @@ import { AISDK } from "@opencode-ai/core/aisdk" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" @@ -16,7 +18,8 @@ const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const host = yield* PluginHost.make(plugin) - yield* XAIPlugin.effect(host) + const integration = yield* Integration.Service + yield* XAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) }) function fakeSelectorSdk(calls: string[]) { @@ -33,6 +36,39 @@ function fakeSelectorSdk(calls: string[]) { } describe("XAIPlugin", () => { + it.effect("registers browser OAuth, device OAuth, and API key methods", () => + Effect.gen(function* () { + yield* addPlugin() + const integration = yield* (yield* Integration.Service).get(Integration.ID.make("xai")) + expect(integration?.name).toBe("xAI") + expect(integration?.methods).toEqual([ + { + id: Integration.MethodID.make("browser"), + type: "oauth", + label: "xAI Grok OAuth (SuperGrok Subscription)", + }, + { + id: Integration.MethodID.make("device"), + type: "oauth", + label: "xAI Grok OAuth (Headless / Remote / VPS)", + }, + { type: "key", label: "Manually enter API Key" }, + ]) + }), + ) + + it.effect("stores API keys through the registered key method", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + yield* integrations.connection.key({ integrationID: Integration.ID.make("xai"), key: "xai-test" }) + expect((yield* (yield* Credential.Service).list(Integration.ID.make("xai")))[0]?.value).toEqual({ + type: "key", + key: "xai-test", + }) + }), + ) + it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 7af3542b6c51..ba046c6aa58d 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -1,5 +1,6 @@ import type { ConnectionInfo, + CredentialKey, CredentialOAuth, CredentialValue, IntegrationEnvMethod, @@ -13,6 +14,8 @@ import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { Transform } from "./registration.js" +export type { IntegrationInputs } + export type IntegrationOAuthAuthorization = { readonly url: string readonly instructions: string @@ -33,16 +36,19 @@ export type IntegrationOAuthMethodRegistration = { readonly refresh?: (credential: CredentialOAuth) => Effect.Effect readonly label?: (credential: CredentialOAuth) => string | undefined } +export type IntegrationKeyMethodRegistration = { + readonly integrationID: string + readonly method: IntegrationKeyMethod + readonly authorize?: (key: string, inputs: IntegrationInputs) => Effect.Effect +} +export type IntegrationEnvMethodRegistration = { + readonly integrationID: string + readonly method: IntegrationEnvMethod +} export type IntegrationMethodRegistration = | IntegrationOAuthMethodRegistration - | { - readonly integrationID: string - readonly method: IntegrationKeyMethod - } - | { - readonly integrationID: string - readonly method: IntegrationEnvMethod - } + | IntegrationKeyMethodRegistration + | IntegrationEnvMethodRegistration export interface IntegrationDraft { list(): readonly IntegrationRef[] diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index a6ec4d528205..94824432e094 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -43,6 +43,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") query: LocationQuery, payload: Schema.Struct({ key: Schema.String, + inputs: Schema.optional(Inputs), label: Schema.optional(Schema.String), }), success: HttpApiSchema.NoContent, diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 6caa55971072..2aa4590dc571 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -60,6 +60,7 @@ export interface KeyMethod extends Schema.Schema.Type {} export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), label: optional(Schema.String), + prompts: optional(Schema.Array(Prompt)), }).annotate({ identifier: "Integration.KeyMethod" }) export interface EnvMethod extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9d1c3a96e237..4d64dd6582e7 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -6772,6 +6772,9 @@ export class Connect extends HeyApiClient { workspace?: string | null } | null key?: string + inputs?: { + [key: string]: string + } | null label?: string | null }, options?: Options, @@ -6784,6 +6787,7 @@ export class Connect extends HeyApiClient { { in: "path", key: "integrationID" }, { in: "query", key: "location" }, { in: "body", key: "key" }, + { in: "body", key: "inputs" }, { in: "body", key: "label" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cd391aab58c1..550d3c7aee2a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -598,24 +598,6 @@ export type Part = | RetryPart | CompactionPart -export type Shell = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } -} - export type Pty = { id: string title: string @@ -917,7 +899,7 @@ export type GlobalEvent = { type: "session.shell.started" properties: { sessionID: string - shell: Shell + shell: ShellInfo } } | { @@ -925,7 +907,7 @@ export type GlobalEvent = { type: "session.shell.ended" properties: { sessionID: string - shell: Shell + shell: ShellInfo output: { output: string cursor: number @@ -1358,7 +1340,7 @@ export type GlobalEvent = { id: string type: "shell.created" properties: { - info: Shell + info: ShellInfo } } | { @@ -1366,7 +1348,7 @@ export type GlobalEvent = { type: "shell.exited" properties: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -2890,24 +2872,6 @@ export type InstructionEntryValueTooLargeError = { message: string } -export type Shell1 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionLogItem = SessionEventDurable | EventLogSynced export type SessionLogItemStream = string @@ -3154,24 +3118,6 @@ export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } -export type Shell2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type EventTuiPromptAppend2 = { id: string type: "tui.prompt.append" @@ -3398,6 +3344,24 @@ export type SessionStructuredError = { message: string } +export type ShellInfo = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type SessionMessageProviderState = { [key: string]: unknown } @@ -3962,7 +3926,7 @@ export type SyncEventSessionShellStarted = { aggregateID: string data: { sessionID: string - shell: Shell + shell: ShellInfo } } } @@ -3977,7 +3941,7 @@ export type SyncEventSessionShellEnded = { aggregateID: string data: { sessionID: string - shell: Shell + shell: ShellInfo output: { output: string cursor: number @@ -5083,7 +5047,7 @@ export type SessionShellStarted = { location?: LocationRef data: { sessionID: string - shell: Shell1 + shell: ShellInfo } } @@ -5102,7 +5066,7 @@ export type SessionShellEnded = { location?: LocationRef data: { sessionID: string - shell: Shell1 + shell: ShellInfo output: { output: string cursor: number @@ -5730,6 +5694,7 @@ export type IntegrationOAuthMethod = { export type IntegrationKeyMethod = { type: "key" label?: string + prompts?: Array } export type IntegrationEnvMethod = { @@ -6461,7 +6426,7 @@ export type ShellCreated = { type: "shell.created" location?: LocationRef data: { - info: Shell1 + info: ShellInfo } } @@ -6475,7 +6440,7 @@ export type ShellExited = { location?: LocationRef data: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -7285,7 +7250,7 @@ export type EventSessionShellStarted = { type: "session.shell.started" properties: { sessionID: string - shell: Shell2 + shell: ShellInfo } } @@ -7294,7 +7259,7 @@ export type EventSessionShellEnded = { type: "session.shell.ended" properties: { sessionID: string - shell: Shell2 + shell: ShellInfo output: { output: string cursor: number @@ -7772,7 +7737,7 @@ export type EventShellCreated = { id: string type: "shell.created" properties: { - info: Shell2 + info: ShellInfo } } @@ -7781,7 +7746,7 @@ export type EventShellExited = { type: "shell.exited" properties: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -8179,24 +8144,6 @@ export type UnknownErrorV2 = { ref?: string | null } -export type ShellV2 = { - id: string - status: "running" | "exited" | "timeout" | "killed" - command: string - cwd: string - shell: string - file: string - pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" - metadata: { - [key: string]: unknown - } - time: { - started: number | "NaN" | "Infinity" | "-Infinity" - completed?: number | "NaN" | "Infinity" | "-Infinity" - } -} - export type SessionMessagesResponseV2 = { data: Array cursor: { @@ -9333,6 +9280,24 @@ export type SessionSkillActivatedV2 = { } } +export type ShellInfoV2 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type SessionShellStartedV2 = { id: string created: number @@ -9348,7 +9313,7 @@ export type SessionShellStartedV2 = { location?: LocationRefV2 data: { sessionID: string - shell: ShellV2 + shell: ShellInfoV2 } } @@ -9367,7 +9332,7 @@ export type SessionShellEndedV2 = { location?: LocationRefV2 data: { sessionID: string - shell: ShellV2 + shell: ShellInfoV2 output: { output: string cursor: number @@ -10514,7 +10479,7 @@ export type ShellCreatedV2 = { type: "shell.created" location?: LocationRefV2 data: { - info: ShellV2 + info: ShellInfoV2 } } @@ -10528,7 +10493,7 @@ export type ShellExitedV2 = { location?: LocationRefV2 data: { id: string - exit?: number | "NaN" | "Infinity" | "-Infinity" + exit?: number status: "running" | "exited" | "timeout" | "killed" } } @@ -10986,6 +10951,24 @@ export type PtyTicketConnectTokenV2 = { expires_in: number } +export type ShellInfo1 = { + id: string + status: "running" | "exited" | "timeout" | "killed" + command: string + cwd: string + shell: string + file: string + pid?: number + exit?: number + metadata: { + [key: string]: unknown + } + time: { + started: number + completed?: number + } +} + export type QuestionV2RequestV2 = { id: string sessionID: string @@ -16783,6 +16766,9 @@ export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2Integra export type V2IntegrationConnectKeyData = { body: { key: string + inputs?: { + [key: string]: string + } | null label?: string | null } path: { @@ -18318,7 +18304,7 @@ export type V2ShellListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -18362,7 +18348,7 @@ export type V2ShellCreateResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } @@ -18445,7 +18431,7 @@ export type V2ShellGetResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } @@ -18490,7 +18476,7 @@ export type V2ShellTimeoutResponses = { */ 200: { location: LocationInfoV2 - data: ShellV2 + data: ShellInfo1 } } diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 6c29d5877607..eb04d48c1606 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -41,6 +41,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" service.connection.key({ integrationID: ctx.params.integrationID, key: ctx.payload.key, + inputs: ctx.payload.inputs, label: ctx.payload.label, }), ) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 1ae8bbec7794..a626ace22c70 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -159,15 +159,29 @@ function openMethod( onConnected?: OnIntegrationConnected, ) { if (method.type === "key") { - dialog.replace(() => ) + void beginKey(integration, method, dialog, onConnected) return } void beginOAuth(integration, method, dialog, onConnected) } +async function beginKey( + integration: IntegrationInfo, + method: Extract, + dialog: ReturnType, + onConnected?: OnIntegrationConnected, +) { + const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} + if (inputs === null) return + dialog.replace(() => ( + + )) +} + function KeyMethod(props: { integration: IntegrationInfo method: Extract + inputs: Record onConnected?: OnIntegrationConnected }) { const data = useData() @@ -183,11 +197,12 @@ function KeyMethod(props: { placeholder="API key" onConfirm={(key) => { if (!key) return - void sdk.api.integration - .connect.key({ + void sdk.api.integration.connect + .key({ integrationID: props.integration.id, location: location(data), key, + inputs: props.inputs, }) .then(() => connected(props.integration, data, dialog, toast, props.onConnected)) .catch((cause) => setError(message(cause))) @@ -222,8 +237,8 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void sdk.api.integration - .connect.oauth({ + void sdk.api.integration.connect + .oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -291,8 +306,8 @@ function OAuthAuto(props: { })) const poll = () => { - void sdk.api.integration - .attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt + .status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -357,8 +372,8 @@ function OAuthCode(props: { placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void sdk.api.integration - .attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code }) + void sdk.api.integration.attempt + .complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected)