Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Agent } from "../agent"
import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { Credential } from "../credential"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigProviderPlugin } from "../config/plugin/provider"
Expand Down Expand Up @@ -67,6 +68,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const catalog = yield* Catalog.Service
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const bus = yield* Bus.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
Expand Down Expand Up @@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(Bus.Service, bus),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Expand Down
145 changes: 15 additions & 130 deletions packages/core/src/plugin/provider/xai.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,18 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { Clock, Effect, Option, Schema } from "effect"
import { App } from "../../app"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"

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 corsOrigins = new Set(["https://accounts.x.ai", "https://auth.x.ai"])
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),
Expand All @@ -47,88 +35,20 @@ const DeviceError = Schema.Struct({
})
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))

const browser = (app: App.Info) => ({
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<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", redirectURI)
const origin = request.headers.origin
if (origin && corsOrigins.has(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin)
response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Content-Type")
response.setHeader("Access-Control-Allow-Private-Network", "true")
response.setHeader("Vary", "Origin")
}
if (request.method === "OPTIONS") {
response.writeHead(204).end()
return
}
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<void, Error>((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, app)),
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
),
}
}),
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app),
}) satisfies IntegrationOAuthMethodRegistration

const device = (app: App.Info) => ({
integrationID: Integration.ID.make("xai"),
method: {
id: deviceMethodID,
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
label: "SuperGrok Subscription",
},
authorize: () =>
request(
`${issuer}/device/code`,
{
method: "POST",
headers: headers(app),
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
body: new URLSearchParams({ client_id: clientID, scope, referrer: "opencode" }).toString(),
},
Device,
).pipe(
Expand All @@ -153,35 +73,28 @@ const device = (app: App.Info) => ({
export const XAIPlugin = define({
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
const credentials = yield* Credential.Service
yield* Effect.forEach(
yield* credentials.list(Integration.ID.make("xai")),
(credential) => {
if (credential.value.type !== "oauth" || credential.value.methodID !== browserMethodID) return Effect.void
return credentials.update(credential.id, {
value: Credential.OAuth.make({ ...credential.value, methodID: deviceMethodID }),
})
},
{ discard: true },
)

yield* ctx.integration.transform((draft) => {
draft.update("xai", (integration) => {
integration.name = "xAI"
})
draft.method.update(browser(ctx.app))
draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
}),
})

function exchange(code: string, pkce: Pkce, app: App.Info) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(app),
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, app: App.Info) {
return request(
`${issuer}/token`,
Expand Down Expand Up @@ -310,31 +223,3 @@ function positiveSeconds(value: unknown, fallback: number) {
const seconds = Number(value)
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback
}

async function generatePKCE(): Promise<Pkce> {
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",
}).toString()}`
}
2 changes: 2 additions & 0 deletions packages/core/src/plugin/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { ConfigPlugin } from "../config/plugin"
import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
Expand Down Expand Up @@ -317,6 +318,7 @@ export const node = makeLocationNode({
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
FileMutation.node,
Formatter.node,
Expand Down
42 changes: 35 additions & 7 deletions packages/core/test/plugin/provider-xai.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
Expand All @@ -16,25 +17,52 @@ const addPlugin = Effect.fn(function* () {
})

describe("XAIPlugin", () => {
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
it.effect("registers device OAuth and API key methods", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integration = yield* integrations.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)",
label: "SuperGrok Subscription",
},
{ type: "key", label: "Manually enter API Key" },
])
}),
)

it.effect("migrates browser OAuth credentials to the device method", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const original = yield* credentials.create({
integrationID: Integration.ID.make("xai"),
label: "personal",
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("browser"),
access: "access",
refresh: "refresh",
expires: 123,
metadata: { account: "account" },
}),
})

yield* addPlugin()

expect(yield* credentials.get(original.id)).toEqual({
...original,
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "access",
refresh: "refresh",
expires: 123,
metadata: { account: "account" },
}),
})
}),
)
})
Loading