import { Duration, Effect, Equal, Option, Schema, SchemaGetter, Scope, Semaphore, Stream } from "effect" import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" import { define } from "@opencode/plugin/effect/plugin" import type { SessionHttpResponse } from "@opencode/plugin/effect/session" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" import { App } from "../../app.js" import { Bus } from "../../bus.js" import { Credential } from "../../credential.js" import { Integration } from "../../integration.js" import { IntegrationConnection } from "../../integration/connection.js" import { ManagedPolicy } from "../../managed-policy.js" import { Model } from "../../model.js" import { Provider } from "../../provider.js" import { WebSearch } from "../../websearch.js" import { ConfigPolicy } from "@opencode/schema/config/policy" import { ConfigProvider } from "@opencode/schema/config/provider" import { Mcp } from "@opencode/schema/mcp" import { Money } from "@opencode/schema/money" const defaultServer = "https://opencode.ai/console" const clientID = "opencode-cli" const methodID = Integration.MethodID.make("device") // Drop statements whose action or effect is a value this version does not know, so a newer Console can // extend either enum. Malformed statements still fail validation. const RemotePolicies = Schema.Array(Schema.Unknown).pipe( Schema.decodeTo(Schema.Unknown, { decode: SchemaGetter.transform((policies) => policies.filter((policy) => { const action = Schema.decodeUnknownOption(Schema.Struct({ action: Schema.String }))(policy) if (Option.isSome(action) && !Schema.is(ConfigPolicy.Info.fields.action)(action.value.action)) return false const effect = Schema.decodeUnknownOption(Schema.Struct({ effect: Schema.String }))(policy) return Option.isNone(effect) || Schema.is(ConfigPolicy.Effect)(effect.value.effect) }), ), encode: SchemaGetter.passthrough({ strict: false }), }), Schema.decodeTo(Schema.Array(ConfigPolicy.Info)), Schema.optional, ) const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info), websearch: Schema.Struct({ providerID: WebSearch.ID, }).pipe(Schema.optional), // MCP servers by name, in the same shape as a remote server in local config. Only remote servers are // accepted so the Console can never make the client run a command. `auth: "console"` asks the client // to attach its own Console credential to that server's requests. mcp: Schema.Struct({ servers: Schema.Record( Schema.String, Schema.Struct({ ...Mcp.RemoteConfig.fields, auth: Schema.Literal("console").pipe(Schema.optional) }), ), }).pipe(Schema.optional), // Organization policy compiled for the authenticated caller; omitted when there is none. // `experimental.policies` remains accepted alongside top-level `policies`. policies: RemotePolicies, experimental: Schema.Struct({ policies: RemotePolicies }).pipe(Schema.optional), }) const Device = Schema.Struct({ device_code: Schema.String, user_code: Schema.String, verification_uri_complete: Schema.String, expires_in: Schema.Number, interval: Schema.Number, }) const Token = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.String, expires_in: Schema.Number, org_id: Schema.optional(Schema.NullOr(Schema.String)), }) const TokenPending = Schema.Struct({ error: Schema.String }) const DeviceToken = Schema.Union([Token, TokenPending]) const User = Schema.Struct({ id: Schema.String, email: Schema.String }) const Org = Schema.Struct({ id: Schema.String, name: Schema.String }) // Console's 403 when the organization enforces SSO and the user's SSO proof is missing or expired. const SsoRequired = Schema.Struct({ _tag: Schema.Literal("SsoRequired"), orgId: Schema.String, connectionId: Schema.String, }) const InvalidGrant = Schema.Struct({ error: Schema.Literal("invalid_grant") }) // Console's 401 when the session behind the access token was revoked or expired. const Unauthorized = Schema.Struct({ _tag: Schema.Literal("Unauthorized") }) const signedOutMessage = "Reconnect OpenCode Console to continue" const ssoMessage = (organization: string | undefined) => `Sign in with SSO again to use ${organization ?? "your OpenCode Console organization"}` class SsoRequiredError extends Schema.TaggedError()("OpencodeConsole.SsoRequired", { organization: Schema.optional(Schema.String), url: Schema.String, }) { override get message() { return `${ssoMessage(this.organization)}.` } } // The Console session or refresh token was revoked or expired; only a new device login recovers. class SignedOutError extends Schema.TaggedError()("OpencodeConsole.SignedOut", {}) { override get message() { return `${signedOutMessage}.` } } function oauth(http: HttpClient.HttpClient) { return { integrationID: Integration.ID.make("opencode"), method: { id: methodID, type: "oauth", label: "OpenCode Console account", form: [ { key: "server", type: "string", format: "uri", hidden: true, default: defaultServer, }, ], }, authorize: (answer) => Effect.gen(function* () { const server = yield* normalizeServer(answer.server ?? defaultServer) const device = yield* post( http, `${server}/auth/device/code`, { client_id: clientID, supports_org_scope: true }, Device, ) const verification = yield* Effect.try({ try: () => { const url = new URL(device.verification_uri_complete, `${server}/`) if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") return url }, catch: (cause) => new Error(`Invalid device verification URL: ${cause instanceof Error ? cause.message : String(cause)}`), }) return { mode: "auto" as const, url: verification.href, instructions: `Enter code: ${device.user_code}`, callback: poll(http, server, device.device_code, Duration.seconds(device.interval)), } }), refresh: (credential) => Effect.gen(function* () { const response = yield* send(http, `${serverUrl(credential)}/auth/device/token`, { grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID, }) if (response.status === 400) { const body = yield* HttpClientResponse.schemaBodyJson(InvalidGrant)(response).pipe(Effect.option) if (Option.isSome(body)) return yield* new SignedOutError() } const token = yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(Token)), ) // Persist rotated tokens without depending on discovery requests. return { ...credential, access: token.access_token, refresh: token.refresh_token, expires: Date.now() + token.expires_in * 1000, metadata: token.org_id == null ? credential.metadata : { ...credential.metadata, orgID: token.org_id, orgName: credential.metadata?.orgID === token.org_id && typeof credential.metadata.orgName === "string" ? credential.metadata.orgName : token.org_id, }, } }), label: (credential) => (typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined), } satisfies IntegrationOAuthMethodRegistration } export const OpencodePlugin = define({ id: "opencode.provider.opencode", effect: Effect.fn(function* (ctx) { const bus = yield* Bus.Service const client = yield* HttpClient.HttpClient // Every request here goes to the Console, which reads the User-Agent to tell which OpenCode a member runs // and whether it evaluates the policies it is being sent. const http = HttpClient.mapRequest(client, HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app))) const managed = yield* ManagedPolicy.Service const scope = yield* Scope.Scope const loading = Semaphore.makeUnsafe(1) type ActiveConnection = Effect.Success> let snapshot: { config: typeof RemoteResponse.Type | undefined connection: ActiveConnection organization: string | undefined // Console MCP servers carry the credential in their headers, so a rotated token changes the snapshot. mcp: | { servers: NonNullable["servers"]; headers: Record } | undefined } = { config: undefined, connection: undefined, organization: undefined, mcp: undefined } // Status last reported for the active connection, so inference failures can name the fix. let reported: IntegrationConnection.Status | undefined let explainers: Effect.Success>[] = [] const load = Effect.fn("OpencodePlugin.load")(function* () { const connection = yield* ctx.integration.connection.active("opencode") if (!connection) { reported = undefined return { config: undefined, connection, organization: undefined, mcp: undefined } } const status = (status: IntegrationConnection.Status | undefined) => { reported = status return ctx.integration.connection.status({ integrationID: "opencode", connection, status }) } return yield* ctx.integration.connection.resolve(connection).pipe( Effect.flatMap((credential) => { if (!credential || credential.type === "external") return Effect.succeed({ config: undefined, connection, organization: undefined, mcp: undefined }) return fetchConfig(http, credential).pipe( Effect.map((config) => ({ config, connection, organization: organizationName(credential), mcp: config?.mcp && { servers: config.mcp.servers, headers: credentialHeaders(credential) }, })), ) }), Effect.tap(() => status(undefined)), Effect.catch((cause) => Effect.gen(function* () { // Only well-formed Console errors change the connection status; anything else may be transient. const next = connectionStatus(cause) if (next) yield* status(next) yield* Effect.logWarning("failed to load OpenCode provider config", { cause }) // A load that fails for the connection already in place keeps its last config: dropping it // would lift organization policy while personal credentials keep working. return IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection) ? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp } : { config: undefined, connection, organization: undefined, mcp: undefined } }), ), ) }) // Statements ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too. const publish = (next: typeof snapshot) => managed.set({ statements: [...(next.config?.experimental?.policies ?? []), ...(next.config?.policies ?? [])], organization: next.organization, }) yield* ctx.integration.transform((editor) => { editor.update("opencode", (integration) => { integration.name = "OpenCode Console" }) editor.method.update(oauth(http)) editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) }) snapshot = yield* load() yield* publish(snapshot) yield* ctx.provider.transform((providers) => { for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) { const source = providers.get(item.canonical ?? providerID) providers.update(providerID, (provider) => { if (source && source.provider !== provider) Object.assign(provider, structuredClone(source.provider), { id: provider.id }) provider.integrationID = Integration.ID.make("opencode") if (item.canonical !== undefined) provider.canonical = item.canonical if (item.name !== undefined) provider.name = item.name provider.package = item.package ?? provider.package provider.settings = Provider.mergeOverlay( withoutCredentials(provider.settings), withoutCredentials(item.settings), ) provider.headers = Provider.mergeHeaders(provider.headers, item.headers) provider.body = Provider.mergeOverlay(provider.body, item.body) }) for (const [modelID, config] of Object.entries(item.models ?? {})) { const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID) providers.models.update(providerID, modelID, (model) => { Object.assign(model, structuredClone(base ?? model)) if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name if (config.modelID !== undefined) model.modelID = config.modelID if (config.compatibility !== undefined) model.compatibility = { ...model.compatibility, ...config.compatibility } model.package = config.package ?? (item.package !== undefined ? undefined : model.package) if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL if (config.capabilities !== undefined) model.capabilities = Model.mergeCapabilities(config.capabilities, model.capabilities) model.settings = Provider.mergeOverlay( withoutCredentials(model.settings), withoutCredentials(config.settings), ) model.headers = Provider.mergeHeaders(model.headers, config.headers) model.body = Provider.mergeOverlay(model.body, config.body) for (const variant of config.variants ?? []) { let existing = model.variants.find((item) => item.id === variant.id) if (!existing) { existing = { id: variant.id } model.variants.push(existing) } if (variant.settings !== undefined) existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings)) if (variant.headers !== undefined) existing.headers = Provider.mergeHeaders(existing.headers, variant.headers) if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body) } if (config.cost !== undefined) model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ tier: cost.tier && { ...cost.tier }, input: cost.input, output: cost.output, cache: { read: cost.cache?.read ?? Money.USDPerMillionTokens.zero, write: cost.cache?.write ?? Money.USDPerMillionTokens.zero, }, })) model.enabled = !config.disabled if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } }) } const configured = providers.get(providerID) if (configured) providers.add({ info: configured.provider, models: Array.from(configured.models.values()), sourceConnection: snapshot.connection, }) } const item = providers.get(Provider.ID.opencode) if (!item) return const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connection || item.provider.settings?.apiKey) providers.update(item.provider.id, (provider) => { if (!hasKey) { provider.activation = "enabled" provider.settings = { ...provider.settings, apiKey: "public" } } }) }) yield* ctx.model.transform((models) => { const item = models.provider.get(Provider.ID.opencode) if (!item) return const hasKey = Boolean( process.env.OPENCODE_API_KEY || snapshot.connection || (item.provider.settings?.apiKey && item.provider.settings.apiKey !== "public"), ) if (hasKey) return for (const model of models.list(item.provider.id)) { if (!model.cost.some((cost) => cost.input > 0)) continue models.update(item.provider.id, model.id, (draft) => { draft.enabled = false }) } }) yield* ctx.websearch.transform((editor) => { const descriptor = snapshot.config?.websearch const connection = snapshot.connection if (!descriptor || !connection) return editor.add({ id: descriptor.providerID, name: "OpenCode Web Search", execute: (input) => Effect.gen(function* () { const active = yield* ctx.integration.connection.active("opencode") if ( !active || (connection.type === "credential" ? active.type !== "credential" || active.id !== connection.id : active.type !== "env" || active.name !== connection.name) ) { return yield* Effect.fail(new Error("OpenCode Console connection changed")) } const credential = yield* ctx.integration.connection.resolve(active) if (!credential || credential.type === "external") return yield* Effect.fail(new Error("OpenCode Console is not connected")) const metadata = credential.metadata const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined const token = credential.type === "oauth" ? credential.access : credential.key const server = yield* normalizeServer(serverUrl(credential)) const request = yield* HttpClientRequest.post(`${server}/api/websearch`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token), HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}), HttpClientRequest.schemaBodyJson(WebSearch.Input)({ query: input.query, providerID: descriptor.providerID, }), ) const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)) .execute(request) .pipe( Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), Effect.flatMap(HttpClientResponse.schemaBodyJson(WebSearch.Response)), Effect.scoped, Effect.timeoutOrElse({ duration: Duration.seconds(25), orElse: () => Effect.fail(new Error("OpenCode web search request timed out")), }), ) if (response.providerID !== descriptor.providerID) { return yield* Effect.fail( new Error( `OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`, ), ) } return response.results }), }) editor.default.set(descriptor.providerID) }) yield* ctx.mcp.transform((editor) => { const mcp = snapshot.mcp if (!mcp) return for (const [name, server] of Object.entries(mcp.servers)) { // A server the user configured under the same name wins. if (editor.get(name)) continue const { auth, ...config } = server editor.set(name, auth === "console" ? { ...config, headers: { ...config.headers, ...mcp.headers } } : config) } }) const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) { snapshot = next yield* publish(next) yield* guard(next) yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload(), ctx.mcp.reload()], { concurrency: 3, discard: true, }) }) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply))) // Annotated: `apply` re-registers the hooks that call `check`, which would otherwise make inference circular. const check = (): Effect.Effect => loading.withPermit( load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))), ) // Console inference rejects a missing SSO sign-in with a generic 401/403. Re-check the Console config, which // reports the reason, and replace the response with instructions when the connection needs the user. const explain = (event: SessionHttpResponse) => Effect.gen(function* () { if (event.response.status !== 401 && event.response.status !== 403) return yield* check() const status = reported if (!status) return void event.response.body?.cancel() event.response = Response.json( { error: { type: "authentication_error", message: `${status.message}.`, }, }, { status: event.response.status }, ) }) // Hooks are scoped per provider so requests to unrelated providers skip the HTTP hook path. const guard = Effect.fn("OpencodePlugin.guard")(function* (next: typeof snapshot) { yield* Effect.forEach(explainers, (registration) => registration.dispose, { discard: true }) explainers = yield* Effect.forEach( next.connection ? new Set([Provider.ID.opencode, ...Object.keys(next.config?.providers ?? {})]) : [], (providerID) => ctx.session.hook("http.response", explain, { providerID }).pipe(Scope.provide(scope)), ) }) yield* guard(snapshot) yield* bus.subscribe(Credential.Event.Switched).pipe( Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")), Stream.runForEach(refresh), Effect.forkScoped({ startImmediately: true }), ) // Console config can change independently of local credential activity, so re-fetch // periodically and only rebuild the catalog and search providers when the snapshot differs. yield* Effect.sleep(Duration.minutes(1)).pipe(Effect.andThen(check()), Effect.forever, Effect.forkScoped) }), }) function fetchConfig(http: HttpClient.HttpClient, value: Credential.Key | Credential.OAuth) { // Scoped so responses whose body is never read (404, errors) are released here instead of by a GC-time abort. return HttpClient.withScope(http) .execute( HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(credentialHeaders(value)), ), ) .pipe( Effect.flatMap((response) => Effect.gen(function* () { if (response.status === 404) return undefined if (response.status === 401) { const body = yield* HttpClientResponse.schemaBodyJson(Unauthorized)(response).pipe(Effect.option) if (Option.isSome(body)) return yield* new SignedOutError() } if (response.status === 403) { const body = yield* HttpClientResponse.schemaBodyJson(SsoRequired)(response).pipe(Effect.option) if (Option.isSome(body)) return yield* new SsoRequiredError({ organization: organizationName(value), url: yield* ssoUrl(serverUrl(value), body.value.connectionId), }) } return yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)), ) }), ), Effect.scoped, ) } function ssoUrl(server: string, connectionID: string) { return normalizeServer(server).pipe( Effect.map((base) => { // Console only redirects to paths under its own base path after SSO. const redirectTo = `${new URL(base).pathname.replace(/\/+$/, "")}/` return `${base}/auth/sso/${encodeURIComponent(connectionID)}/start?redirectTo=${encodeURIComponent(redirectTo)}` }), ) } function connectionStatus(cause: unknown): IntegrationConnection.Status | undefined { if (cause instanceof SsoRequiredError) return { status: "needs_auth", message: ssoMessage(cause.organization), url: cause.url, } if ( cause instanceof SignedOutError || (cause instanceof Integration.AuthorizationError && cause.cause instanceof SignedOutError) ) return { status: "needs_auth", message: signedOutMessage } return undefined } function organizationName(credential: Credential.Value) { return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined } function credentialHeaders(value: Credential.Key | Credential.OAuth): Record { const orgID = value.metadata?.orgID return { authorization: `Bearer ${value.type === "oauth" ? value.access : value.key}`, ...(typeof orgID === "string" ? { "x-org-id": orgID } : {}), } } function serverUrl(value: Credential.Value) { return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer } function withoutCredentials(body: Readonly> | undefined) { return ( body && Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key))) ) } function normalizeServer(input: unknown) { return Effect.try({ try: () => { if (typeof input !== "string") throw new Error("expected string") const url = new URL(input) if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") return `${url.origin}${url.pathname.replace(/\/+$/, "")}` }, catch: (cause) => new Error(`Invalid OpenCode server URL: ${cause instanceof Error ? cause.message : String(cause)}`), }) } function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) { const loop = (wait: Duration.Duration): Effect.Effect => Effect.gen(function* () { yield* Effect.sleep(wait) const result = yield* post( http, `${server}/auth/device/token`, { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: clientID, }, DeviceToken, false, ) if ("access_token" in result) return yield* credential(http, server, result) if (result.error === "authorization_pending") return yield* loop(wait) if (result.error === "slow_down") { return yield* loop(Duration.sum(wait, Duration.seconds(5))) } return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`)) }) return loop(interval) } function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) { return Effect.gen(function* () { const [user, orgs] = yield* Effect.all( [ get(http, `${server}/api/user`, token.access_token, User), get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)), ], { concurrency: 2 }, ) const org = token.org_id == null ? orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0] : orgs.find((org) => org.id === token.org_id) if (token.org_id != null && !org) { return yield* Effect.fail(new Error(`OpenCode organization not found: ${token.org_id}`)) } return Credential.OAuth.make({ type: "oauth" as const, methodID, access: token.access_token, refresh: token.refresh_token, expires: Date.now() + token.expires_in * 1000, metadata: { server, accountID: user.id, email: user.email, orgID: org?.id, orgName: org?.name, }, }) }) } function get(http: HttpClient.HttpClient, url: string, token: string, schema: S) { return HttpClient.filterStatusOk(http) .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token))) .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))) } function post( http: HttpClient.HttpClient, url: string, body: Record, schema: S, statusOk = true, ) { return send(http, url, body).pipe( Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))), Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), ) } function send(http: HttpClient.HttpClient, url: string, body: Record) { return HttpClientRequest.post(url).pipe( HttpClientRequest.acceptJson, HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body), Effect.flatMap((request) => http.execute(request)), ) }